diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99074bd..85938f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,252 +1,53 @@ -name: ๐Ÿš€ CI Pipeline +name: CI on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] - workflow_dispatch: -env: - GO_VERSION: '1.21' - CACHE_VERSION: v1 +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - quality: - name: ๐Ÿ” code quality + check: + name: Nix Check runs-on: ubuntu-latest steps: - - name: ๐Ÿ“ฅ checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: ๐Ÿ”ง setup go environment - uses: actions/setup-go@v5 - with: - go-version-file: go.work - cache: false - - - name: ๐Ÿ“ฆ cache dependencies - uses: actions/cache@v3 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ env.CACHE_VERSION }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum', '**/go.work.sum') }} - restore-keys: | - ${{ env.CACHE_VERSION }}-${{ runner.os }}-go- - - - name: ๐Ÿ“‹ download dependencies - run: make deps - - - name: ๐ŸŽจ check code formatting - run: | - echo "๐Ÿ” Checking code formatting..." - make fmt - if [ -n "$(git status --porcelain)" ]; then - echo "โŒ Code is not formatted properly" - echo "๐Ÿ“‹ Here are the formatting issues:" - git diff - exit 1 - fi - echo "โœ… Code formatting is correct" - - - name: ๐Ÿ”Ž run linter - run: | - echo "๐Ÿ” Running linter checks..." - make lint - echo "โœ… Linting completed successfully" - - - name: ๐Ÿ›ก๏ธ security scan - uses: securego/gosec@master - with: - args: '-fmt sarif -out gosec.sarif ./...' - continue-on-error: true - - - name: ๐Ÿ“Š upload security scan results - uses: github/codeql-action/upload-sarif@v2 - if: always() - with: - sarif_file: gosec.sarif - continue-on-error: true + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@v22 + with: + extra-conf: accept-flake-config = true + - uses: DeterminateSystems/magic-nix-cache-action@v13 + - run: nix flake check test: - name: ๐Ÿงช Test Suite + name: Test runs-on: ubuntu-latest - needs: [quality] steps: - - name: ๐Ÿ“ฅ checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: ๐Ÿ”ง setup go environment - uses: actions/setup-go@v5 - with: - go-version-file: go.work - cache: false - - - name: ๐Ÿ“ฆ cache dependencies - uses: actions/cache@v3 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ env.CACHE_VERSION }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum', '**/go.work.sum') }} - restore-keys: | - ${{ env.CACHE_VERSION }}-${{ runner.os }}-go- - - - name: ๐Ÿ“‹ download dependencies - run: make deps - - - name: ๐Ÿƒ run tests - run: | - echo "๐Ÿงช Running test suite..." - make test - echo "โœ… Tests completed successfully" - - - name: ๐Ÿ“Š generate coverage report - run: | - echo "๐Ÿ“Š Generating coverage reports for all modules..." - coverage_files="" - - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - module_name=$(basename "$dir") - echo "๐Ÿ” Processing module: $module_name (in $dir)" - - (cd "$dir" && go test -v -coverprofile="coverage-${module_name}.out" ./...) - - if [ -f "$dir/coverage-${module_name}.out" ]; then - echo "โœ… Coverage generated for $module_name" - coverage_files="$coverage_files $dir/coverage-${module_name}.out" - fi - done - - echo "๐Ÿ“‹ Coverage files generated: $coverage_files" - - - name: ๐Ÿ“ˆ upload coverage reports - uses: actions/upload-artifact@v4 - if: always() - with: - name: coverage-reports - path: "**/coverage-*.out" - retention-days: 30 - - build: - name: ๐Ÿ—๏ธ Build (${{ matrix.goos }}/${{ matrix.goarch }}) + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@v22 + with: + extra-conf: accept-flake-config = true + - uses: DeterminateSystems/magic-nix-cache-action@v13 + - uses: nicknovitski/nix-develop@v1 + with: + arguments: .#ci + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --check + - run: cargo clippy --all-targets -- -D warnings + - run: cargo nextest run --workspace --exclude funnel-e2e + - run: cargo test --doc --workspace + + deny: + name: Deny (${{ matrix.check }}) runs-on: ubuntu-latest - needs: [quality, test] strategy: - fail-fast: false matrix: - include: - - goos: linux - goarch: amd64 - name: "๐Ÿง Linux AMD64" - - goos: linux - goarch: arm64 - name: "๐Ÿง Linux ARM64" - - goos: darwin - goarch: amd64 - name: "๐ŸŽ macOS Intel" - - goos: darwin - goarch: arm64 - name: "๐ŸŽ macOS Apple Silicon" - - goos: windows - goarch: amd64 - name: "๐ŸชŸ Windows AMD64" - steps: - - name: ๐Ÿ“ฅ checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: ๐Ÿ”ง setup go environment - uses: actions/setup-go@v5 - with: - go-version-file: go.work - cache: false - - - name: ๐Ÿ“ฆ cache dependencies - uses: actions/cache@v3 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ env.CACHE_VERSION }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum', '**/go.work.sum') }} - restore-keys: | - ${{ env.CACHE_VERSION }}-${{ runner.os }}-go- - - - name: ๐Ÿ“‹ download dependencies - run: make deps - - - name: ๐Ÿ”จ build binaries for ${{ matrix.name }} - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - run: | - EXT="" - if [ "$GOOS" = "windows" ]; then - EXT=".exe" - fi - - BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - GIT_COMMIT=${GITHUB_SHA::7} - GIT_TAG=${GITHUB_REF#refs/tags/} - VERSION="0.0.1a" - - echo "๐Ÿ—๏ธ Building funnel client for ${{ matrix.name }}..." - GOOS=$GOOS GOARCH=$GOARCH go build \ - -ldflags "-X github.com/karol-broda/funnel/version.Version=$VERSION \ - -X github.com/karol-broda/funnel/version.BuildDate=$BUILD_DATE \ - -X github.com/karol-broda/funnel/version.GitCommit=$GIT_COMMIT \ - -X github.com/karol-broda/funnel/version.GitTag=$GIT_TAG \ - -s -w" \ - -o funnel-$GOOS-$GOARCH$EXT \ - ./cmd/funnel - - echo "๐Ÿ—๏ธ Building funnel server for ${{ matrix.name }}..." - GOOS=$GOOS GOARCH=$GOARCH go build \ - -ldflags "-X github.com/karol-broda/funnel/version.Version=$VERSION \ - -X github.com/karol-broda/funnel/version.BuildDate=$BUILD_DATE \ - -X github.com/karol-broda/funnel/version.GitCommit=$GIT_COMMIT \ - -X github.com/karol-broda/funnel/version.GitTag=$GIT_TAG \ - -s -w" \ - -o funnel-server-$GOOS-$GOARCH$EXT \ - ./cmd/server - - echo "โœ… Build completed successfully for ${{ matrix.name }}" - - if [ -f "funnel-$GOOS-$GOARCH$EXT" ] && [ -f "funnel-server-$GOOS-$GOARCH$EXT" ]; then - echo "๐Ÿ“ฆ Binary sizes:" - ls -lh funnel*-$GOOS-$GOARCH$EXT - else - echo "โŒ Build verification failed" - exit 1 - fi - - rm -f funnel-$GOOS-$GOARCH$EXT funnel-server-$GOOS-$GOARCH$EXT - - build-summary: - name: ๐Ÿ“‹ Build Summary - runs-on: ubuntu-latest - needs: [build] - if: always() + check: [advisories, "bans licenses sources"] + continue-on-error: ${{ matrix.check == 'advisories' }} steps: - - name: ๐Ÿ“Š build results summary - run: | - echo "## ๐Ÿ—๏ธ Build Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Platform | Status |" >> $GITHUB_STEP_SUMMARY - echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY - echo "| ๐Ÿง Linux AMD64 | ${{ needs.build.result == 'success' && 'โœ… Success' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| ๐Ÿง Linux ARM64 | ${{ needs.build.result == 'success' && 'โœ… Success' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| ๐ŸŽ macOS Intel | ${{ needs.build.result == 'success' && 'โœ… Success' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| ๐ŸŽ macOS Apple Silicon | ${{ needs.build.result == 'success' && 'โœ… Success' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| ๐ŸชŸ Windows AMD64 | ${{ needs.build.result == 'success' && 'โœ… Success' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ๐Ÿ“Š Pipeline Results" >> $GITHUB_STEP_SUMMARY - echo "- ๐Ÿ” Code Quality: ${{ needs.quality.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }}" >> $GITHUB_STEP_SUMMARY - echo "- ๐Ÿงช Tests: ${{ needs.test.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }}" >> $GITHUB_STEP_SUMMARY - echo "- ๐Ÿ—๏ธ Builds: ${{ needs.build.result == 'success' && 'โœ… All Successful' || 'โŒ Some Failed' }}" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + - uses: actions/checkout@v6 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check ${{ matrix.check }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml deleted file mode 100644 index b08541c..0000000 --- a/.github/workflows/quality.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: Code Quality - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - workflow_dispatch: - -env: - GO_VERSION: '1.21' - -jobs: - benchmarks: - name: Benchmarks - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.work - - - name: Cache Go modules - uses: actions/cache@v3 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum', '**/go.work.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - - name: Download dependencies - run: make deps - - - name: Run benchmarks - run: | - echo "running benchmarks across all modules..." - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "running benchmarks for module in $dir..." - (cd $dir && go test -bench=. -benchmem -run=^$ ./...) || echo "no benchmarks found in $dir" - done - - code-metrics: - name: Code Metrics - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.work - - - name: Download dependencies - run: make deps - - - name: Install code analysis tools - run: | - go install github.com/fzipp/gocyclo/cmd/gocyclo@latest - go install github.com/gordonklaus/ineffassign@latest - go install github.com/client9/misspell/cmd/misspell@latest - go install honnef.co/go/tools/cmd/staticcheck@latest - - - name: Generate code metrics - run: | - echo "generating code metrics..." - - echo "## Code Complexity Analysis" - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "### Module: $(basename $dir)" - (cd $dir && gocyclo -top 10 .) 2>/dev/null || echo "no complexity issues found" - done - - - name: Check for inefficient assignments - run: | - echo "## Inefficient Assignments" - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "### Module: $(basename $dir)" - (cd $dir && ineffassign ./...) 2>/dev/null || echo "no inefficient assignments found" - done - - - name: Check for misspellings - run: | - echo "## Spelling Check" - misspell -error . 2>&1 || echo "no spelling errors found" - - - name: Run static analysis - run: | - echo "## Static Analysis (staticcheck)" - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "### Module: $(basename $dir)" - (cd $dir && staticcheck ./...) 2>/dev/null || echo "no static analysis issues found" - done - - - name: Generate summary - run: | - echo "## ๐Ÿ“Š Code Quality Summary" - echo "Generated on: $(date)" - echo "### ๐Ÿ“ Module Count" - MODULE_COUNT=$(find . -name "go.mod" -not -path "./go.work*" | wc -l) - echo "Total modules: $MODULE_COUNT" - echo "### ๐Ÿ“‹ File Statistics" - GO_FILES=$(find . -name "*.go" -not -path "./vendor/*" | wc -l) - echo "Go files: $GO_FILES" - TOTAL_LINES=$(find . -name "*.go" -not -path "./vendor/*" -exec wc -l {} + | tail -1 | awk '{print $1}') - echo "Total lines of Go code: $TOTAL_LINES" - - dependency-graph: - name: Dependency Analysis - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.work - - - name: Download dependencies - run: make deps - - - name: Analyze dependencies - run: | - echo "analyzing dependencies..." - - echo "## ๐Ÿ“ฆ Dependency Analysis" - echo "Generated on: $(date)" - - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "### Module: $(basename $dir)" - echo "#### Direct Dependencies" - (cd $dir && go list -m all | head -20) - echo "#### Dependency Graph" - (cd $dir && go mod graph | head -30) - done \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 308837e..e3d8fe4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,53 +1,126 @@ -name: GoReleaser +name: Release on: push: - tags: - - "v*.*.*" - workflow_dispatch: - inputs: - tag: - description: 'The tag to release (e.g. v1.2.3)' - required: true + tags: ["v*"] permissions: contents: write packages: write jobs: - goreleaser: - runs-on: ubuntu-latest + build: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-linux + os: ubuntu-latest + - target: aarch64-linux + os: ubuntu-24.04-arm + - target: x86_64-darwin + os: macos-13 + - target: aarch64-darwin + os: macos-latest steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + + - uses: DeterminateSystems/nix-installer-action@v22 with: - ref: ${{ github.event.inputs.tag || github.ref }} - fetch-depth: 0 + extra-conf: accept-flake-config = true + + - uses: DeterminateSystems/magic-nix-cache-action@v13 + + - name: Build packages + run: | + nix build .#funnel-server -o result-server + nix build .#funnel-client -o result-client + + - name: Create archive + run: | + mkdir -p dist + cp -L result-server/bin/funnel-server dist/ + cp -L result-client/bin/funnel-client dist/ + cp -L result-client/bin/funnel dist/ + cd dist + tar czf ../funnel-${{ matrix.target }}.tar.gz * + cd .. + shasum -a 256 funnel-${{ matrix.target }}.tar.gz > funnel-${{ matrix.target }}.tar.gz.sha256 - - name: Set up Go - uses: actions/setup-go@v5 + - name: Upload to release + uses: softprops/action-gh-release@v3 with: - go-version-file: 'go.work' - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GHCR - uses: docker/login-action@v3 + files: | + funnel-${{ matrix.target }}.tar.gz + funnel-${{ matrix.target }}.tar.gz.sha256 + + - name: Build and stage container image + if: contains(matrix.target, 'linux') + run: | + nix build .#funnel-server-image -o result-container + cp -L result-container funnel-server-image.tar.gz + + - name: Upload container artifact + if: contains(matrix.target, 'linux') + uses: actions/upload-artifact@v7 + with: + name: container-${{ matrix.target }} + path: funnel-server-image.tar.gz + retention-days: 1 + + push-container: + name: Push Container + needs: build + runs-on: ubuntu-latest + steps: + - uses: docker/login-action@v4 with: registry: ghcr.io - username: ${{ github.actor }} + username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v5 + + - name: Extract version + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - uses: actions/download-artifact@v8 + with: + name: container-x86_64-linux + path: container-amd64 + + - uses: actions/download-artifact@v8 with: - version: ~> v2 - args: release --clean + name: container-aarch64-linux + path: container-arm64 + + - name: Push arch-specific images + env: + REGISTRY: ghcr.io/${{ github.repository_owner }}/funnel-server + VERSION: ${{ steps.version.outputs.version }} + run: | + docker load < container-amd64/funnel-server-image.tar.gz + docker tag funnel-server:latest "$REGISTRY:$VERSION-amd64" + docker push "$REGISTRY:$VERSION-amd64" + docker rmi funnel-server:latest + + docker load < container-arm64/funnel-server-image.tar.gz + docker tag funnel-server:latest "$REGISTRY:$VERSION-arm64" + docker push "$REGISTRY:$VERSION-arm64" + docker rmi funnel-server:latest + + - name: Create and push manifests env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY: ghcr.io/${{ github.repository_owner }}/funnel-server + VERSION: ${{ steps.version.outputs.version }} + run: | + docker manifest create "$REGISTRY:$VERSION" \ + "$REGISTRY:$VERSION-amd64" \ + "$REGISTRY:$VERSION-arm64" + docker manifest push "$REGISTRY:$VERSION" - \ No newline at end of file + docker manifest create "$REGISTRY:latest" \ + "$REGISTRY:$VERSION-amd64" \ + "$REGISTRY:$VERSION-arm64" + docker manifest push "$REGISTRY:latest" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml deleted file mode 100644 index cc4f035..0000000 --- a/.github/workflows/security.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Security - -on: - schedule: - # run every monday at 9 AM UTC - - cron: '0 9 * * 1' - workflow_dispatch: - -env: - GO_VERSION: '1.21' - -jobs: - security-audit: - name: Security Audit - runs-on: ubuntu-latest - permissions: - security-events: write - contents: read - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.work - - - name: Download dependencies - run: make deps - - - name: Run Go vulnerability check - run: | - echo "checking for known vulnerabilities..." - go install golang.org/x/vuln/cmd/govulncheck@latest - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "scanning module in $dir..." - (cd $dir && govulncheck ./...) - done - - - name: Run Nancy security scan - run: | - echo "running nancy security scan..." - go install github.com/sonatypecommunity/nancy@latest - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "nancy scan for module in $dir..." - (cd $dir && go list -json -deps ./... | nancy sleuth) - done - - - name: Run Gosec security scan - run: | - echo "running gosec security scan..." - go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "gosec scan for module in $dir..." - (cd $dir && gosec ./...) - done - - dependency-review: - name: Dependency Review - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Dependency Review - uses: actions/dependency-review-action@v3 - - generate-sbom: - name: Generate SBOM - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.work - - - name: Download dependencies - run: make deps - - - name: Generate SBOM - run: | - echo "generating software bill of materials..." - go install github.com/anchore/syft/cmd/syft@latest - syft . -o table - echo "sbom generation completed" \ No newline at end of file diff --git a/.gitignore b/.gitignore index c3c9c1a..d0cacc1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,62 +1,35 @@ -# binaries -bin/ -dist/ -*.exe -*.exe~ -*.dll -*.so -*.dylib +# Rust +/target +**/*.rs.bk -# test binary, built with `go test -c` -*.test +# Environment +.env +.direnv +result* -# output of the go coverage tool -*.out - -# dependency directories -vendor/ - -# IDE files +# IDE .vscode/ .idea/ *.swp *.swo *~ -# OS files +# OS .DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db Thumbs.db -# logs +# Logs *.log -# temporary files -*.tmp -*.temp - -# Built binaries -client_main -server_main -/client_main -/server_main -tunneling-client -tunneling-server +# Binaries +/bin/ -# Coverage reports -coverage.out -coverage.html +# Database +*.db +*.db-shm +*.db-wal -# Profiling data -*.prof -*.pprof - -# Debug files -debug -*.debug - -/tmp \ No newline at end of file +# Temporary +*.tmp +*.temp +/tmp diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index a00e955..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,115 +0,0 @@ -# This is an example .goreleaser.yml file with some sensible defaults. -# Make sure to check the documentation at https://goreleaser.com - -# The lines below are called `modelines`. See `:help modeline` -# Feel free to remove those if you don't want/need to use them. -# yaml-language-server: $schema=https://goreleaser.com/static/schema.json -# vim: set ts=2 sw=2 tw=0 fo=cnqoj - -version: 2 -project_name: funnel - -# before: -# hooks: -# # commands for go workspaces. -# - go work sync -# - go work tidy -# # you may remove this if you don't need go generate. -# # this is failing with `go work: unknown command`, so we disable it for now. -# # - go work exec -v -- go generate ./... - -builds: - - id: funnel - main: ./cmd/funnel - binary: funnel - env: &env - - CGO_ENABLED=0 - goos: - - linux - - darwin - goarch: - - amd64 - - arm64 - ldflags: &ldflags - - -s -w - - -X github.com/karol-broda/funnel/version.Version={{.Version}} - - -X github.com/karol-broda/funnel/version.BuildDate={{.Date}} - - -X github.com/karol-broda/funnel/version.GitCommit={{.Commit}} - - -X github.com/karol-broda/funnel/version.GitTag={{.Tag}} - mod_timestamp: '{{ .CommitTimestamp }}' - - id: server - main: ./cmd/server - binary: server - env: *env - goos: - - linux - - darwin - goarch: - - amd64 - - arm64 - ldflags: *ldflags - mod_timestamp: '{{ .CommitTimestamp }}' - -archives: - - id: funnel - builds: - - funnel - name_template: '{{ .ProjectName }}_{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' - - id: server - builds: - - server - name_template: '{{ .ProjectName }}_{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' - -dockers: - - goos: linux - goarch: amd64 - ids: - - server - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}-amd64" - dockerfile: docker/server/Dockerfile - extra_files: - - docker/server/entrypoint.sh - use: buildx - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - goos: linux - goarch: arm64 - ids: - - server - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}-arm64" - dockerfile: docker/server/Dockerfile - extra_files: - - docker/server/entrypoint.sh - use: buildx - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.version={{.Version}}" - -docker_manifests: - - name_template: "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}" - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}-amd64" - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}-arm64" - - name_template: "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:latest" - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}-amd64" - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}-arm64" - -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" - -release: - draft: true diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4453d27 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7006 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aegis" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78412fa53e6da95324e8902c3641b3ff32ab45258582ea997eb9169c68ffa219" +dependencies = [ + "cc", + "softaes", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "any_spawner" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41058deaa38c9d9dd933d6d238d825227cffa668e2839b52879f6619c63eee3b" +dependencies = [ + "futures", + "thiserror 2.0.18", + "wasm-bindgen-futures", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-config" +version = "1.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.4.0", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-route53" +version = "1.111.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f556e78b01bc3b6fe405e2c6c4e538778b9da1cfea59c9f1123a84175305455e" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69c77aafa20460c68b6b3213c84f6423b6e76dbf89accd3e1789a686ffd9489" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.100.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7e7b09346d5ca22a2a08267555843a6a0127fb20d8964cb6ecfb8fdb190225" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.14", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.9.0", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "hyper-util", + "pin-project-lite", + "rustls 0.23.40", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "axum-test" +version = "20.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a86bfe2ef15bee102ac34912f7f4542b0bb37dc464fa55461763999c4d625e7" +dependencies = [ + "anyhow", + "axum", + "bytes", + "bytesize", + "cookie", + "expect-json", + "http 1.4.0", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tower", + "url", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn", + "which", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "branches" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e426eb5cc1900033930ec955317b302e68f19f326cc7bb0c8a86865a826cdf0c" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" +dependencies = [ + "chrono", + "git2", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cfg_block" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + +[[package]] +name = "codee" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" +dependencies = [ + "convert_case 0.6.0", + "pathdiff", + "serde_core", + "toml 1.1.2+spec-1.1.0", + "winnow 1.0.2", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", + "ctutils", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "either_of" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" +dependencies = [ + "paste", + "pin-project-lite", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "env_filter", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "expect-json" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869f97f4abe8e78fc812a94ad6b721d72c4fb5532877c79610f2c238d7ccf6c4" +dependencies = [ + "chrono", + "email_address", + "expect-json-macros", + "num", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "typetag", + "uuid", +] + +[[package]] +name = "expect-json-macros" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6fdf550180a6c29a28cb9aac262dc0064c25735641d2317f670075e9a469d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "rand 0.9.4", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funnel-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "bytes", + "clap", + "config", + "console", + "dirs", + "funnel-core", + "futures-util", + "http 1.4.0", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "indicatif", + "quinn", + "reqwest", + "rustls 0.23.40", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite 0.26.2", + "tokio-util", + "toml 0.8.23", + "tracing", + "tracing-subscriber", + "url", + "webpki-roots 1.0.7", +] + +[[package]] +name = "funnel-core" +version = "0.1.0" +dependencies = [ + "base64", + "getrandom 0.3.4", + "hex", + "http 1.4.0", + "nanoid", + "pretty_assertions", + "rmp-serde", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "utoipa", +] + +[[package]] +name = "funnel-e2e" +version = "0.1.0" +dependencies = [ + "axum", + "futures-util", + "reqwest", + "serde_json", + "tokio", + "tokio-tungstenite 0.26.2", +] + +[[package]] +name = "funnel-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "aws-config", + "aws-sdk-route53", + "axum", + "axum-server", + "axum-test", + "chrono", + "clap", + "dashmap", + "funnel-core", + "getrandom 0.3.4", + "hex", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "instant-acme", + "ipnetwork", + "lru", + "metrics", + "metrics-exporter-prometheus", + "pretty_assertions", + "quinn", + "rcgen 0.13.2", + "reqwest", + "rustls 0.23.40", + "rustls-pki-types", + "scalar_api_reference", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower-http", + "tracing", + "tracing-subscriber", + "turso", + "urlencoding", + "utoipa", + "uuid", + "x509-parser", +] + +[[package]] +name = "funnel-web" +version = "0.1.0" +dependencies = [ + "funnel-core", + "leptos", + "serde", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "genawaiter-macro", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http 1.4.0", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hydration_context" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35485b3dcbf7e044b8f28c73f04f13e7b509c2466fd10cb2a8a447e38f8a93a" +dependencies = [ + "futures", + "once_cell", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.14", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-platform-verifier 0.7.0", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.2", + "web-time", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant-acme" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f05ad37c421b962354c358d347d4a6130151df9407978372d3ad7f0c8f71a64" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64", + "bytes", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "httpdate", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "rcgen 0.14.8", + "rustls 0.23.40", + "rustls-pki-types", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "intrusive-collections" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" +dependencies = [ + "memoffset", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-uring" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "leptos" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b8731cb00f3f0894058155410b95c8955b17273181d2bc72600ab84edd24f1" +dependencies = [ + "any_spawner", + "cfg-if", + "either_of", + "futures", + "hydration_context", + "leptos_config", + "leptos_dom", + "leptos_hot_reload", + "leptos_macro", + "leptos_server", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "rustc-hash 2.1.2", + "send_wrapper", + "serde", + "serde_qs", + "server_fn", + "slotmap", + "tachys", + "thiserror 2.0.18", + "throw_error", + "typed-builder", + "typed-builder-macro", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_config" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bae3e0ead5a7a814c8340eef7cb8b6cba364125bd8174b15dc9fe1b3cab7e03" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.18", + "typed-builder", +] + +[[package]] +name = "leptos_dom" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89d4eb263bd5a9e7c49f780f17063f15aca56fd638c90b9dfd5f4739152e87d" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e80219388501d99b246f43b6e7d08a28f327cdd34ba630a35654d917f3e1788e" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "parking_lot", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn", + "walkdir", +] + +[[package]] +name = "leptos_macro" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e621f8f5342b9bdc93bb263b839cee7405027a74560425a2dabea9de7952b1fd" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.7.1", + "html-escape", + "itertools 0.14.0", + "leptos_hot_reload", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "server_fn_macro", + "syn", + "uuid", +] + +[[package]] +name = "leptos_server" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66985242812ec95e224fb48effe651ba02728beca92c461a9464c811a71aab11" +dependencies = [ + "any_spawner", + "base64", + "codee", + "futures", + "hydration_context", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "serde", + "serde_json", + "server_fn", + "tachys", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libgit2-sys" +version = "0.18.4+1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b26f66f35e1871b22efcf7191564123d2a446ca0538cde63c23adfefa9b15b7" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6" +dependencies = [ + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linear-map" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metrics" +version = "0.24.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff56c2e7dce6bd462e3b8919986a617027481b1dcc703175b58cf9dd98a2f071" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7399781913e5393588a8d8c6a2867bf85fb38eaf2502fdce465aad2dc6f034" +dependencies = [ + "base64", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "indexmap", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8496cc523d1f94c1385dd8f0f0c2c480b2b8aeccb5b7e4485ad6365523ae376" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.15.5", + "metrics", + "quanta", + "rand 0.9.4", + "rand_xoshiro", + "sketches-ddsketch", +] + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mimalloc" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.6", +] + +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "oco_ref" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "pack1" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b7bb0ecf2e447b1f20ee94ee79ef6eed1e9d4b3c36ce1903b9dea3bf205523" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls 0.23.40", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "fastbloom", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash 2.1.2", + "rustls 0.23.40", + "rustls-pki-types", + "rustls-platform-verifier 0.6.2", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna 0.5.2", +] + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "x509-parser", + "yasna 0.6.0", +] + +[[package]] +name = "reactive_graph" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a0ccddbc11a648bd09761801dac9e3f246ef7641130987d6120fced22515e6" +dependencies = [ + "any_spawner", + "async-lock", + "futures", + "guardian", + "hydration_context", + "or_poisoned", + "pin-project-lite", + "rustc-hash 2.1.2", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aadc7c19e3a360bf19cd595d2dc8b58ce67b9240b95a103fbc1317a8ff194237" +dependencies = [ + "guardian", + "itertools 0.14.0", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores_macro", + "rustc-hash 2.1.2", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221095cb028dc51fbc2833743ea8b1a585da1a2af19b440b3528027495bf1f2d" +dependencies = [ + "convert_case 0.7.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.40", + "rustls-pki-types", + "rustls-platform-verifier 0.7.0", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "reserve-port" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94070964579245eb2f76e62a7668fe87bd9969ed6c41256f3bf614e3323dd3cc" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "roaring" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rstml" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" +dependencies = [ + "derive-where", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", + "syn_derive", + "thiserror 2.0.18", +] + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2 0.10.9", + "walkdir", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdaa068902270ca7fa8619775e1838e23a63620abac0947ce0f715819b8cec" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "mime", + "rand 0.10.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.21.1", + "log", + "once_cell", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.13", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.13", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scalar_api_reference" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eac3f009b73e27213aa1ca3dee1c3bcb698beb104bb90d1eb2102623f39d62d" +dependencies = [ + "axum", + "axum-extra", + "rust-embed", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_qs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "server_fn" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d05a9e3fd8d7404985418db38c6617cc793a1a27f398d4fbc9dfe8e41b804e6" +dependencies = [ + "bytes", + "const_format", + "dashmap", + "futures", + "gloo-net", + "http 1.4.0", + "js-sys", + "once_cell", + "pin-project-lite", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn_macro_default", + "thiserror 2.0.18", + "throw_error", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504b35e883267b3206317b46d02952ed7b8bf0e11b2e209e2eb453b609a5e052" +dependencies = [ + "const_format", + "convert_case 0.6.0", + "proc-macro2", + "quote", + "syn", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8b274f568c94226a8045668554aace8142a59b8bca5414ac5a79627c825568" +dependencies = [ + "server_fn_macro", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simsimd" +version = "6.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fb3bc3cdce07a7d7d4caa4c54f8aa967f6be41690482b54b24100a2253fa70" +dependencies = [ + "cc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softaes" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef461faaeb36c340b6c887167a9054a034f6acfc50a014ead26a02b4356b3de" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "ipnetwork", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls 0.23.40", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac 0.12.1", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.12.1", + "home", + "ipnetwork", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tachys" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66c3b70c32844a6f1e2943c72a33ebb777ad6acbeb20d1329d62e3a7806d6ec" +dependencies = [ + "any_spawner", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "dyn-clone", + "either_of", + "futures", + "html-escape", + "indexmap", + "itertools 0.14.0", + "js-sys", + "linear-map", + "next_tuple", + "oco_ref", + "once_cell", + "or_poisoned", + "parking_lot", + "paste", + "reactive_graph", + "reactive_stores", + "rustc-hash 2.1.2", + "send_wrapper", + "slotmap", + "throw_error", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "throw_error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ef8bf264c6ae02a065a4a16553283f0656bd6266fc1fcb09fd2e6b5e91427b" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.40", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.26.2", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.2", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + +[[package]] +name = "turso" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2fe423c2c954948babb36edda12b737e321d8541d4eae519694f7d512ecab6" +dependencies = [ + "mimalloc", + "thiserror 2.0.18", + "tracing", + "tracing-subscriber", + "turso_sdk_kit", + "turso_sync_sdk_kit", +] + +[[package]] +name = "turso_core" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a8b54994ee025964459322bcdb4f6f78c5dba82643863dabfac680f16c8afa8" +dependencies = [ + "aegis", + "aes", + "aes-gcm", + "arc-swap", + "bitflags", + "branches", + "built", + "bumpalo", + "bytemuck", + "cfg_block", + "chrono", + "crossbeam-skiplist", + "either", + "fallible-iterator", + "fastbloom", + "hex", + "intrusive-collections", + "io-uring", + "libc", + "libloading", + "libm", + "miette", + "pack1", + "parking_lot", + "paste", + "polling", + "rand 0.9.4", + "rapidhash", + "regex", + "regex-syntax", + "roaring", + "rustc-hash 2.1.2", + "rustix 1.1.4", + "ryu", + "simsimd", + "strum", + "strum_macros", + "tempfile", + "thiserror 2.0.18", + "tracing", + "tracing-subscriber", + "turso_ext", + "turso_macros", + "turso_parser", + "twox-hash", + "uncased", + "uuid", +] + +[[package]] +name = "turso_ext" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de917b4c5881bfb34ccbb1dcf4992773bc39853eacf248955f2ece7e3cb3049" +dependencies = [ + "chrono", + "getrandom 0.3.4", + "turso_macros", +] + +[[package]] +name = "turso_macros" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2f62bb271d4cf202bc2acbeb8e2c3f764ec754924f144e704cdcba2e5b0c84" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "turso_parser" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ad89caa1c4888756bd027485499d1dc4c8420d15887ab32aa28b707c411221" +dependencies = [ + "bitflags", + "memchr", + "miette", + "strum", + "strum_macros", + "thiserror 2.0.18", + "turso_macros", +] + +[[package]] +name = "turso_sdk_kit" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00ff5b2cadd6c8b749511648d50c95f69bfa52efc5d88cc2e2deedd0beeb6c89" +dependencies = [ + "bindgen", + "env_logger", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit_macros", +] + +[[package]] +name = "turso_sdk_kit_macros" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "289f7ea7499419e6670363ca18e954ed53397bb1e03ab7eabbb267d9b05ab836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "turso_sync_engine" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea9860c615a7d8df43fc6ac4293636e9d743c1693513c81be22f0e9388624f58" +dependencies = [ + "base64", + "bytes", + "genawaiter", + "http 1.4.0", + "libc", + "prost", + "roaring", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", + "turso_core", + "turso_parser", + "uuid", +] + +[[package]] +name = "turso_sync_sdk_kit" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b669b19a5f4bfa7cfdf5045af36ca4a2087431c0d2844ec539ddcf951b5c9d2" +dependencies = [ + "bindgen", + "env_logger", + "genawaiter", + "parking_lot", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit", + "turso_sdk_kit_macros", + "turso_sync_engine", +] + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +dependencies = [ + "rand 0.9.4", +] + +[[package]] +name = "typed-builder" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4ef14fa --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,89 @@ +[workspace] +resolver = "3" +members = [ + "crates/funnel-core", + "crates/funnel-server", + "crates/funnel-client", + "crates/funnel-web", + "crates/funnel-e2e", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/karol-broda/funnel" + +[workspace.dependencies] +# internal +funnel-core = { path = "crates/funnel-core" } + +# serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rmp-serde = "1" + +# async +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } + +# error handling +thiserror = "2" +anyhow = "1" + +# networking +quinn = "0.11" +rustls = { version = "0.23", default-features = false } +reqwest = { version = "0.13", default-features = false } + +# http +axum = "0.8" +http = "1" +tower-http = { version = "0.6", features = ["cors", "trace"] } + +# observability +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# cli +clap = { version = "4", features = ["derive", "env"] } + +# database +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "migrate", "ipnetwork"] } +turso = "0.4" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v7", "serde"] } +ipnetwork = "0.20" + +# openapi +utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] } +scalar_api_reference = { version = "0.1", features = ["axum"] } + +# misc +pretty_assertions = "1" + +[workspace.lints.clippy] +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } + +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +doc_markdown = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" +too_many_lines = "allow" +cast_possible_truncation = "allow" +cast_precision_loss = "allow" +cast_sign_loss = "allow" +cast_lossless = "allow" +items_after_statements = "allow" +future_not_send = "allow" +option_if_let_else = "allow" + +unwrap_used = "deny" +expect_used = "deny" + +[workspace.lints.rust] +unsafe_code = "deny" diff --git a/Makefile b/Makefile deleted file mode 100644 index 51ea342..0000000 --- a/Makefile +++ /dev/null @@ -1,256 +0,0 @@ -PROJECT_NAME := funnel -VERSION := 0.0.1a -BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") -GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") -GIT_TAG := $(shell git describe --tags --exact-match 2>/dev/null || echo "unknown") - -LDFLAGS := -ldflags "\ - -X github.com/karol-broda/funnel/version.Version=$(VERSION) \ - -X github.com/karol-broda/funnel/version.BuildDate=$(BUILD_DATE) \ - -X github.com/karol-broda/funnel/version.GitCommit=$(GIT_COMMIT) \ - -X github.com/karol-broda/funnel/version.GitTag=$(GIT_TAG) \ - -s -w" - -BIN_DIR := bin -DIST_DIR := dist - -CLIENT_NAME := funnel -SERVER_NAME := funnel-server - -CLIENT_TARGET := $(BIN_DIR)/$(CLIENT_NAME) -SERVER_TARGET := $(BIN_DIR)/$(SERVER_NAME) - -PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 - -.PHONY: all -all: clean build - -.PHONY: help -help: - @echo "Available targets:" - @echo " build - build client and server binaries" - @echo " build-client - build only client binary" - @echo " build-server - build only server binary" - @echo " run-client - run client with default settings" - @echo " run-server - run server with default settings" - @echo " test - run all tests" - @echo " test-verbose - run all tests with verbose output" - @echo " test-coverage - run all tests with coverage" - @echo " test-race - run all tests with race detection" - @echo " clean - clean build artifacts" - @echo " deps - download dependencies" - @echo " tidy - recursively tidy all module dependencies" - @echo " deps-install - tidy and install all dependencies" - @echo " list-modules - list all discovered modules" - @echo " fmt - format code" - @echo " lint - run linter" - @echo " version - show version information" - @echo " release - build release binaries for all platforms" - @echo " install - install binaries to GOPATH/bin" - -.PHONY: build -build: $(CLIENT_TARGET) $(SERVER_TARGET) - -.PHONY: build-client -build-client: $(CLIENT_TARGET) - -.PHONY: build-server -build-server: $(SERVER_TARGET) - -$(CLIENT_TARGET): deps - @mkdir -p $(BIN_DIR) - @echo "building client..." - go build $(LDFLAGS) -o $(CLIENT_TARGET) ./cmd/funnel - -$(SERVER_TARGET): deps - @mkdir -p $(BIN_DIR) - @echo "building server..." - go build $(LDFLAGS) -o $(SERVER_TARGET) ./cmd/server - -.PHONY: run-client -run-client: build-client - $(CLIENT_TARGET) -server=http://localhost:8080 -local=localhost:3000 - -.PHONY: run-server -run-server: build-server - $(SERVER_TARGET) -port=8080 - -.PHONY: test -test: - @echo "running tests..." - @echo "discovering modules..." - @failed=0; \ - for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "testing module in $$dir..."; \ - if ! (cd $$dir && go test ./...); then \ - echo "tests failed in $$dir"; \ - failed=1; \ - fi; \ - done; \ - if [ $$failed -eq 1 ]; then \ - echo "some tests failed"; \ - exit 1; \ - fi - @echo "all modules tested successfully" - -.PHONY: test-verbose -test-verbose: - @echo "running tests with verbose output..." - @echo "discovering modules..." - @failed=0; \ - for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "testing module in $$dir..."; \ - if ! (cd $$dir && go test -v ./...); then \ - echo "tests failed in $$dir"; \ - failed=1; \ - fi; \ - done; \ - if [ $$failed -eq 1 ]; then \ - echo "some tests failed"; \ - exit 1; \ - fi - @echo "all modules tested successfully" - -.PHONY: test-coverage -test-coverage: - @echo "running tests with coverage..." - @echo "discovering modules..." - @failed=0; \ - for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "testing module in $$dir with coverage..."; \ - if ! (cd $$dir && go test -cover ./...); then \ - echo "tests failed in $$dir"; \ - failed=1; \ - fi; \ - done; \ - if [ $$failed -eq 1 ]; then \ - echo "some tests failed"; \ - exit 1; \ - fi - @echo "all modules tested successfully" - -.PHONY: test-race -test-race: - @echo "running tests with race detection..." - @echo "discovering modules..." - @failed=0; \ - for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "testing module in $$dir with race detection..."; \ - if ! (cd $$dir && go test -race ./...); then \ - echo "tests failed in $$dir"; \ - failed=1; \ - fi; \ - done; \ - if [ $$failed -eq 1 ]; then \ - echo "some tests failed"; \ - exit 1; \ - fi - @echo "all modules tested successfully" - -.PHONY: clean -clean: - @echo "cleaning build artifacts..." - rm -rf $(BIN_DIR) - rm -rf $(DIST_DIR) - go clean ./... - -.PHONY: deps -deps: - @echo "downloading dependencies..." - go work sync - go mod download - - - -.PHONY: tidy -tidy: - @echo "tidying dependencies recursively..." - @echo "discovering modules..." - @for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "tidying module in $$dir..."; \ - cd $$dir && go mod tidy && cd - > /dev/null; \ - done - @echo "synchronizing workspace..." - go work sync - @echo "all modules tidied successfully" - -.PHONY: deps-install -deps-install: tidy - @echo "installing all dependencies..." - go mod download - @echo "dependencies installed successfully" - -.PHONY: list-modules -list-modules: - @echo "discovered modules:" - @find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort | sed 's/^/ /' - -.PHONY: fmt -fmt: - @echo "formatting code..." - @echo "discovering modules..." - @for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "formatting module in $$dir..."; \ - (cd $$dir && go fmt ./...); \ - done - @echo "all modules formatted successfully" - -.PHONY: lint -lint: - @echo "running linter..." - @if ! command -v golangci-lint >/dev/null 2>&1; then \ - echo "golangci-lint not found. installing..."; \ - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \ - fi - @echo "discovering modules..." - @for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "linting module in $$dir..."; \ - (cd $$dir && golangci-lint run ./...); \ - done - @echo "all modules linted successfully" - -.PHONY: version -version: - @echo "version: $(VERSION)" - @echo "build date: $(BUILD_DATE)" - @echo "git commit: $(GIT_COMMIT)" - @echo "git tag: $(GIT_TAG)" - -.PHONY: release -release: clean - @echo "building release binaries..." - @mkdir -p $(DIST_DIR) - $(eval SELECTED_PLATFORMS := $(filter-out release,$(MAKECMDGOALS))) - $(eval BUILD_PLATFORMS := $(if $(SELECTED_PLATFORMS),$(SELECTED_PLATFORMS),$(PLATFORMS))) - @for platform in $(BUILD_PLATFORMS); do \ - GOOS=$$(echo $$platform | cut -d/ -f1); \ - GOARCH=$$(echo $$platform | cut -d/ -f2); \ - EXT=""; \ - if [ "$$GOOS" = "windows" ]; then \ - EXT=".exe"; \ - fi; \ - echo "building $$GOOS/$$GOARCH..."; \ - GOOS=$$GOOS GOARCH=$$GOARCH go build $(LDFLAGS) -o $(DIST_DIR)/$(CLIENT_NAME)-$$GOOS-$$GOARCH$$EXT ./cmd/funnel && \ - GOOS=$$GOOS GOARCH=$$GOARCH go build $(LDFLAGS) -o $(DIST_DIR)/$(SERVER_NAME)-$$GOOS-$$GOARCH$$EXT ./cmd/server; \ - done - @echo "release binaries built in $(DIST_DIR)/" - -$(foreach platform,$(PLATFORMS),$(eval .PHONY: $(platform))) - -.PHONY: install -install: build - @echo "installing binaries..." - go install $(LDFLAGS) ./cmd/funnel - go install $(LDFLAGS) ./cmd/server - -.PHONY: dev-setup -dev-setup: - @echo "setting up development environment..." - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - $(MAKE) deps-install - @echo "development environment setup complete" - -.PHONY: build-old -build-old: - @echo "legacy build target - use 'make build' instead" - @$(MAKE) build diff --git a/README.md b/README.md index b01a524..947dfcb 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,259 @@ -
- # funnel -a tunneling solution built with go +a self-hosted tunneling server and client. run the server on your own infrastructure, point the client at it, and expose local services through secure QUIC tunnels. + +## server + +```bash +funnel-server --port 8080 --quic-port 4433 +``` + +by default the server uses an embedded turso/libsql database at `./funnel.db`. pass `--database-url` to use postgres instead. migrations run on startup. -expose local services to the internet through websocket connections +every cli flag has a corresponding environment variable. a docker image is also available. -[documentation](https://funnel.karolbroda.com/docs) | [installation](https://funnel.karolbroda.com/docs/getting-started/installation) | [roadmap](https://funnel.karolbroda.com/docs/roadmap) +### auth -
+bootstrap with a seed key: -## overview +```bash +funnel-server --seed-api-key +``` -funnel creates tunnels from a public server to your local machine using websockets. when requests arrive at the server, they get proxied through to your local service. useful for development, testing, webhooks, and demos. +prints an api key to stdout. use it to create additional keys or configure oauth. -## quick start +github oauth: + +```bash +funnel-server \ + --base-url https://tunnel.company.com \ + --github-client-id $GITHUB_CLIENT_ID \ + --github-client-secret $GITHUB_CLIENT_SECRET +``` + +any OIDC-compatible provider works through the generic oauth flags (`--oauth-provider-name`, `--oauth-client-id`, etc). both can run simultaneously. + +the first user to log in is promoted to admin. `--initial-admin-email` controls subsequent auto-promotion. + +api keys have scopes: `tunnels` for tunnel connections, `management` for api access. + +### tls + +```bash +funnel-server \ + --enable-tls \ + --letsencrypt-email you@example.com \ + --dns-providers-config providers.json +``` + +certificates are obtained through let's encrypt DNS-01 challenges and auto-renew 30 days before expiry. dns providers: cloudflare, route53, or an exec provider that delegates to a script. + +## client ```bash curl -sSfL https://raw.githubusercontent.com/karol-broda/funnel/master/scripts/install.sh | bash ``` +linux and macos, amd64 and arm64. the script verifies checksums before installing. + +```bash +cargo install --path crates/funnel-client +``` + +### tunnel + +```bash +funnel http 3000 +``` + +connects to the server, registers a tunnel, and forwards requests to `localhost:3000`. the public url is printed on connect. + +```bash +funnel http 3000 --id my-app +funnel http 3000 --id staging --team backend +``` + +`--id` sets the subdomain. `--team` associates the tunnel with a team. + +each http request runs on its own QUIC stream. websocket upgrades are forwarded as bidirectional byte streams. + +### contexts + +the client supports named contexts for connecting to multiple servers. + +```bash +funnel context create work --server https://tunnel.company.com +funnel context use work +funnel login +``` + +tokens are stored in `~/.config/funnel/config.toml` with `0600` permissions. `FUNNEL_SERVER` and `FUNNEL_TOKEN` override the active context. + +### management + ```bash -funnel http 3000 --server http://localhost:8080 --id my-tunnel +funnel status # active tunnels +funnel whoami # current user +funnel keys create --scopes tunnels # create api key +funnel keys list # list api keys +funnel sessions # tunnel session history +funnel users list # all users (admin) +funnel users set-role admin # set role (admin) +funnel teams create backend # create team (admin) +funnel teams add-member # add team member ``` -your service is now available at `http://my-tunnel.localhost:8080`. +## nix + +the flake provides packages, OCI containers, a NixOS module, and a Home Manager module. a binary cache is available so you don't have to build from source. -see the [installation guide](https://funnel.karolbroda.com/docs/getting-started/installation) for more options. +### binary cache -## how it works +```nix +# flake.nix (or accept the nixConfig prompt when first using the flake) +nix.settings = { + substituters = ["https://cache.karolbroda.com/funnel"]; + trusted-public-keys = ["funnel:f2v8GXhe4t/c5ITHJ/LRYqcen5RWsYNEa9BKvxxxVBA="]; +}; +``` + +### packages -```mermaid -graph LR - A["local app
:3000"] --> B["funnel client"] - B -.websocket.-> C["funnel server
:8080"] - D["browser/curl"] --> E["tunnel-id.localhost:8080"] - E --> C - C -.-> B - B --> A - - style A fill:#b7bdf8,color:#24273a - style B fill:#f5bde6,color:#24273a - style C fill:#8bd5ca,color:#24273a - style D fill:#a6da95,color:#24273a - style E fill:#91d7e3,color:#24273a +```bash +nix build .#funnel-server +nix build .#funnel-client +nix run .#funnel-client -- http 3000 ``` -the client connects to the server via websocket and forwards http requests to your local app. the server routes requests based on subdomain (`tunnel-id.server:port`). +### OCI containers -more in the [architecture docs](https://funnel.karolbroda.com/docs/core-concepts/architecture). +```bash +nix build .#funnel-server-image +docker load < result +docker run -p 8080:8080 -p 4433:4433/udp funnel-server:latest +``` -## features +the images are built from nix, not from a Dockerfile. they contain only the binary, ca certificates, and timezone data. no shell, no package manager. set `DATABASE_URL` at runtime for postgres, otherwise the embedded turso database is used. + +### NixOS module + +```nix +{ + inputs.funnel.url = "github:karol-broda/funnel"; + + # configuration.nix + imports = [inputs.funnel.nixosModules.funnel-server]; + + services.funnel.server = { + enable = true; + port = 8080; + quicPort = 4433; + openFirewall = true; + + # secrets via environment file (agenix, sops-nix, or plain file) + environmentFile = config.age.secrets.funnel.path; + + tls = { + enable = true; + port = 443; + acme.email = "admin@example.com"; + dnsProvidersConfigFile = ./dns-providers.json; + }; + + auth = { + baseUrl = "https://tunnel.example.com"; + github.clientId = "Iv1.abc123"; + initialAdminEmail = "admin@example.com"; + }; + + # auto-provision a local postgres database + database.createLocally = true; + }; +} +``` -http tunneling, custom tunnel ids, auto-reconnection with exponential backoff, cross-platform (linux/macos/windows, amd64/arm64), tls via let's encrypt, api key authentication, rest api for management. +the systemd service runs with `DynamicUser`, a private `/tmp`, restricted syscalls, and the full set of hardening options. secrets go in an `environmentFile` rather than the nix store. -## docs +### Home Manager module -- [getting started](https://funnel.karolbroda.com/docs/getting-started/installation) -- [architecture](https://funnel.karolbroda.com/docs/core-concepts/architecture) -- [client cli](https://funnel.karolbroda.com/docs/reference/client-cli) -- [server cli](https://funnel.karolbroda.com/docs/reference/server-cli) -- [server api](https://funnel.karolbroda.com/docs/reference/server-api) -- [deployment](https://funnel.karolbroda.com/docs/server/deployment) -- [security](https://funnel.karolbroda.com/docs/server/security) +```nix +{ + inputs.funnel.url = "github:karol-broda/funnel"; + + imports = [inputs.funnel.homeManagerModules.funnel-client]; + + programs.funnel = { + enable = true; + settings = { + currentContext = "production"; + contexts = { + production.server = "https://tunnel.example.com"; + staging.server = "https://tunnel.staging.example.com"; + }; + }; + }; +} +``` + +when no `tokenFile` is used, the config is a read-only symlink to the nix store. when any context sets `tokenFile`, the config is generated at activation time as a regular file with `0600` permissions, so tokens never enter the store. + +you can also skip declarative token management entirely and set `FUNNEL_TOKEN` in your shell environment, or leave contexts empty and use `funnel login`. + +with sops-nix: + +```nix +sops.secrets."funnel/production-token" = {}; +sops.secrets."funnel/staging-token" = {}; + +programs.funnel = { + enable = true; + settings.contexts = { + production = { + server = "https://tunnel.example.com"; + tokenFile = config.sops.secrets."funnel/production-token".path; + }; + staging = { + server = "https://tunnel.staging.example.com"; + tokenFile = config.sops.secrets."funnel/staging-token".path; + }; + }; +}; +``` + +### overlay + +```nix +nixpkgs.overlays = [inputs.funnel.overlays.default]; +environment.systemPackages = [pkgs.funnel-client]; +``` ## development ```bash -git clone https://github.com/karol-broda/funnel.git -cd funnel -make dev-setup -make build -make test +nix develop +just build +just test +just e2e ``` -`make help` for all commands. +e2e tests start a real server and client, create a tunnel, and send http requests, websocket messages, and large payloads through it. -## contributing +NixOS VM tests run a full server in a QEMU VM and verify the health endpoint, authentication, systemd hardening, restart behavior, and client connectivity: -fork, branch, `make dev-setup`, make changes, `make build && make test`, `make fmt && make lint`, open a pr. +```bash +nix build .#checks.x86_64-linux.nixos-server +``` + +### nix recipes + +```bash +just nix-build # build server + client +just nix-images # build OCI container images +just nix-check # run clippy, fmt, and VM tests +just nix-push # push packages to the attic binary cache +just nix-push-images # push container images to the cache +just nix-docker-load # load a container image into docker +``` ## license diff --git a/TLS_SETUP.md b/TLS_SETUP.md deleted file mode 100644 index bd78b9d..0000000 --- a/TLS_SETUP.md +++ /dev/null @@ -1,265 +0,0 @@ -# tls setup guide - -this guide explains how to configure and use the tunnel server with automatic let's encrypt tls certificates using multiple dns providers. - -## overview - -the tunnel server now supports automatic tls certificate management using let's encrypt with dns-01 challenges. this allows you to: - -- generate wildcard certificates (`*.example.com`) -- support multiple domains across different dns providers -- handle multiple aws accounts/tenants -- automatically renew certificates before expiration -- use sni (server name indication) for multi-domain support - -## prerequisites - -1. **domain control**: you must control the dns for the domains you want certificates for -2. **dns provider access**: api credentials for your dns provider(s) -3. **email address**: required for let's encrypt registration - -## supported dns providers - -the server dynamically supports **any dns provider** available in the [go-acme/lego](https://go-acme.github.io/lego/dns/index.html) library. you are no longer limited to a hardcoded list. - -to use a provider, you must specify its name in the `provider` field and provide the required credentials in the `config` map. the keys in the `config` map **must match the environment variable names** that the specific lego provider expects. - -for a complete list of providers and their required environment variables, see the official lego dns provider documentation: [https://go-acme.github.io/lego/dns/index.html](https://go-acme.github.io/lego/dns/index.html) - -for example, to use `gandiv5`, you would find its required environment variable (`gandiv5_api_key`) and use that as the key in your config. - -### 1. dns providers configuration - -create a `dns-providers.json` file. the structure of this file is defined by the `dns-providers.schema.json` schema. - -here is a basic example using route53 and cloudflare: - -```json -{ - "providers": [ - { - "name": "aws-production", - "provider": "route53", - "domains": ["example.com", "*.example.com"], - "config": { - "aws_access_key_id": "your-access-key", - "aws_secret_access_key": "your-secret-key", - "aws_region": "us-east-1" - } - }, - { - "name": "cloudflare-main", - "provider": "cloudflare", - "domains": ["myapp.org", "*.myapp.org"], - "config": { - "cloudflare_dns_api_token": "your-api-token" - } - } - ] -} -``` - -### 2. route53 configuration options - -for aws route53, you can configure: - -```json -{ - "name": "aws-tenant", - "provider": "route53", - "domains": ["example.com", "*.example.com"], - "config": { - "aws_access_key_id": "akiaiosfodnn7example", - "aws_secret_access_key": "wjalrxutnfemi/k7mdeng/bpxrficyexamplekey", - "aws_region": "us-east-1", - "aws_session_token": "optional-session-token", - "aws_assume_role_arn": "arn:aws:iam::123456789012:role/route53access" - } -} -``` - -**note**: `aws_profile` is handled automatically by the aws sdk through environment variables and is not needed in the config. - -### 3. cloudflare configuration options - -for cloudflare, you can use either: - -**api token (recommended)**: -```json -{ - "name": "cloudflare-token", - "provider": "cloudflare", - "domains": ["example.com", "*.example.com"], - "config": { - "cloudflare_dns_api_token": "your-scoped-api-token" - } -} -``` - -**global api key**: -```json -{ - "name": "cloudflare-global", - "provider": "cloudflare", - "domains": ["example.com", "*.example.com"], - "config": { - "cloudflare_email": "your-email@example.com", - "cloudflare_api_key": "your-global-api-key" - } -} -``` - -### example: digitalocean - -to configure digitalocean, you would find its documentation requires `do_auth_token`. your configuration would look like this: - -```json -{ - "name": "digitalocean-main", - "provider": "digitalocean", - "domains": ["do-domain.com", "*.do-domain.com"], - "config": { - "do_auth_token": "your-digitalocean-api-token" - } -} -``` - -## running the server - -### basic tls setup - -```bash -./tunnel-server \ - --enable-tls \ - --email your-email@example.com \ - --dns-config ./dns-providers.json \ - --cert-dir ./certs \ - --tls-port 443 \ - --port 80 -``` - -### development/testing - -for development, you might want to use different ports: - -```bash -./tunnel-server \ - --enable-tls \ - --email your-email@example.com \ - --dns-config ./dns-providers.json \ - --tls-port 8443 \ - --port 8080 -``` - -### production setup - -for production, typically you'd run on standard ports: - -```bash -sudo ./tunnel-server \ - --enable-tls \ - --email your-email@example.com \ - --dns-config /etc/funnel/dns-providers.json \ - --cert-dir /var/lib/funnel/certs \ - --tls-port 443 \ - --port 80 -``` - -## how it works - -### certificate management - -1. **on-demand generation**: certificates are generated automatically when a client connects with sni -2. **wildcard support**: the system automatically requests both the specific domain and wildcard (`*.domain.com`) -3. **caching**: certificates are cached in memory and persisted to disk -4. **auto-renewal**: certificates are automatically renewed when they expire within 30 days - -### dns challenge process - -1. client connects with sni for `subdomain.example.com` -2. system checks if a valid certificate exists -3. if not, it determines the appropriate dns provider based on domain mapping -4. creates dns txt record `_acme-challenge.subdomain.example.com` -5. let's encrypt validates the challenge -6. certificate is issued and cached -7. dns txt record is cleaned up - -### multi-provider routing - -the system uses a "provider multiplexer" that routes dns challenges to the correct provider: - -- exact domain match: `example.com` โ†’ provider for `example.com` -- wildcard match: `sub.example.com` โ†’ provider for `*.example.com` -- parent domain match: `deep.sub.example.com` โ†’ provider for `example.com` - -## security considerations - -1. **credential storage**: dns provider credentials are stored in the configuration file - ensure proper file permissions -2. **certificate storage**: certificates are stored in the specified cert directory with appropriate permissions -3. **tls configuration**: the server uses secure tls settings with modern cipher suites -4. **rate limits**: be aware of let's encrypt rate limits (50 certificates per registered domain per week) - -## troubleshooting - -### common issues - -1. **dns propagation**: dns changes can take time to propagate. the system waits for propagation automatically. -2. **rate limits**: if you hit let's encrypt rate limits, you'll need to wait or use the staging environment for testing. -3. **dns provider errors**: check your dns provider credentials and permissions. - -### logging - -the system provides detailed logging for certificate operations: - -```bash -# enable debug logging -export log_level=debug -./tunnel-server --enable-tls ... -``` - -### testing with let's encrypt staging - -for testing, you can modify the code to use let's encrypt staging environment by changing: - -```go -legoconfig.cadirurl = lego.ledirectorystaging -``` - -## file structure - -``` -./ -โ”œโ”€โ”€ tunnel-server # main executable -โ”œโ”€โ”€ dns-providers.json # dns providers configuration -โ”œโ”€โ”€ dns-providers.schema.json # example configuration -โ”œโ”€โ”€ certs/ # certificate storage directory -โ”‚ โ”œโ”€โ”€ example.com.crt # certificate files -โ”‚ โ”œโ”€โ”€ example.com.key # private key files -โ”‚ โ””โ”€โ”€ ... -โ””โ”€โ”€ tls_setup.md # this guide -``` - -## example systemd service - -```ini -[unit] -description=tunnel server with tls -after=network.target - -[service] -type=simple -user=tunnel -workingdirectory=/opt/tunnel-server -execstart=/opt/tunnel-server/tunnel-server \ - --enable-tls \ - --email admin@example.com \ - --dns-config /opt/tunnel-server/dns-providers.json \ - --cert-dir /var/lib/tunnel-server/certs \ - --tls-port 443 \ - --port 80 -restart=always -restartsec=5 - -[install] -wantedby=multi-user.target -``` \ No newline at end of file diff --git a/client/client.go b/client/client.go deleted file mode 100644 index 54b464c..0000000 --- a/client/client.go +++ /dev/null @@ -1,74 +0,0 @@ -package client - -import ( - "context" - "sync" - "time" - - "github.com/gorilla/websocket" - "github.com/karol-broda/funnel/shared" -) - -type Client struct { - TunnelID string - ServerURL string - LocalAddr string - Token string - Conn *websocket.Conn - connMu sync.Mutex - lastPong time.Time - lastPongMu sync.Mutex - ongoingRequests map[string]context.CancelFunc - ongoingRequestsMu sync.Mutex - requestSemaphore chan struct{} - outgoingMessages chan *shared.Message - closeOnce sync.Once - requestWg sync.WaitGroup - ctx context.Context - cancel context.CancelFunc -} - -func New(tunnelID, serverURL, localAddr, token string) *Client { - ctx, cancel := context.WithCancel(context.Background()) - return &Client{ - TunnelID: tunnelID, - ServerURL: serverURL, - LocalAddr: localAddr, - Token: token, - ongoingRequests: make(map[string]context.CancelFunc), - requestSemaphore: make(chan struct{}, 128), - outgoingMessages: make(chan *shared.Message, 100), - lastPong: time.Now(), - ctx: ctx, - cancel: cancel, - } -} - -func (c *Client) Close() { - c.closeOnce.Do(func() { - logger := shared.GetTunnelLogger("client", c.TunnelID) - logger.Debug().Msg("closing client connection") - - c.cancel() - - if c.Conn != nil { - c.Conn.Close() - } - - if c.outgoingMessages != nil { - close(c.outgoingMessages) - } - }) -} - -func (c *Client) updateLastPong() { - c.lastPongMu.Lock() - defer c.lastPongMu.Unlock() - c.lastPong = time.Now() -} - -func (c *Client) getLastPong() time.Time { - c.lastPongMu.Lock() - defer c.lastPongMu.Unlock() - return c.lastPong -} diff --git a/client/client_test.go b/client/client_test.go deleted file mode 100644 index 7fea9fa..0000000 --- a/client/client_test.go +++ /dev/null @@ -1,377 +0,0 @@ -package client - -import ( - "context" - "net/http" - "net/url" - "strings" - "testing" - - "github.com/karol-broda/funnel/shared" -) - -func TestClientConnectionHandling(t *testing.T) { - tests := []struct { - name string - serverURL string - expectError bool - }{ - { - name: "valid http URL", - serverURL: "http://localhost:8080", - expectError: false, - }, - { - name: "valid https URL", - serverURL: "https://example.com", - expectError: false, - }, - { - name: "invalid URL", - serverURL: "invalid-url", - expectError: true, - }, - { - name: "empty URL", - serverURL: "", - expectError: true, - }, - { - name: "URL with port", - serverURL: "http://localhost:9000", - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := &Client{ - ServerURL: tt.serverURL, - LocalAddr: "localhost:3000", - TunnelID: "test-tunnel", - } - - err := client.connect(context.Background()) - - if tt.expectError { - if err == nil { - t.Error("expected error but got none") - } - return - } - - if err != nil { - if _, parseErr := url.Parse(tt.serverURL); parseErr != nil { - t.Errorf("URL parsing failed: %v", parseErr) - } - } - }) - } -} - -func TestClientURLConstruction(t *testing.T) { - tests := []struct { - name string - serverURL string - tunnelID string - expectedScheme string - expectedHost string - expectedQuery string - }{ - { - name: "http to ws", - serverURL: "http://localhost:8080", - tunnelID: "test123", - expectedScheme: "ws", - expectedHost: "localhost:8080", - expectedQuery: "id=test123", - }, - { - name: "https to wss", - serverURL: "https://tunnel.example.com", - tunnelID: "secure-tunnel", - expectedScheme: "wss", - expectedHost: "tunnel.example.com", - expectedQuery: "id=secure-tunnel", - }, - { - name: "http with path", - serverURL: "http://localhost:8080/api", - tunnelID: "path-test", - expectedScheme: "ws", - expectedHost: "localhost:8080", - expectedQuery: "id=path-test", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - u, err := url.Parse(tt.serverURL) - if err != nil { - t.Fatalf("failed to parse server URL: %v", err) - } - - wsScheme := "ws" - if u.Scheme == "https" { - wsScheme = "wss" - } - - if wsScheme != tt.expectedScheme { - t.Errorf("expected scheme %s, got %s", tt.expectedScheme, wsScheme) - } - - if u.Host != tt.expectedHost { - t.Errorf("expected host %s, got %s", tt.expectedHost, u.Host) - } - - expectedURL := wsScheme + "://" + u.Host + "/?id=" + tt.tunnelID - wsURL := expectedURL - - if !strings.Contains(wsURL, tt.expectedQuery) { - t.Errorf("expected query %s in URL %s", tt.expectedQuery, wsURL) - } - }) - } -} - -func TestClientHeaderFiltering(t *testing.T) { - client := &Client{ - TunnelID: "test-headers", - } - - tests := []struct { - name string - inputHeaders map[string][]string - expectedSkipped []string - expectedKept []string - }{ - { - name: "forward x-headers and proxy headers", - inputHeaders: map[string][]string{ - "X-Real-IP": {"192.168.1.1"}, - "X-Forwarded-For": {"10.0.0.1"}, - "Content-Type": {"application/json"}, - "Accept": {"*/*"}, - }, - expectedSkipped: []string{}, - expectedKept: []string{"X-Real-IP", "X-Forwarded-For", "Content-Type", "Accept"}, - }, - { - name: "skip connection-specific headers", - inputHeaders: map[string][]string{ - "Connection": {"keep-alive"}, - "Upgrade": {"websocket"}, - "User-Agent": {"test-client"}, - "Content-Type": {"application/json"}, - }, - expectedSkipped: []string{"Connection", "Upgrade"}, - expectedKept: []string{"User-Agent", "Content-Type"}, - }, - { - name: "keep standard headers and forward proxy headers", - inputHeaders: map[string][]string{ - "Content-Type": {"text/plain"}, - "Content-Length": {"42"}, - "Accept": {"text/html"}, - "User-Agent": {"test-browser"}, - "X-Forwarded-Host": {"example.com"}, - "X-Forwarded-Proto": {"https"}, - }, - expectedSkipped: []string{}, - expectedKept: []string{"Content-Type", "Content-Length", "Accept", "User-Agent", "X-Forwarded-Host", "X-Forwarded-Proto"}, - }, - { - name: "handle host header specially", - inputHeaders: map[string][]string{ - "Host": {"tunnel.example.com"}, - "Content-Type": {"application/json"}, - "Accept": {"*/*"}, - }, - expectedSkipped: []string{}, - expectedKept: []string{"Content-Type", "Accept"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req, err := http.NewRequest("GET", "http://localhost:3000/test", nil) - if err != nil { - t.Fatalf("failed to create request: %v", err) - } - - client.setRequestHeaders(req, tt.inputHeaders) - - for _, header := range tt.expectedSkipped { - if values := req.Header.Values(header); len(values) > 0 { - t.Errorf("header %s should be skipped but found values: %v", header, values) - } - } - - for _, header := range tt.expectedKept { - if values := req.Header.Values(header); len(values) == 0 { - t.Errorf("header %s should be kept but not found", header) - } - } - - if tt.inputHeaders["Host"] != nil { - if req.Host != client.LocalAddr { - t.Errorf("Host should be set to local address %s, but got %s", client.LocalAddr, req.Host) - } - } - }) - } -} - -func TestClientErrorHandling(t *testing.T) { - tests := []struct { - name string - requestID string - expectedStatus int - expectedBody string - }{ - { - name: "internal server error", - requestID: "test-500", - expectedStatus: 500, - expectedBody: "Internal Server Error", - }, - { - name: "bad gateway", - requestID: "test-502", - expectedStatus: 502, - expectedBody: "Bad Gateway", - }, - { - name: "service unavailable", - requestID: "test-503", - expectedStatus: 503, - expectedBody: "Service Unavailable", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - response := shared.Message{ - Type: "response", - RequestID: tt.requestID, - Status: tt.expectedStatus, - Headers: map[string][]string{ - "Content-Type": {"text/plain"}, - }, - Body: []byte(http.StatusText(tt.expectedStatus)), - } - - if response.Type != "response" { - t.Errorf("expected response type, got %s", response.Type) - } - - if response.RequestID != tt.requestID { - t.Errorf("expected request ID %s, got %s", tt.requestID, response.RequestID) - } - - if response.Status != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, response.Status) - } - - if string(response.Body) != tt.expectedBody { - t.Errorf("expected body %s, got %s", tt.expectedBody, string(response.Body)) - } - }) - } -} - -func TestClientConfiguration(t *testing.T) { - tests := []struct { - name string - serverURL string - localAddr string - tunnelID string - valid bool - }{ - { - name: "valid configuration", - serverURL: "http://localhost:8080", - localAddr: "localhost:3000", - tunnelID: "test-tunnel", - valid: true, - }, - { - name: "empty server URL", - serverURL: "", - localAddr: "localhost:3000", - tunnelID: "test-tunnel", - valid: false, - }, - { - name: "empty local address", - serverURL: "http://localhost:8080", - localAddr: "", - tunnelID: "test-tunnel", - valid: false, - }, - { - name: "empty tunnel ID", - serverURL: "http://localhost:8080", - localAddr: "localhost:3000", - tunnelID: "", - valid: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := &Client{ - ServerURL: tt.serverURL, - LocalAddr: tt.localAddr, - TunnelID: tt.tunnelID, - } - - // validate configuration - valid := client.ServerURL != "" && client.LocalAddr != "" && client.TunnelID != "" - - if valid != tt.valid { - t.Errorf("expected valid=%v, got valid=%v", tt.valid, valid) - } - }) - } -} - -func BenchmarkClientHeaderFiltering(b *testing.B) { - client := &Client{TunnelID: "bench-test"} - - headers := map[string][]string{ - "X-Real-IP": {"192.168.1.1"}, - "X-Forwarded-For": {"10.0.0.1"}, - "X-Forwarded-Proto": {"https"}, - "Host": {"example.com"}, - "Connection": {"keep-alive"}, - "Content-Type": {"application/json"}, - "Accept": {"application/json"}, - "User-Agent": {"test-client/1.0"}, - "Authorization": {"Bearer token123"}, - "Content-Length": {"42"}, - } - - b.ReportAllocs() - for i := 0; i < b.N; i++ { - req, _ := http.NewRequest("GET", "http://localhost:3000/test", nil) - client.setRequestHeaders(req, headers) - } -} - -func BenchmarkClientURLConstruction(b *testing.B) { - client := &Client{ - ServerURL: "http://localhost:8080", - TunnelID: "benchmark-tunnel", - } - - b.ReportAllocs() - for i := 0; i < b.N; i++ { - u, _ := url.Parse(client.ServerURL) - wsScheme := "ws" - if u.Scheme == "https" { - wsScheme = "wss" - } - _ = wsScheme + "://" + u.Host + "/?id=" + client.TunnelID - } -} diff --git a/client/config.go b/client/config.go deleted file mode 100644 index 1ea25a9..0000000 --- a/client/config.go +++ /dev/null @@ -1,214 +0,0 @@ -package client - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/BurntSushi/toml" -) - -type Config struct { - Inlets map[string]Inlet `toml:"inlets"` -} - -// Inlet represents a tunnel server configuration -type Inlet struct { - Server string `toml:"server"` - Domain string `toml:"domain,omitempty"` - Token string `toml:"token,omitempty"` -} - -// ConfigManager handles configuration loading and management -type ConfigManager struct { - configPath string - config *Config -} - -// NewConfigManager creates a new configuration manager -func NewConfigManager() *ConfigManager { - return &ConfigManager{} -} - -// getConfigPath determines the configuration file path -func (cm *ConfigManager) getConfigPath() string { - if cm.configPath != "" { - return cm.configPath - } - - // try XDG config directory first - if configDir := os.Getenv("XDG_CONFIG_HOME"); configDir != "" { - cm.configPath = filepath.Join(configDir, "funnel", "config.toml") - return cm.configPath - } - - // fallback to ~/.config/funnel/config.toml - if homeDir, err := os.UserHomeDir(); err == nil { - configDir := filepath.Join(homeDir, ".config", "funnel") - cm.configPath = filepath.Join(configDir, "config.toml") - return cm.configPath - } - - // if all else fails, use current directory - cm.configPath = "funnel.toml" - return cm.configPath -} - -// LoadConfig loads the configuration from file -func (cm *ConfigManager) LoadConfig() (*Config, error) { - configPath := cm.getConfigPath() - - // check if config file exists - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return nil, fmt.Errorf("config file not found at %s", configPath) - } - - // read and parse existing config - data, err := os.ReadFile(configPath) - if err != nil { - return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err) - } - - config := &Config{ - Inlets: make(map[string]Inlet), - } - - if err := toml.Unmarshal(data, config); err != nil { - return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err) - } - - cm.config = config - return config, nil -} - -// SaveConfig saves the configuration to file -func (cm *ConfigManager) SaveConfig(config *Config) error { - configPath := cm.getConfigPath() - - // ensure the directory exists - configDir := filepath.Dir(configPath) - if err := os.MkdirAll(configDir, 0755); err != nil { - return fmt.Errorf("failed to create config directory %s: %w", configDir, err) - } - - // marshal config to TOML - data, err := toml.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal config to TOML: %w", err) - } - - // write to file with restricted permissions (contains token) - if err := os.WriteFile(configPath, data, 0600); err != nil { - return fmt.Errorf("failed to write config file %s: %w", configPath, err) - } - - cm.config = config - return nil -} - -// GetInlet retrieves a specific inlet configuration by name -func (cm *ConfigManager) GetInlet(name string) (*Inlet, error) { - if cm.config == nil { - if _, err := cm.LoadConfig(); err != nil { - return nil, err - } - } - - inlet, exists := cm.config.Inlets[name] - if !exists { - return nil, fmt.Errorf("inlet '%s' not found in configuration", name) - } - - if inlet.Server == "" { - return nil, fmt.Errorf("inlet '%s' has no server configured", name) - } - - return &inlet, nil -} - -// GetDefaultInlet retrieves the default inlet configuration -func (cm *ConfigManager) GetDefaultInlet() (*Inlet, error) { - return cm.GetInlet("default") -} - -// ListInlets returns all available inlet names -func (cm *ConfigManager) ListInlets() ([]string, error) { - if cm.config == nil { - if _, err := cm.LoadConfig(); err != nil { - return nil, err - } - } - - inlets := make([]string, 0, len(cm.config.Inlets)) - for name := range cm.config.Inlets { - inlets = append(inlets, name) - } - - return inlets, nil -} - -// ValidateConfig validates the loaded configuration -func (cm *ConfigManager) ValidateConfig() error { - if cm.config == nil { - return fmt.Errorf("no configuration loaded") - } - - if len(cm.config.Inlets) == 0 { - return fmt.Errorf("no inlets configured") - } - - for name, inlet := range cm.config.Inlets { - if inlet.Server == "" { - return fmt.Errorf("inlet '%s' has no server configured", name) - } - } - - return nil -} - -// SetToken sets the token for a specific inlet, creating the inlet if it doesn't exist -func (cm *ConfigManager) SetToken(inletName, token string) error { - if cm.config == nil { - cm.config = &Config{Inlets: make(map[string]Inlet)} - cm.LoadConfig() - if cm.config == nil { - cm.config = &Config{Inlets: make(map[string]Inlet)} - } - } - - inlet := cm.config.Inlets[inletName] - inlet.Token = token - cm.config.Inlets[inletName] = inlet - - return cm.SaveConfig(cm.config) -} - -// SetServer sets the server URL for a specific inlet, creating the inlet if it doesn't exist -func (cm *ConfigManager) SetServer(inletName, server string) error { - if cm.config == nil { - cm.config = &Config{Inlets: make(map[string]Inlet)} - cm.LoadConfig() - if cm.config == nil { - cm.config = &Config{Inlets: make(map[string]Inlet)} - } - } - - inlet := cm.config.Inlets[inletName] - inlet.Server = server - cm.config.Inlets[inletName] = inlet - - return cm.SaveConfig(cm.config) -} - -// GetConfigPath returns the path to the config file -func (cm *ConfigManager) GetConfigPath() string { - return cm.getConfigPath() -} - -// GetConfig returns the loaded config, loading it if necessary -func (cm *ConfigManager) GetConfig() *Config { - if cm.config == nil { - cm.LoadConfig() - } - return cm.config -} diff --git a/client/connection.go b/client/connection.go deleted file mode 100644 index a2ddb56..0000000 --- a/client/connection.go +++ /dev/null @@ -1,87 +0,0 @@ -package client - -import ( - "context" - "net/http" - "net/url" - "time" - - "github.com/karol-broda/funnel/shared" - - "github.com/gorilla/websocket" -) - -func (c *Client) connect(ctx context.Context) error { - logger := shared.GetTunnelLogger("client.connection", c.TunnelID) - logger.Debug().Msg("starting connection process") - - connectionStart := time.Now() - - u, err := url.Parse(c.ServerURL) - if err != nil { - logger.Error().Err(err).Msg("failed to parse server url") - return err - } - - wsScheme := "ws" - if u.Scheme == "https" { - wsScheme = "wss" - } - - u.Scheme = wsScheme - u.RawQuery = "id=" + c.TunnelID - wsURL := u.String() - - logger.Debug().Str("websocket_url", wsURL).Msg("constructed websocket URL") - - dialer := *websocket.DefaultDialer - dialer.HandshakeTimeout = 15 * time.Second - dialer.EnableCompression = true - dialer.ReadBufferSize = 65536 - dialer.WriteBufferSize = 65536 - - logger.Debug(). - Dur("handshake_timeout", dialer.HandshakeTimeout). - Int("read_buffer_size", dialer.ReadBufferSize). - Int("write_buffer_size", dialer.WriteBufferSize). - Bool("compression_enabled", dialer.EnableCompression). - Msg("websocket dialer configured") - - var headers http.Header - if c.Token != "" { - headers = http.Header{} - headers.Set("Authorization", "Bearer "+c.Token) - logger.Debug().Msg("authentication token configured") - } - - dialStart := time.Now() - conn, resp, err := dialer.DialContext(ctx, wsURL, headers) - dialDuration := time.Since(dialStart) - - if err != nil { - logger.Error().Err(err). - Str("websocket_url", wsURL). - Dur("dial_duration", dialDuration). - Msg("websocket connection failed") - if resp != nil { - logger.Error().Int("http_status", resp.StatusCode).Msg("websocket upgrade response status") - } - return err - } - - totalConnectTime := time.Since(connectionStart) - logger.Info(). - Dur("dial_time", dialDuration). - Dur("total_connect_time", totalConnectTime). - Msg("websocket connection established") - - logger.Debug(). - Int("http_status", resp.StatusCode). - Msg("websocket upgrade successful") - - c.Conn = conn - c.connMu.Lock() - c.lastPong = time.Now() - c.connMu.Unlock() - return nil -} diff --git a/client/go.mod b/client/go.mod deleted file mode 100644 index 698249f..0000000 --- a/client/go.mod +++ /dev/null @@ -1,18 +0,0 @@ -module github.com/karol-broda/funnel/client - -go 1.24.4 - -require ( - github.com/BurntSushi/toml v1.5.0 - github.com/gorilla/websocket v1.5.3 - github.com/karol-broda/funnel/shared v0.0.0-00010101000000-000000000000 -) - -require ( - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/rs/zerolog v1.34.0 // indirect - golang.org/x/sys v0.32.0 // indirect -) - -replace github.com/karol-broda/funnel/shared => ../shared diff --git a/client/go.sum b/client/go.sum deleted file mode 100644 index 772dea6..0000000 --- a/client/go.sum +++ /dev/null @@ -1,20 +0,0 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/client/request_handler.go b/client/request_handler.go deleted file mode 100644 index c1f1739..0000000 --- a/client/request_handler.go +++ /dev/null @@ -1,364 +0,0 @@ -package client - -import ( - "bytes" - "context" - "errors" - "io" - "net/http" - "strings" - "time" - - "github.com/gorilla/websocket" - "github.com/karol-broda/funnel/shared" -) - -func (c *Client) readPump() { - defer func() { - c.requestWg.Wait() - c.Close() - }() - logger := shared.GetTunnelLogger("client.handler", c.TunnelID) - - logger.Info().Msg("starting request handler loop") - - transport := &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 5, - IdleConnTimeout: 30 * time.Second, - MaxConnsPerHost: 10, - } - - httpClient := &http.Client{ - Timeout: 30 * time.Second, - Transport: transport, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - - logger.Debug(). - Dur("http_timeout", httpClient.Timeout). - Int("max_conns_per_host", 10). - Int("max_idle_conns", 10). - Msg("http client configured") - - c.setupHeartbeat() - - requestCount := 0 - for { - select { - case <-c.ctx.Done(): - logger.Info().Msg("read pump shutting down due to context cancellation") - return - default: - var msg shared.Message - readStart := time.Now() - err := c.Conn.ReadJSON(&msg) - readDuration := time.Since(readStart) - - if err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { - logger.Error().Err(err).Dur("read_duration", readDuration).Msg("unexpected websocket read error") - } else { - logger.Info().Dur("read_duration", readDuration).Msg("websocket connection closed gracefully") - } - return - } - - logger.Debug().Dur("message_read_time", readDuration).Str("message_type", msg.Type).Msg("received message from server") - - switch msg.Type { - case "request": - requestCount++ - logger.Info(). - Int("request_count", requestCount). - Str("request_id", msg.RequestID). - Str("method", msg.Method). - Str("path", msg.Path). - Int("body_size", len(msg.Body)). - Msg("received request from server") - - c.requestWg.Add(1) - go func(m shared.Message) { - defer c.requestWg.Done() - c.processRequest(httpClient, m) - }(msg) - case "request_cancel": - logger.Info().Str("request_id", msg.RequestID).Msg("received request cancellation") - c.ongoingRequestsMu.Lock() - if cancel, ok := c.ongoingRequests[msg.RequestID]; ok { - cancel() - delete(c.ongoingRequests, msg.RequestID) - } - c.ongoingRequestsMu.Unlock() - default: - logger.Debug().Str("message_type", msg.Type).Msg("ignoring unhandled message type") - } - } - } -} - -func (c *Client) setupHeartbeat() { - logger := shared.GetTunnelLogger("client.handler", c.TunnelID) - - c.Conn.SetPongHandler(func(string) error { - c.updateLastPong() - logger.Debug().Msg("pong received from server") - return nil - }) - - go func() { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-c.ctx.Done(): - logger.Debug().Msg("context cancelled, stopping ping routine") - return - case <-ticker.C: - if c.Conn == nil { - logger.Debug().Msg("connection is nil, stopping ping routine") - return - } - - lastPong := c.getLastPong() - if time.Since(lastPong) > 90*time.Second { - logger.Warn(). - Dur("time_since_last_pong", time.Since(lastPong)). - Msg("no pong received recently, connection may be stale") - c.Close() - return - } - - select { - case c.outgoingMessages <- &shared.Message{Type: "ping"}: - logger.Debug().Msg("ping message queued") - case <-c.ctx.Done(): - logger.Debug().Msg("context cancelled while queuing ping, stopping ping routine") - return - default: - logger.Warn().Msg("outgoing message channel full, cannot queue ping") - } - } - } - }() - - logger.Debug().Msg("heartbeat mechanism setup completed") -} - -func (c *Client) writePump() { - defer func() { - c.Close() - }() - logger := shared.GetTunnelLogger("client.handler", c.TunnelID) - logger.Info().Msg("starting writer loop") - - for msg := range c.outgoingMessages { - if msg.Type == "ping" { - if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { - logger.Error().Err(err).Msg("failed to send ping") - return - } - logger.Debug().Msg("ping sent to server") - continue - } - - c.connMu.Lock() - err := c.Conn.WriteJSON(msg) - c.connMu.Unlock() - - if err != nil { - logger.Error().Err(err).Msg("failed to write json message") - return - } - - if msg.RequestID != "" { - logger.Debug(). - Str("request_id", msg.RequestID). - Str("type", msg.Type). - Msg("message sent to server") - } - } - logger.Info().Msg("writer loop finished") -} - -func (c *Client) processRequest(httpClient *http.Client, msg shared.Message) { - logger := shared.GetRequestLogger("client.handler", c.TunnelID, msg.RequestID) - - select { - case c.requestSemaphore <- struct{}{}: - defer func() { <-c.requestSemaphore }() - case <-c.ctx.Done(): - logger.Warn().Msg("shutting down, not processing new request") - return - } - - reqCtx, cancel := context.WithTimeout(c.ctx, 30*time.Second) - defer cancel() - - c.ongoingRequestsMu.Lock() - c.ongoingRequests[msg.RequestID] = cancel - c.ongoingRequestsMu.Unlock() - - defer func() { - c.ongoingRequestsMu.Lock() - delete(c.ongoingRequests, msg.RequestID) - c.ongoingRequestsMu.Unlock() - }() - - processStart := time.Now() - logger.Debug(). - Str("method", msg.Method). - Str("path", msg.Path). - Int("header_count", len(msg.Headers)). - Int("body_size", len(msg.Body)). - Int("concurrent_requests", len(c.requestSemaphore)). - Msg("processing request") - - req, err := http.NewRequestWithContext(reqCtx, msg.Method, "http://"+c.LocalAddr+msg.Path, bytes.NewReader(msg.Body)) - if err != nil { - logger.Error().Err(err).Msg("failed to create request") - if reqCtx.Err() == nil { - c.sendError(msg.RequestID, http.StatusInternalServerError, "failed to create request") - } - return - } - - c.setRequestHeaders(req, msg.Headers) - - logger.Debug(). - Int("concurrent_requests", len(c.requestSemaphore)). - Int("header_count", len(req.Header)). - Str("local_url", req.URL.String()). - Msg("forwarding request to local service") - - upstreamResponseTimeStart := time.Now() - resp, err := httpClient.Do(req) - - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - logger.Warn().Err(err).Msg("request to local service was canceled or timed out") - return - } - logger.Error().Err(err).Msg("failed to make request to local service") - c.sendError(msg.RequestID, http.StatusBadGateway, "local service connection failed") - return - } - defer resp.Body.Close() - - logger.Debug(). - Int("status_code", resp.StatusCode). - Dur("upstream_response_time", time.Since(upstreamResponseTimeStart)). - Int64("content_length", resp.ContentLength). - Msg("received response from local service") - - bodyReadStart := time.Now() - body, err := io.ReadAll(resp.Body) - bodyReadDuration := time.Since(bodyReadStart) - - if err != nil { - logger.Error().Err(err). - Dur("body_read_duration", bodyReadDuration). - Msg("failed to read response body") - c.sendError(msg.RequestID, http.StatusBadGateway, "failed to read response body") - return - } - - totalProcessTime := time.Since(processStart) - logger.Info(). - Int("status_code", resp.StatusCode). - Int("response_size", len(body)). - Dur("total_process_time", totalProcessTime). - Dur("upstream_time", time.Since(upstreamResponseTimeStart)). - Dur("body_read_time", bodyReadDuration). - Msg("request processed successfully") - - select { - case <-c.ctx.Done(): - logger.Info().Str("request_id", msg.RequestID).Msg("shutting down, not sending response") - return - default: - c.sendResponse(msg.RequestID, resp.StatusCode, resp.Header, body) - } -} - -func (c *Client) setRequestHeaders(req *http.Request, headers map[string][]string) { - req.Header = make(http.Header) - - for k, values := range headers { - if c.shouldSkipHeader(k) { - continue - } - if strings.ToLower(k) == "host" { - continue - } - for _, value := range values { - req.Header.Add(k, value) - } - } - req.Host = c.LocalAddr -} - -func (c *Client) shouldSkipHeader(headerName string) bool { - lower := strings.ToLower(headerName) - return lower == "connection" || - lower == "keep-alive" || - lower == "proxy-authenticate" || - lower == "proxy-authorization" || - lower == "te" || - lower == "trailer" || - lower == "transfer-encoding" || - lower == "upgrade" || - lower == "proxy-connection" -} - -func (c *Client) sendResponse(requestID string, status int, headers http.Header, body []byte) { - logger := shared.GetRequestLogger("client.handler", c.TunnelID, requestID) - - respHeaders := make(map[string][]string) - for k, v := range headers { - if !c.shouldSkipHeader(k) { - respHeaders[k] = v - } - } - - respMsg := &shared.Message{ - Type: "response", - RequestID: requestID, - Status: status, - Headers: respHeaders, - Body: body, - } - - select { - case c.outgoingMessages <- respMsg: - sendDuration := time.Since(time.Now()) - logger.Debug(). - Int("status_code", status). - Int("header_count", len(respHeaders)). - Int("response_size", len(body)). - Dur("send_duration", sendDuration). - Msg("response queued successfully") - default: - logger.Warn().Msg("outgoing message channel full, dropping response") - } -} - -func (c *Client) sendError(requestID string, statusCode int, error string) { - logger := shared.GetRequestLogger("client.handler", c.TunnelID, requestID) - logger.Error().Int("status_code", statusCode).Str("error", error).Msg("sending error response") - errMessage := &shared.Message{ - Type: "response", - RequestID: requestID, - Status: statusCode, - Headers: http.Header{"Content-Type": []string{"text/plain"}}, - Body: []byte(error), - } - select { - case c.outgoingMessages <- errMessage: - default: - logger.Warn().Int("status", statusCode).Msg("outgoing message channel full, dropping error response") - } -} diff --git a/client/request_handler_header_test.go b/client/request_handler_header_test.go deleted file mode 100644 index b58876d..0000000 --- a/client/request_handler_header_test.go +++ /dev/null @@ -1,294 +0,0 @@ -package client - -import ( - "net/http/httptest" - "testing" -) - -func TestClient_shouldSkipHeader(t *testing.T) { - client := &Client{ - TunnelID: "test-tunnel", - LocalAddr: "localhost:3000", - } - - tests := []struct { - name string - headerName string - shouldSkip bool - }{ - { - name: "skip Connection header", - headerName: "Connection", - shouldSkip: true, - }, - { - name: "skip connection header lowercase", - headerName: "connection", - shouldSkip: true, - }, - { - name: "skip Upgrade header", - headerName: "Upgrade", - shouldSkip: true, - }, - { - name: "skip upgrade header lowercase", - headerName: "upgrade", - shouldSkip: true, - }, - { - name: "skip Proxy-Connection header", - headerName: "Proxy-Connection", - shouldSkip: true, - }, - { - name: "skip Proxy-Authenticate header", - headerName: "Proxy-Authenticate", - shouldSkip: true, - }, - { - name: "skip Proxy-Authorization header", - headerName: "Proxy-Authorization", - shouldSkip: true, - }, - { - name: "skip TE header", - headerName: "TE", - shouldSkip: true, - }, - { - name: "skip Trailer header", - headerName: "Trailer", - shouldSkip: true, - }, - { - name: "skip Transfer-Encoding header", - headerName: "Transfer-Encoding", - shouldSkip: true, - }, - { - name: "forward User-Agent header", - headerName: "User-Agent", - shouldSkip: false, - }, - { - name: "forward X-Forwarded-For header", - headerName: "X-Forwarded-For", - shouldSkip: false, - }, - { - name: "forward X-Forwarded-Host header", - headerName: "X-Forwarded-Host", - shouldSkip: false, - }, - { - name: "forward X-Forwarded-Proto header", - headerName: "X-Forwarded-Proto", - shouldSkip: false, - }, - { - name: "forward X-Real-IP header", - headerName: "X-Real-IP", - shouldSkip: false, - }, - { - name: "forward Content-Type header", - headerName: "Content-Type", - shouldSkip: false, - }, - { - name: "forward Accept header", - headerName: "Accept", - shouldSkip: false, - }, - { - name: "forward Authorization header", - headerName: "Authorization", - shouldSkip: false, - }, - { - name: "forward custom header", - headerName: "X-Custom-Header", - shouldSkip: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := client.shouldSkipHeader(tt.headerName) - if result != tt.shouldSkip { - t.Errorf("shouldSkipHeader(%s) = %v, want %v", tt.headerName, result, tt.shouldSkip) - } - }) - } -} - -func TestClient_setRequestHeaders(t *testing.T) { - tests := []struct { - name string - inputHeaders map[string][]string - localAddr string - expectedSkipped []string - expectedForwarded []string - expectedHost string - }{ - { - name: "forward proxy headers", - inputHeaders: map[string][]string{ - "X-Forwarded-For": {"192.168.1.100"}, - "X-Forwarded-Host": {"api.example.com"}, - "X-Forwarded-Proto": {"https"}, - "X-Real-IP": {"203.0.113.45"}, - "User-Agent": {"TestClient/1.0"}, - "Accept": {"application/json"}, - }, - localAddr: "localhost:8080", - expectedSkipped: []string{}, - expectedForwarded: []string{"X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP", "User-Agent", "Accept"}, - expectedHost: "localhost:8080", - }, - { - name: "skip connection headers", - inputHeaders: map[string][]string{ - "Connection": {"keep-alive"}, - "Upgrade": {"websocket"}, - "Transfer-Encoding": {"chunked"}, - "TE": {"trailers"}, - "User-Agent": {"TestClient/1.0"}, - "Content-Type": {"application/json"}, - }, - localAddr: "localhost:3000", - expectedSkipped: []string{"Connection", "Upgrade", "Transfer-Encoding", "TE"}, - expectedForwarded: []string{"User-Agent", "Content-Type"}, - expectedHost: "localhost:3000", - }, - { - name: "handle Host header specially", - inputHeaders: map[string][]string{ - "Host": {"tunnel.example.com"}, - "User-Agent": {"TestClient/1.0"}, - "Content-Type": {"application/json"}, - }, - localAddr: "localhost:9000", - expectedSkipped: []string{}, - expectedForwarded: []string{"User-Agent", "Content-Type"}, - expectedHost: "localhost:9000", - }, - { - name: "mixed headers with proxy and connection headers", - inputHeaders: map[string][]string{ - "X-Forwarded-For": {"192.168.1.100, 10.0.0.1"}, - "X-Forwarded-Host": {"api.example.com"}, - "Connection": {"close"}, - "Proxy-Connection": {"keep-alive"}, - "User-Agent": {"TestClient/1.0"}, - "Authorization": {"Bearer token123"}, - "Content-Type": {"application/json"}, - "X-Custom-Header": {"custom-value"}, - "Transfer-Encoding": {"gzip"}, - }, - localAddr: "localhost:5000", - expectedSkipped: []string{"Connection", "Proxy-Connection", "Transfer-Encoding"}, - expectedForwarded: []string{"X-Forwarded-For", "X-Forwarded-Host", "User-Agent", "Authorization", "Content-Type", "X-Custom-Header"}, - expectedHost: "localhost:5000", - }, - { - name: "no Host header provided", - inputHeaders: map[string][]string{ - "User-Agent": {"TestClient/1.0"}, - "Content-Type": {"application/json"}, - }, - localAddr: "localhost:4000", - expectedSkipped: []string{}, - expectedForwarded: []string{"User-Agent", "Content-Type"}, - expectedHost: "localhost:4000", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := &Client{ - TunnelID: "test-headers", - LocalAddr: tt.localAddr, - } - - req := httptest.NewRequest("GET", "http://"+tt.localAddr+"/test", nil) - - client.setRequestHeaders(req, tt.inputHeaders) - - for _, skippedHeader := range tt.expectedSkipped { - if values := req.Header.Values(skippedHeader); len(values) > 0 { - t.Errorf("Header %s should be skipped but found values: %v", skippedHeader, values) - } - } - - for _, forwardedHeader := range tt.expectedForwarded { - if values := req.Header.Values(forwardedHeader); len(values) == 0 { - t.Errorf("Header %s should be forwarded but not found", forwardedHeader) - } - } - - if req.Host != tt.expectedHost { - t.Errorf("Host header = %s, want %s", req.Host, tt.expectedHost) - } - - if tt.inputHeaders["X-Forwarded-For"] != nil { - if forwardedFor := req.Header.Get("X-Forwarded-For"); forwardedFor != tt.inputHeaders["X-Forwarded-For"][0] { - t.Errorf("X-Forwarded-For = %s, want %s", forwardedFor, tt.inputHeaders["X-Forwarded-For"][0]) - } - } - }) - } -} - -func TestClient_setRequestHeaders_HeaderValues(t *testing.T) { - client := &Client{ - TunnelID: "test-values", - LocalAddr: "localhost:3000", - } - - tests := []struct { - name string - inputHeaders map[string][]string - headerToTest string - expectedValue string - }{ - { - name: "single value header", - inputHeaders: map[string][]string{ - "Authorization": {"Bearer token123"}, - }, - headerToTest: "Authorization", - expectedValue: "Bearer token123", - }, - { - name: "multiple value header - first value", - inputHeaders: map[string][]string{ - "Accept": {"application/json", "text/html"}, - }, - headerToTest: "Accept", - expectedValue: "application/json", - }, - { - name: "forwarded header preservation", - inputHeaders: map[string][]string{ - "X-Forwarded-Proto": {"https"}, - }, - headerToTest: "X-Forwarded-Proto", - expectedValue: "https", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "http://localhost:3000/test", nil) - - client.setRequestHeaders(req, tt.inputHeaders) - - actualValue := req.Header.Get(tt.headerToTest) - if actualValue != tt.expectedValue { - t.Errorf("Header %s = %s, want %s", tt.headerToTest, actualValue, tt.expectedValue) - } - }) - } -} diff --git a/client/runner.go b/client/runner.go deleted file mode 100644 index a531af7..0000000 --- a/client/runner.go +++ /dev/null @@ -1,121 +0,0 @@ -package client - -import ( - "context" - "fmt" - "math" - "net/url" - "strings" - "sync" - "time" - - "github.com/karol-broda/funnel/shared" -) - -const maxReconnectDelay = 30 * time.Second - -func Run(tunnelID, serverURL, localAddr, token string, shutdown <-chan struct{}) { - logger := shared.GetTunnelLogger("client.runner", tunnelID) - logger.Info().Str("local_addr", localAddr).Str("server_url", serverURL).Bool("has_token", token != "").Msg("starting tunnel client with reconnection logic") - - reconnectAttempts := 0 - - for { - select { - case <-shutdown: - logger.Info().Msg("shutdown signal received, stopping client runner.") - return - default: - // continue - } - - c := New(tunnelID, serverURL, localAddr, token) - - logger.Info().Int("attempt", reconnectAttempts+1).Msg("attempting to connect to server") - err := c.connect(c.ctx) - - if err != nil { - errorCategory := categorizeConnectionError(err) - logger.Error().Err(err).Str("error_category", errorCategory).Msg("connection failed") - - select { - case <-shutdown: - continue - case <-time.After(getReconnectDelay(reconnectAttempts)): - reconnectAttempts++ - continue - } - } - - logger.Info().Msg("connected successfully") - reconnectAttempts = 0 - - if u, err := url.Parse(c.ServerURL); err != nil { - logger.Error().Err(err).Msg("failed to parse server url for public url display") - } else { - publicURL := fmt.Sprintf("http://%s.%s", c.TunnelID, u.Host) - logger.Info().Str("public_url", publicURL).Msg("tunnel is available") - } - - var wg sync.WaitGroup - wg.Add(2) - - runCtx, cancelRun := context.WithCancel(context.Background()) - - go func() { - defer wg.Done() - c.readPump() - cancelRun() - }() - - go func() { - defer wg.Done() - c.writePump() - cancelRun() - }() - - select { - case <-shutdown: - logger.Info().Msg("shutdown during active connection, closing.") - c.Close() - case <-runCtx.Done(): - logger.Warn().Msg("connection lost, will attempt to reconnect.") - } - - wg.Wait() - c.Close() - } -} - -func getReconnectDelay(attempts int) time.Duration { - delay := time.Duration(math.Pow(2, float64(attempts))) * time.Second - if delay > maxReconnectDelay { - return maxReconnectDelay - } - return delay -} - -func categorizeConnectionError(err error) string { - if err == nil { - return "unknown" - } - - errStr := strings.ToLower(err.Error()) - - switch { - case strings.Contains(errStr, "timeout"): - return "timeout" - case strings.Contains(errStr, "refused"): - return "connection_refused" - case strings.Contains(errStr, "no such host"): - return "dns_resolution" - case strings.Contains(errStr, "network"): - return "network_error" - case strings.Contains(errStr, "tls"): - return "tls_error" - case strings.Contains(errStr, "websocket"): - return "websocket_error" - default: - return "other" - } -} diff --git a/cmd/funnel/go.mod b/cmd/funnel/go.mod deleted file mode 100644 index fb2c473..0000000 --- a/cmd/funnel/go.mod +++ /dev/null @@ -1,28 +0,0 @@ -module github.com/karol-broda/funnel/cmd/funnel - -go 1.24.4 - -require ( - github.com/karol-broda/funnel/client v0.0.0-00010101000000-000000000000 - github.com/karol-broda/funnel/shared v0.0.0-00010101000000-000000000000 - github.com/karol-broda/funnel/version v0.0.0-00010101000000-000000000000 -) - -require github.com/BurntSushi/toml v1.5.0 // indirect - -require ( - github.com/gorilla/websocket v1.5.3 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/rs/zerolog v1.34.0 - github.com/spf13/cobra v1.9.1 - github.com/spf13/pflag v1.0.6 // indirect - golang.org/x/sys v0.32.0 // indirect -) - -replace github.com/karol-broda/funnel/client => ../../client - -replace github.com/karol-broda/funnel/shared => ../../shared - -replace github.com/karol-broda/funnel/version => ../../version diff --git a/cmd/funnel/go.sum b/cmd/funnel/go.sum deleted file mode 100644 index 1d397bb..0000000 --- a/cmd/funnel/go.sum +++ /dev/null @@ -1,30 +0,0 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/funnel/main.go b/cmd/funnel/main.go deleted file mode 100644 index 4feb095..0000000 --- a/cmd/funnel/main.go +++ /dev/null @@ -1,253 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/signal" - "path/filepath" - "strings" - "syscall" - - "github.com/karol-broda/funnel/client" - "github.com/karol-broda/funnel/shared" - "github.com/karol-broda/funnel/version" - "github.com/rs/zerolog" - "github.com/spf13/cobra" -) - -var ( - server string - local string - id string - inlet string - token string -) - -var rootCmd = &cobra.Command{ - Use: filepath.Base(os.Args[0]), - Short: "a tunnel client for creating secure tunnels", - Long: `a tunnel client that creates secure tunnels to expose local services through a remote server`, -} - -var httpCmd = &cobra.Command{ - Use: "http [address:port | port]", - Short: "create an HTTP tunnel", - Long: `create an HTTP tunnel to expose a local HTTP service through a remote server`, - Args: cobra.ExactArgs(1), - Run: runHTTPClient, -} - -var versionCmd = &cobra.Command{ - Use: "version", - Short: "print version information", - Run: func(cmd *cobra.Command, args []string) { - version.PrintVersionInfo("funnel-client") - }, -} - -var configCmd = &cobra.Command{ - Use: "config", - Short: "manage client configuration", -} - -var configSetTokenCmd = &cobra.Command{ - Use: "set-token ", - Short: "save authentication token to config file", - Args: cobra.ExactArgs(1), - Run: runConfigSetToken, -} - -var configSetServerCmd = &cobra.Command{ - Use: "set-server ", - Short: "save server URL to config file", - Args: cobra.ExactArgs(1), - Run: runConfigSetServer, -} - -var configShowCmd = &cobra.Command{ - Use: "show", - Short: "show current configuration", - Run: runConfigShow, -} - -var configPathCmd = &cobra.Command{ - Use: "path", - Short: "show config file path", - Run: runConfigPath, -} - -var configInlet string - -func init() { - httpCmd.Flags().StringVarP(&server, "server", "s", "", "tunnel server url (overrides config)") - httpCmd.Flags().StringVarP(&id, "id", "i", "", "tunnel id (subdomain)") - httpCmd.Flags().StringVarP(&inlet, "inlet", "", "default", "inlet configuration to use") - httpCmd.Flags().StringVarP(&token, "token", "t", "", "authentication token (overrides config)") - - configCmd.PersistentFlags().StringVar(&configInlet, "inlet", "default", "inlet to configure") - configCmd.AddCommand(configSetTokenCmd, configSetServerCmd, configShowCmd, configPathCmd) - - rootCmd.AddCommand(httpCmd) - rootCmd.AddCommand(versionCmd) - rootCmd.AddCommand(configCmd) -} - -func runHTTPClient(cmd *cobra.Command, args []string) { - shared.InitializeLogging(shared.DefaultLogConfig()) - - logger := shared.GetLogger("client") - - logger.Info(). - Str("version", version.GetVersion()). - Str("inlet", inlet). - Msg("tunnel client starting up") - - localArg := args[0] - if localArg == "" { - logger.Fatal().Msg("local address or port cannot be empty") - } - - if strings.Contains(localArg, ":") { - local = localArg - logger.Info().Str("local_address", local).Msg("using provided address:port") - } else { - local = "localhost:" + localArg - logger.Info().Str("port", localArg).Str("constructed_local", local).Msg("constructed local address from port") - } - - // resolve server URL and token from config or command line flags - finalServer, finalToken, err := resolveServerAndToken(logger) - if err != nil { - logger.Fatal().Err(err).Msg("failed to resolve server configuration") - } - - logger.Info().Str("server", finalServer).Bool("has_token", finalToken != "").Msg("using server configuration") - - if id == "" { - generatedID, err := shared.GenerateDomainSafeID() - if err != nil { - logger.Fatal().Err(err).Msg("failed to generate tunnel ID") - } - id = generatedID - logger.Info().Str("generated_id", id).Msg("generated tunnel ID") - } else { - logger.Info().Str("provided_id", id).Msg("using provided tunnel ID") - } - - logger.Info().Msg("client configuration validated, starting tunnel") - - shutdownChan := make(chan struct{}) - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - go func() { - <-sigChan - logger.Info().Msg("received shutdown signal, initiating shutdown") - close(shutdownChan) - }() - - client.Run(id, finalServer, local, finalToken, shutdownChan) - - logger.Info().Msg("client has shut down") -} - -func resolveServerAndToken(logger zerolog.Logger) (string, string, error) { - var finalServer string - var finalToken string - - // try to load configuration - configManager := client.NewConfigManager() - inletConfig, err := configManager.GetInlet(inlet) - - // resolve server: command line flag takes precedence over config - if server != "" { - finalServer = server - logger.Info().Str("server", server).Msg("using server from command line flag") - } else if err == nil && inletConfig != nil { - finalServer = inletConfig.Server - logger.Info(). - Str("inlet", inlet). - Str("server", inletConfig.Server). - Str("domain", inletConfig.Domain). - Msg("using server from configuration") - } else { - logger.Error().Err(err).Str("inlet", inlet).Msg("failed to load inlet configuration") - return "", "", fmt.Errorf("no configuration found. please specify --server flag or create a config file at ~/.config/funnel/config.toml") - } - - // resolve token: command line flag takes precedence over config - if token != "" { - finalToken = token - logger.Info().Msg("using token from command line flag") - } else if err == nil && inletConfig != nil && inletConfig.Token != "" { - finalToken = inletConfig.Token - logger.Info().Str("inlet", inlet).Msg("using token from configuration") - } - - return finalServer, finalToken, nil -} - -func runConfigSetToken(cmd *cobra.Command, args []string) { - tokenValue := args[0] - - configManager := client.NewConfigManager() - if err := configManager.SetToken(configInlet, tokenValue); err != nil { - fmt.Fprintf(os.Stderr, "error: failed to save token: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Token saved to inlet %q\n", configInlet) - fmt.Printf(" config file: %s\n", configManager.GetConfigPath()) - fmt.Printf("\n Warning: token is stored in plain text in the config file.\n") - fmt.Printf(" Ensure the file has appropriate permissions (chmod 600).\n") -} - -func runConfigSetServer(cmd *cobra.Command, args []string) { - serverValue := args[0] - - configManager := client.NewConfigManager() - if err := configManager.SetServer(configInlet, serverValue); err != nil { - fmt.Fprintf(os.Stderr, "error: failed to save server: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Server saved to inlet %q\n", configInlet) - fmt.Printf(" config file: %s\n", configManager.GetConfigPath()) -} - -func runConfigShow(cmd *cobra.Command, args []string) { - configManager := client.NewConfigManager() - config := configManager.GetConfig() - - fmt.Printf("Config file: %s\n\n", configManager.GetConfigPath()) - - if config == nil || len(config.Inlets) == 0 { - fmt.Println("No inlets configured.") - return - } - - for name, inlet := range config.Inlets { - fmt.Printf("[%s]\n", name) - if inlet.Server != "" { - fmt.Printf(" server: %s\n", inlet.Server) - } - if inlet.Domain != "" { - fmt.Printf(" domain: %s\n", inlet.Domain) - } - if inlet.Token != "" { - fmt.Printf(" token: %s...\n", inlet.Token[:min(10, len(inlet.Token))]) - } - fmt.Println() - } -} - -func runConfigPath(cmd *cobra.Command, args []string) { - configManager := client.NewConfigManager() - fmt.Println(configManager.GetConfigPath()) -} - -func main() { - if err := rootCmd.Execute(); err != nil { - os.Exit(1) - } -} diff --git a/cmd/server/go.mod b/cmd/server/go.mod deleted file mode 100644 index dd7531f..0000000 --- a/cmd/server/go.mod +++ /dev/null @@ -1,225 +0,0 @@ -module github.com/karol-broda/funnel/cmd/server - -go 1.24.4 - -require ( - github.com/karol-broda/funnel/server v0.0.0-00010101000000-000000000000 - github.com/karol-broda/funnel/shared v0.0.0-00010101000000-000000000000 - github.com/karol-broda/funnel/version v0.0.0-20250808200607-dc36be944b32 -) - -require ( - cloud.google.com/go/auth v0.15.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect - cloud.google.com/go/compute/metadata v0.6.0 // indirect - github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect - github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect - github.com/Azure/go-autorest v14.2.0+incompatible // indirect - github.com/Azure/go-autorest/autorest v0.11.30 // indirect - github.com/Azure/go-autorest/autorest/adal v0.9.22 // indirect - github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect - github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect - github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect - github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect - github.com/Azure/go-autorest/logger v0.2.1 // indirect - github.com/Azure/go-autorest/tracing v0.6.0 // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 // indirect - github.com/KyleBanks/depth v1.2.1 // indirect - github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect - github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect - github.com/aliyun/alibaba-cloud-sdk-go v1.63.100 // indirect - github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect - github.com/aws/aws-sdk-go-v2/config v1.29.9 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.62 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect - github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.1 // indirect - github.com/aws/aws-sdk-go-v2/service/route53 v1.50.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 // indirect - github.com/aws/smithy-go v1.22.2 // indirect - github.com/baidubce/bce-sdk-go v0.9.223 // indirect - github.com/benbjohnson/clock v1.3.0 // indirect - github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/civo/civogo v0.3.11 // indirect - github.com/cloudflare/cloudflare-go v0.115.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dimchansky/utfbom v1.1.1 // indirect - github.com/dnsimple/dnsimple-go v1.7.0 // indirect - github.com/exoscale/egoscale/v3 v3.1.13 // indirect - github.com/fatih/structs v1.1.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.2 // indirect - github.com/ghodss/yaml v1.0.0 // indirect - github.com/go-acme/lego/v4 v4.23.1 // indirect - github.com/go-errors/errors v1.0.1 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/jsonreference v0.20.0 // indirect - github.com/go-openapi/spec v0.20.6 // indirect - github.com/go-openapi/swag v0.19.15 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.16.0 // indirect - github.com/go-resty/resty/v2 v2.16.5 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect - github.com/goccy/go-json v0.10.5 // indirect - github.com/gofrs/flock v0.12.1 // indirect - github.com/golang-jwt/jwt/v4 v4.5.1 // indirect - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect - github.com/google/go-querystring v1.1.0 // indirect - github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect - github.com/googleapis/gax-go/v2 v2.14.1 // indirect - github.com/gophercloud/gophercloud v1.14.1 // indirect - github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect - github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect - github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.141 // indirect - github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect - github.com/infobloxopen/infoblox-go-client/v2 v2.9.0 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect - github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect - github.com/kylelemons/godebug v1.1.0 // indirect - github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect - github.com/labbsr0x/goh v1.0.1 // indirect - github.com/leodido/go-urn v1.2.4 // indirect - github.com/linode/linodego v1.48.1 // indirect - github.com/liquidweb/liquidweb-cli v0.6.9 // indirect - github.com/liquidweb/liquidweb-go v1.6.4 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mailru/easyjson v0.7.6 // indirect - github.com/miekg/dns v1.1.64 // indirect - github.com/mimuret/golang-iij-dpf v0.9.1 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect - github.com/nrdcg/auroradns v1.1.0 // indirect - github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 // indirect - github.com/nrdcg/desec v0.10.0 // indirect - github.com/nrdcg/dnspod-go v0.4.0 // indirect - github.com/nrdcg/freemyip v0.3.0 // indirect - github.com/nrdcg/goacmedns v0.2.0 // indirect - github.com/nrdcg/goinwx v0.10.0 // indirect - github.com/nrdcg/mailinabox v0.2.0 // indirect - github.com/nrdcg/namesilo v0.2.1 // indirect - github.com/nrdcg/nodion v0.1.0 // indirect - github.com/nrdcg/porkbun v0.4.0 // indirect - github.com/nzdjb/go-metaname v1.0.0 // indirect - github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect - github.com/oracle/oci-go-sdk/v65 v65.87.0 // indirect - github.com/ovh/go-ovh v1.7.0 // indirect - github.com/patrickmn/go-cache v2.1.0+incompatible // indirect - github.com/pelletier/go-toml/v2 v2.1.0 // indirect - github.com/peterhellberg/link v1.2.0 // indirect - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/pquerna/otp v1.4.0 // indirect - github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect - github.com/sacloud/api-client-go v0.2.10 // indirect - github.com/sacloud/go-http v0.1.8 // indirect - github.com/sacloud/iaas-api-go v1.14.0 // indirect - github.com/sacloud/packages-go v0.0.10 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/scaleway/scaleway-sdk-go v1.0.0-beta.32 // indirect - github.com/selectel/domains-go v1.1.0 // indirect - github.com/selectel/go-selvpcclient/v3 v3.2.1 // indirect - github.com/shopspring/decimal v1.3.1 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect - github.com/softlayer/softlayer-go v1.1.7 // indirect - github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect - github.com/sony/gobreaker v0.5.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/viper v1.18.2 // indirect - github.com/stretchr/testify v1.10.0 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - github.com/swaggo/files/v2 v2.0.0 // indirect - github.com/swaggo/http-swagger/v2 v2.0.2 // indirect - github.com/swaggo/swag v1.16.6 // indirect - github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128 // indirect - github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128 // indirect - github.com/tjfoc/gmsm v1.4.1 // indirect - github.com/transip/gotransip/v6 v6.26.0 // indirect - github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec // indirect - github.com/vinyldns/go-vinyldns v0.9.16 // indirect - github.com/volcengine/volc-sdk-golang v1.0.199 // indirect - github.com/vultr/govultr/v3 v3.17.0 // indirect - github.com/yandex-cloud/go-genproto v0.0.0-20250319153614-fb9d3e5eb01a // indirect - github.com/yandex-cloud/go-sdk v0.0.0-20250320143332-9cbcfc5de4ae // indirect - go.mongodb.org/mongo-driver v1.13.1 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect - go.opentelemetry.io/otel v1.34.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.34.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - go.uber.org/ratelimit v0.3.0 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/oauth2 v0.28.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/text v0.24.0 // indirect - golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.32.0 // indirect - google.golang.org/api v0.227.0 // indirect - google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect - google.golang.org/grpc v1.71.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/ns1/ns1-go.v2 v2.13.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) - -require ( - github.com/gorilla/websocket v1.5.3 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/rs/zerolog v1.34.0 // indirect - github.com/spf13/cobra v1.9.1 - github.com/spf13/pflag v1.0.6 // indirect - golang.org/x/sys v0.32.0 // indirect -) - -replace github.com/karol-broda/funnel/server => ../../server - -replace github.com/karol-broda/funnel/shared => ../../shared - -replace github.com/karol-broda/funnel/version => ../../version diff --git a/cmd/server/go.sum b/cmd/server/go.sum deleted file mode 100644 index c6eee0e..0000000 --- a/cmd/server/go.sum +++ /dev/null @@ -1,1430 +0,0 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= -cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= -cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= -cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 h1:Dy3M9aegiI7d7PF1LUdjbVigJReo+QOceYsMyFh9qoE= -github.com/AdamSLevy/jsonrpc2/v14 v14.1.0/go.mod h1:ZakZtbCXxCz82NJvq7MoREtiQesnDfrtF6RFUGzQfLo= -github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= -github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 h1:zLzoX5+W2l95UJoVwiyNS4dX8vHyQ6x2xRLoBBL9wMk= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0/go.mod h1:wVEOJfGTj0oPAUGA1JuRAvz/lxXQsWW16axmHPP47Bk= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= -github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= -github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= -github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= -github.com/Azure/go-autorest/autorest v0.11.30 h1:iaZ1RGz/ALZtN5eq4Nr1SOFSlf2E4pDI3Tcsl+dZPVE= -github.com/Azure/go-autorest/autorest v0.11.30/go.mod h1:t1kpPIOpIVX7annvothKvb0stsrXa37i7b+xpmBW8Fs= -github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= -github.com/Azure/go-autorest/autorest/adal v0.9.22 h1:/GblQdIudfEM3AWWZ0mrYJQSd7JS4S/Mbzh6F0ov0Xc= -github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= -github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= -github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= -github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 h1:w77/uPk80ZET2F+AfQExZyEWtn+0Rk/uw17m9fv5Ajc= -github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= -github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= -github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= -github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= -github.com/Azure/go-autorest/autorest/mocks v0.4.2 h1:PGN4EDXnuQbojHbU0UWoNvmu9AGVwYHG9/fkDYhtAfw= -github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= -github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc= -github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M= -github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= -github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= -github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= -github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= -github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= -github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= -github.com/HdrHistogram/hdrhistogram-go v1.1.0/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= -github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= -github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= -github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= -github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 h1:xPMsUicZ3iosVPSIP7bW5EcGUzjiiMl1OYTe14y/R24= -github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87/go.mod h1:iGLljf5n9GjT6kc0HBvyI1nOKnGQbNB66VzSNbK5iks= -github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= -github.com/Shopify/sarama v1.30.1/go.mod h1:hGgx05L/DiW8XYBXeJdKIN6V2QUy2H6JqME5VT1NLRw= -github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= -github.com/Shopify/toxiproxy/v2 v2.1.6-0.20210914104332-15ea381dcdae/go.mod h1:/cvHQkZ1fst0EmZnA5dFtiQdWCNCFYzb+uE2vqVgvx0= -github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= -github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= -github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= -github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 h1:F1j7z+/DKEsYqZNoxC6wvfmaiDneLsQOFQmuq9NADSY= -github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2/go.mod h1:QlXr/TrICfQ/ANa76sLeQyhAJyNR9sEcfNuZBkY9jgY= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/aliyun/alibaba-cloud-sdk-go v1.63.100 h1:yUkCbrSM1cWtgBfRVKMQtdt22KhDvKY7g4V+92eG9wA= -github.com/aliyun/alibaba-cloud-sdk-go v1.63.100/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= -github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= -github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/config v1.29.9 h1:Kg+fAYNaJeGXp1vmjtidss8O2uXIsXwaRqsQJKXVr+0= -github.com/aws/aws-sdk-go-v2/config v1.29.9/go.mod h1:oU3jj2O53kgOU4TXq/yipt6ryiooYjlkqqVaZk7gY/U= -github.com/aws/aws-sdk-go-v2/credentials v1.17.62 h1:fvtQY3zFzYJ9CfixuAQ96IxDrBajbBWGqjNTCa79ocU= -github.com/aws/aws-sdk-go-v2/credentials v1.17.62/go.mod h1:ElETBxIQqcxej++Cs8GyPBbgMys5DgQPTwo7cUPDKt8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.1 h1:0j58UseBtLuBcP6nY2z4SM1qZEvLF0ylyH6+ggnphLg= -github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.1/go.mod h1:Qy22QnQSdHbZwMZrarsWZBIuK51isPlkD+Z4sztxX0o= -github.com/aws/aws-sdk-go-v2/service/route53 v1.50.0 h1:/nkJHXtJXJeelXHqG0898+fWKgvfaXBhGzbCsSmn9j8= -github.com/aws/aws-sdk-go-v2/service/route53 v1.50.0/go.mod h1:kGYOjvTa0Vw0qxrqrOLut1vMnui6qLxqv/SX3vYeM8Y= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 h1:8JdC7Gr9NROg1Rusk25IcZeTO59zLxsKgE0gkh5O6h0= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.1/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.1 h1:KwuLovgQPcdjNMfFt9OhUd9a2OwcOKhxfvF4glTzLuA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= -github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= -github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= -github.com/baidubce/bce-sdk-go v0.9.223 h1:vvDeIemf7ePPP59nLHCntQ/vS++ok2HKbRPgmz1VZKU= -github.com/baidubce/bce-sdk-go v0.9.223/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg= -github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= -github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= -github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= -github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/c-bata/go-prompt v0.2.5/go.mod h1:vFnjEGDIIA/Lib7giyE4E9c50Lvl8j0S+7FVlAwDAVw= -github.com/casbin/casbin/v2 v2.37.0/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg= -github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= -github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= -github.com/civo/civogo v0.3.11 h1:mON/fyrV946Sbk6paRtOSGsN+asCgCmHCgArf5xmGxM= -github.com/civo/civogo v0.3.11/go.mod h1:7+GeeFwc4AYTULaEshpT2vIcl3Qq8HPoxA17viX3l6g= -github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= -github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= -github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= -github.com/dnsimple/dnsimple-go v1.7.0 h1:JKu9xJtZ3SqOC+BuYgAWeab7+EEx0sz422vu8j611ZY= -github.com/dnsimple/dnsimple-go v1.7.0/go.mod h1:EKpuihlWizqYafSnQHGCd/gyvy3HkEQJ7ODB4KdV8T8= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= -github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= -github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/exoscale/egoscale/v3 v3.1.13 h1:CAGC7QRjp2AiGj01agsSD0VKCp4OZmW5f51vV2IguNQ= -github.com/exoscale/egoscale/v3 v3.1.13/go.mod h1:t9+MpSEam94na48O/xgvvPFpQPRiwZ3kBN4/UuQtKco= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= -github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= -github.com/franela/goblin v0.0.0-20210519012713-85d372ac71e2/go.mod h1:VzmDKDJVZI3aJmnRI9VjAn9nJ8qPPsN1fqzr9dqInIo= -github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= -github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= -github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-acme/lego/v4 v4.23.1 h1:lZ5fGtGESA2L9FB8dNTvrQUq3/X4QOb8ExkKyY7LSV4= -github.com/go-acme/lego/v4 v4.23.1/go.mod h1:7UMVR7oQbIYw6V7mTgGwi4Er7B6Ww0c+c8feiBM0EgI= -github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s= -github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= -github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= -github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= -github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= -github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= -github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= -github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= -github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA= -github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= -github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= -github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= -github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= -github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= -github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= -github.com/gophercloud/gophercloud v1.14.1 h1:DTCNaTVGl8/cFu58O1JwWgis9gtISAFONqpMKNg/Vpw= -github.com/gophercloud/gophercloud v1.14.1/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= -github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 h1:sH7xkTfYzxIEgzq1tDHIMKRh1vThOEOGNsettdEeLbE= -github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56/go.mod h1:VSalo4adEk+3sNkmVJLnhHoOyOYYS8sTWLG4mv5BKto= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= -github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= -github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= -github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= -github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= -github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.141 h1:8i57QAi5u+iPAYze92bkIvZoHiS0J45ndul5glr/NE8= -github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.141/go.mod h1:Y/+YLCFCJtS29i2MbYPTUlNNfwXvkzEsZKR0imY/2aY= -github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df h1:MZf03xP9WdakyXhOWuAD5uPK3wHh96wCsqe3hCMKh8E= -github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhKWFeDesPjMj+wCHReeknARU3wqlyN4= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= -github.com/infobloxopen/infoblox-go-client/v2 v2.9.0 h1:wS8kTlQVeVbrepeY83s9X+XdSa6Qah5KO+tdW+zRQXU= -github.com/infobloxopen/infoblox-go-client/v2 v2.9.0/go.mod h1:NeNJpz09efw/edzqkVivGv1bWqBXTomqYBRFbP+XBqg= -github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= -github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= -github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= -github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= -github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= -github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= -github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= -github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc= -github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= -github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= -github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= -github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= -github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= -github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= -github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= -github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/labbsr0x/bindman-dns-webhook v1.0.2 h1:I7ITbmQPAVwrDdhd6dHKi+MYJTJqPCK0jE6YNBAevnk= -github.com/labbsr0x/bindman-dns-webhook v1.0.2/go.mod h1:p6b+VCXIR8NYKpDr8/dg1HKfQoRHCdcsROXKvmoehKA= -github.com/labbsr0x/goh v1.0.1 h1:97aBJkDjpyBZGPbQuOK5/gHcSFbcr5aRsq3RSRJFpPk= -github.com/labbsr0x/goh v1.0.1/go.mod h1:8K2UhVoaWXcCU7Lxoa2omWnC8gyW8px7/lmO61c027w= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= -github.com/linode/linodego v1.48.1 h1:Ojw1S+K5jJr1dggO8/H6r4FINxXnJbOU5GkbpaTfmhU= -github.com/linode/linodego v1.48.1/go.mod h1:fc3t60If8X+yZTFAebhCnNDFrhwQhq9HDU92WnBousQ= -github.com/liquidweb/go-lwApi v0.0.0-20190605172801-52a4864d2738/go.mod h1:0sYF9rMXb0vlG+4SzdiGMXHheCZxjguMq+Zb4S2BfBs= -github.com/liquidweb/liquidweb-cli v0.6.9 h1:acbIvdRauiwbxIsOCEMXGwF75aSJDbDiyAWPjVnwoYM= -github.com/liquidweb/liquidweb-cli v0.6.9/go.mod h1:cE1uvQ+x24NGUL75D0QagOFCG8Wdvmwu8aL9TLmA/eQ= -github.com/liquidweb/liquidweb-go v1.6.4 h1:6S0m3hHSpiLqGD7AFSb7lH/W/qr1wx+tKil9fgIbjMc= -github.com/liquidweb/liquidweb-go v1.6.4/go.mod h1:B934JPIIcdA+uTq2Nz5PgOtG6CuCaEvQKe/Ge/5GgZ4= -github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= -github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= -github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= -github.com/miekg/dns v1.1.47/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= -github.com/miekg/dns v1.1.64 h1:wuZgD9wwCE6XMT05UU/mlSko71eRSXEAm2EbjQXLKnQ= -github.com/miekg/dns v1.1.64/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= -github.com/mimuret/golang-iij-dpf v0.9.1 h1:Gj6EhHJkOhr+q2RnvRPJsPMcjuVnWPSccEHyoEehU34= -github.com/mimuret/golang-iij-dpf v0.9.1/go.mod h1:sl9KyOkESib9+KRD3HaGpgi1xk7eoN2+d96LCLsME2M= -github.com/minio/highwayhash v1.0.1/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= -github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= -github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 h1:o6uBwrhM5C8Ll3MAAxrQxRHEu7FkapwTuI2WmL1rw4g= -github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04/go.mod h1:5sN+Lt1CaY4wsPvgQH/jsuJi4XO2ssZbdsIizr4CVC8= -github.com/nats-io/jwt v1.2.2/go.mod h1:/xX356yQA6LuXI9xWW7mZNpxgF2mBmGecH+Fj34sP5Q= -github.com/nats-io/jwt/v2 v2.0.3/go.mod h1:VRP+deawSXyhNjXmxPCHskrR6Mq50BqpEI5SEcNiGlY= -github.com/nats-io/nats-server/v2 v2.5.0/go.mod h1:Kj86UtrXAL6LwYRA6H4RqzkHhK0Vcv2ZnKD5WbQ1t3g= -github.com/nats-io/nats.go v1.12.1/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/zMCxRT6w= -github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= -github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= -github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nrdcg/auroradns v1.1.0 h1:KekGh8kmf2MNwqZVVYo/fw/ZONt8QMEmbMFOeljteWo= -github.com/nrdcg/auroradns v1.1.0/go.mod h1:O7tViUZbAcnykVnrGkXzIJTHoQCHcgalgAe6X1mzHfk= -github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 h1:ouZ2JWDl8IW5k1qugYbmpbmW8hn85Ig6buSMBRlz3KI= -github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3/go.mod h1:ZwadWt7mVhMHMbAQ1w8IhDqtWO3eWqWq72W7trnaiE8= -github.com/nrdcg/desec v0.10.0 h1:qrEDiqnsvNU9QE7lXIXi/tIHAfyaFXKxF2/8/52O8uM= -github.com/nrdcg/desec v0.10.0/go.mod h1:5+4vyhMRTs49V9CNoODF/HwT8Mwxv9DJ6j+7NekUnBs= -github.com/nrdcg/dnspod-go v0.4.0 h1:c/jn1mLZNKF3/osJ6mz3QPxTudvPArXTjpkmYj0uK6U= -github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= -github.com/nrdcg/freemyip v0.3.0 h1:0D2rXgvLwe2RRaVIjyUcQ4S26+cIS2iFwnhzDsEuuwc= -github.com/nrdcg/freemyip v0.3.0/go.mod h1:c1PscDvA0ukBF0dwelU/IwOakNKnVxetpAQ863RMJoM= -github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0= -github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg= -github.com/nrdcg/goinwx v0.10.0 h1:6W630bjDxQD6OuXKqrFRYVpTt0G/9GXXm3CeOrN0zJM= -github.com/nrdcg/goinwx v0.10.0/go.mod h1:mnMSTi7CXBu2io4DzdOBoGFA1XclD0sEPWJaDhNgkA4= -github.com/nrdcg/mailinabox v0.2.0 h1:IKq8mfKiVwNW2hQii/ng1dJ4yYMMv3HAP3fMFIq2CFk= -github.com/nrdcg/mailinabox v0.2.0/go.mod h1:0yxqeYOiGyxAu7Sb94eMxHPIOsPYXAjTeA9ZhePhGnc= -github.com/nrdcg/namesilo v0.2.1 h1:kLjCjsufdW/IlC+iSfAqj0iQGgKjlbUUeDJio5Y6eMg= -github.com/nrdcg/namesilo v0.2.1/go.mod h1:lwMvfQTyYq+BbjJd30ylEG4GPSS6PII0Tia4rRpRiyw= -github.com/nrdcg/nodion v0.1.0 h1:zLKaqTn2X0aDuBHHfyA1zFgeZfiCpmu/O9DM73okavw= -github.com/nrdcg/nodion v0.1.0/go.mod h1:inbuh3neCtIWlMPZHtEpe43TmRXxHV6+hk97iCZicms= -github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw= -github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/nzdjb/go-metaname v1.0.0 h1:sNASlZC1RM3nSudtBTE1a3ZVTDyTpjqI5WXRPrdZ9Hg= -github.com/nzdjb/go-metaname v1.0.0/go.mod h1:0GR0LshZax1Lz4VrOrfNSE4dGvTp7HGjiemdczXT2H4= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E= -github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/ginkgo/v2 v2.20.1 h1:YlVIbqct+ZmnEph770q9Q7NVAz4wwIiVNahee6JyUzo= -github.com/onsi/ginkgo/v2 v2.20.1/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= -github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= -github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= -github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= -github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= -github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE= -github.com/oracle/oci-go-sdk/v65 v65.87.0 h1:CeVuK8t0dYODGT3P9IDhz4vyXF8poYE1ijoiO5vrKl0= -github.com/oracle/oci-go-sdk/v65 v65.87.0/go.mod h1:IBEV9l1qBzUpo7zgGaRUhbB05BVfcDGYRFBCPlTcPp0= -github.com/ovh/go-ovh v1.7.0 h1:V14nF7FwDjQrZt9g7jzcvAAQ3HN6DNShRFRMC3jLoPw= -github.com/ovh/go-ovh v1.7.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= -github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/performancecopilot/speed/v4 v4.0.0/go.mod h1:qxrSyuDGrTOWfV+uKRFhfxw6h/4HXRGUiZiufxo49BM= -github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c= -github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc= -github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= -github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= -github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= -github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= -github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= -github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= -github.com/regfish/regfish-dnsapi-go v0.1.1 h1:TJFtbePHkd47q5GZwYl1h3DIYXmoxdLjW/SBsPtB5IE= -github.com/regfish/regfish-dnsapi-go v0.1.1/go.mod h1:ubIgXSfqarSnl3XHSn8hIFwFF3h0yrq0ZiWD93Y2VjY= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sacloud/api-client-go v0.2.10 h1:+rv3jDohD+pkdYwOTBiB+jZsM0xK3AxadXRzhp3q66c= -github.com/sacloud/api-client-go v0.2.10/go.mod h1:Jj3CTy2+O4bcMedVDXlbHuqqche85HEPuVXoQFhLaRc= -github.com/sacloud/go-http v0.1.8 h1:ynreWA/vnM8G2ksbMlmefBHsXURKPz49qlPRqQ9IQdw= -github.com/sacloud/go-http v0.1.8/go.mod h1:7TL7TN1fnPKHsMifIqURDkGujnKViCgEz5Ei/LQdFK8= -github.com/sacloud/iaas-api-go v1.14.0 h1:xjkFWqdo4ilTrKPNNYBNWR/CZ/kVRsJrdAHAad6J/AQ= -github.com/sacloud/iaas-api-go v1.14.0/go.mod h1:C8os2Mnj0TOmMdSllwhaDWKMVG2ysFnpe69kyA4M3V0= -github.com/sacloud/packages-go v0.0.10 h1:UiQGjy8LretewkRhsuna1TBM9Vz/l9FoYpQx+D+AOck= -github.com/sacloud/packages-go v0.0.10/go.mod h1:f8QITBh9z4IZc4yE9j21Q8b0sXEMwRlRmhhjWeDVTYs= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.32 h1:4+LP7qmsLSGbmc66m1s5dKRMBwztRppfxFKlYqYte/c= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.32/go.mod h1:kzh+BSAvpoyHHdHBCDhmSWtBc1NbLMZ2lWHqnBoxFks= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/selectel/domains-go v1.1.0 h1:futG50J43ALLKQAnZk9H9yOtLGnSUh7c5hSvuC5gSHo= -github.com/selectel/domains-go v1.1.0/go.mod h1:SugRKfq4sTpnOHquslCpzda72wV8u0cMBHx0C0l+bzA= -github.com/selectel/go-selvpcclient/v3 v3.2.1 h1:ny6WIAMiHzKxOgOEnwcWE79wIQij1AHHylzPA41MXCw= -github.com/selectel/go-selvpcclient/v3 v3.2.1/go.mod h1:3EfSf8aEWyhspOGbvZ6mvnFg7JN5uckxNyBFPGWsXNQ= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= -github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= -github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 h1:hp2CYQUINdZMHdvTdXtPOY2ainKl4IoMcpAXEf2xj3Q= -github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/smartystreets/gunit v1.0.4 h1:tpTjnuH7MLlqhoD21vRoMZbMIi5GmBsAJDFyF67GhZA= -github.com/smartystreets/gunit v1.0.4/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ= -github.com/softlayer/softlayer-go v1.1.7 h1:SgTL+pQZt1h+5QkAhVmHORM/7N9c1X0sljJhuOIHxWE= -github.com/softlayer/softlayer-go v1.1.7/go.mod h1:WeJrBLoTJcaT8nO1azeyHyNpo/fDLtbpbvh+pzts+Qw= -github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e h1:3OgWYFw7jxCZPcvAg+4R8A50GZ+CCkARF10lxu2qDsQ= -github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e/go.mod h1:fKZCUVdirrxrBpwd9wb+lSoVixvpwAu8eHzbQB2tums= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= -github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= -github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/afero v1.4.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= -github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= -github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= -github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= -github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= -github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= -github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= -github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= -github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ= -github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= -github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128 h1:NGnqDc8FQL0YdiCHgTO4Wkso6ToD8rE3JW9VOzoPBNA= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128 h1:mrJ5Fbkd7sZIJ5F6oRfh5zebPQaudPH9Y0+GUmFytYU= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128/go.mod h1:zbsYIBT+VTX4z4ocjTAdLBIWyNYj3z0BRqd0iPdnjsk= -github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= -github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/transip/gotransip/v6 v6.26.0 h1:Aejfvh8rSp8Mj2GX/RpdBjMCv+Iy/DmgfNgczPDP550= -github.com/transip/gotransip/v6 v6.26.0/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s= -github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= -github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= -github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= -github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= -github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= -github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec h1:2s/ghQ8wKE+UzD/hf3P4Gd1j0JI9ncbxv+nsypPoUYI= -github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec/go.mod h1:BZr7Qs3ku1ckpqed8tCRSqTlp8NAeZfAVpfx4OzXMss= -github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= -github.com/vinyldns/go-vinyldns v0.9.16 h1:GZJStDkcCk1F1AcRc64LuuMh+ENL8pHA0CVd4ulRMcQ= -github.com/vinyldns/go-vinyldns v0.9.16/go.mod h1:5qIJOdmzAnatKjurI+Tl4uTus7GJKJxb+zitufjHs3Q= -github.com/volcengine/volc-sdk-golang v1.0.199 h1:zv9QOqTl/IsLwtfC37GlJtcz6vMAHi+pjq8ILWjLYUc= -github.com/volcengine/volc-sdk-golang v1.0.199/go.mod h1:stZX+EPgv1vF4nZwOlEe8iGcriUPRBKX8zA19gXycOQ= -github.com/vultr/govultr/v3 v3.17.0 h1:His5Jh5N8KKqaJxfy3uG6jQbLXy0TmQhNxOiRvkKk00= -github.com/vultr/govultr/v3 v3.17.0/go.mod h1:q34Wd76upKmf+vxFMgaNMH3A8BbsPBmSYZUGC8oZa5w= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= -github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= -github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/yandex-cloud/go-genproto v0.0.0-20250319153614-fb9d3e5eb01a h1:YO8gGyAV4N5SR3NzloZ1128IahSpXWr78oU7aEe7f04= -github.com/yandex-cloud/go-genproto v0.0.0-20250319153614-fb9d3e5eb01a/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= -github.com/yandex-cloud/go-sdk v0.0.0-20250320143332-9cbcfc5de4ae h1:x+uGuST05LVlgCxF5TsP8kQCCTW7uIeAQJ1dKtSmWqE= -github.com/yandex-cloud/go-sdk v0.0.0-20250320143332-9cbcfc5de4ae/go.mod h1:V71iJlJnS/NtNNdg/B7SwccBS19aXxwY3fv/wut9D74= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= -go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0= -go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk= -go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -go.uber.org/ratelimit v0.3.0 h1:IdZd9wqvFXnvLvSEBo0KPcGfkoBGNkpTHlrE3Rcjkjw= -go.uber.org/ratelimit v0.3.0/go.mod h1:So5LG7CV1zWpY1sHe+DXTJqQvOx+FFPFaAs2SnoyBaI= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210915214749-c084706c2272/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210920023735-84f357641f63/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU= -golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= -golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= -golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201110211018-35f3e6cf4a65/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= -gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= -gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc= -google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 h1:Q3nlH8iSQSRUwOskjbcSMcF2jiYMNiQYZ0c2KEJLKKU= -google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38/go.mod h1:xBI+tzfqGGN2JBeSebfKXFSdBpWVQ7sLW40PTupVRm4= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= -gopkg.in/h2non/gock.v1 v1.0.15 h1:SzLqcIlb/fDfg7UvukMpNcWsu7sI5tWwL+KCATZqks0= -gopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= -gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/ns1/ns1-go.v2 v2.13.0 h1:I5NNqI9Bi1SGK92TVkOvLTwux5LNrix/99H2datVh48= -gopkg.in/ns1/ns1-go.v2 v2.13.0/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/cmd/server/main.go b/cmd/server/main.go deleted file mode 100644 index 4e0ccab..0000000 --- a/cmd/server/main.go +++ /dev/null @@ -1,310 +0,0 @@ -// Package main provides the main entry point for the funnel server. -// -// @title Funnel Server API -// @version 1.0 -// @description REST API for tunnel management and monitoring in the funnel tunneling solution -// @description -// @description This API provides endpoints for: -// @description - Health checking -// @description - Server statistics and monitoring -// @description - Tunnel management (list, view, delete) -// @description - Individual tunnel statistics -// -// @contact.name Funnel Support -// @contact.url https://github.com/karol-broda/funnel -// -// @license.name MIT -// @license.url https://github.com/karol-broda/funnel/blob/master/LICENSE.md -// -// @host localhost:8080 -// @BasePath /api -// -// @schemes http https -// -// @tag.name Server -// @tag.description Server health and statistics endpoints -// -// @tag.name Tunnels -// @tag.description Tunnel management and monitoring endpoints -package main - -import ( - "crypto/tls" - "fmt" - "net/http" - "os" - "runtime" - "time" - - "github.com/karol-broda/funnel/server" - "github.com/karol-broda/funnel/shared" - "github.com/karol-broda/funnel/version" - "github.com/spf13/cobra" -) - -var ( - port int - tlsPort int - host string - enableTls bool - certDir string - letsEncryptEmail string - dnsProvidersConfig string - tokenStorePath string - requireAuth bool -) - -func getDefaultCertDir() string { - if runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" { - return "/var/lib/funnel/certs" - } - return "./certs" -} - -func getDefaultTokenStorePath() string { - if runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" { - return "/var/lib/funnel/tokens.json" - } - return "./tokens.json" -} - -func main() { - rootCmd := &cobra.Command{ - Use: "server", - Short: "funnel server", - Run: runServer, - } - versionCmd := &cobra.Command{ - Use: "version", - Short: "print version information", - Run: func(cmd *cobra.Command, args []string) { - version.PrintVersionInfo("tunnel-server") - }, - } - - tokenCmd := &cobra.Command{ - Use: "token", - Short: "manage authentication tokens", - } - - tokenCreateCmd := &cobra.Command{ - Use: "create", - Short: "create a new authentication token", - Run: runTokenCreate, - } - tokenCreateCmd.Flags().StringVar(&tokenName, "name", "", "name for the token (required)") - tokenCreateCmd.MarkFlagRequired("name") - - tokenListCmd := &cobra.Command{ - Use: "list", - Short: "list all active tokens", - Run: runTokenList, - } - - tokenRevokeCmd := &cobra.Command{ - Use: "revoke", - Short: "revoke a token", - Run: runTokenRevoke, - } - tokenRevokeCmd.Flags().StringVar(&tokenName, "name", "", "name of the token to revoke (required)") - tokenRevokeCmd.MarkFlagRequired("name") - - tokenCmd.AddCommand(tokenCreateCmd, tokenListCmd, tokenRevokeCmd) - tokenCmd.PersistentFlags().StringVar(&tokenStorePath, "token-store", getDefaultTokenStorePath(), "path to token store file") - - rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8080, "port to listen on for http") - rootCmd.PersistentFlags().IntVar(&tlsPort, "tls-port", 8443, "port to listen on for https") - rootCmd.PersistentFlags().StringVar(&host, "host", "0.0.0.0", "host to listen on") - rootCmd.PersistentFlags().BoolVar(&enableTls, "enable-tls", false, "enable tls with let's encrypt") - rootCmd.PersistentFlags().StringVar(&certDir, "cert-dir", getDefaultCertDir(), "directory to store tls certificates") - rootCmd.PersistentFlags().StringVar(&letsEncryptEmail, "letsencrypt-email", "", "email address for let's encrypt") - rootCmd.PersistentFlags().StringVar(&dnsProvidersConfig, "dns-providers-config", "", "path to dns providers config file") - rootCmd.PersistentFlags().StringVar(&tokenStorePath, "token-store", getDefaultTokenStorePath(), "path to token store file") - rootCmd.PersistentFlags().BoolVar(&requireAuth, "require-auth", false, "require authentication for tunnel connections") - rootCmd.AddCommand(versionCmd, tokenCmd) - - if err := rootCmd.Execute(); err != nil { - fmt.Println(err) - os.Exit(1) - } -} - -var tokenName string - -func runTokenCreate(cmd *cobra.Command, args []string) { - shared.InitializeLogging(shared.DefaultLogConfig()) - - storePath := cmd.Flag("token-store").Value.String() - tokenStore, err := server.NewTokenStore(storePath) - if err != nil { - fmt.Fprintf(os.Stderr, "error: failed to load token store: %v\n", err) - os.Exit(1) - } - - plainToken, err := tokenStore.Create(tokenName) - if err != nil { - fmt.Fprintf(os.Stderr, "error: failed to create token: %v\n", err) - os.Exit(1) - } - - fmt.Printf("\nToken created for %q\n\n", tokenName) - fmt.Printf(" %s\n\n", plainToken) - fmt.Printf(" Save this token now - it will NOT be shown again.\n") - fmt.Printf(" Token is persisted to disk and survives server restarts.\n\n") -} - -func runTokenList(cmd *cobra.Command, args []string) { - shared.InitializeLogging(shared.DefaultLogConfig()) - - storePath := cmd.Flag("token-store").Value.String() - tokenStore, err := server.NewTokenStore(storePath) - if err != nil { - fmt.Fprintf(os.Stderr, "error: failed to load token store: %v\n", err) - os.Exit(1) - } - - tokens := tokenStore.List() - if len(tokens) == 0 { - fmt.Println("No active tokens found.") - return - } - - fmt.Printf("\n%-20s %-12s %s\n", "NAME", "PREFIX", "CREATED") - fmt.Printf("%-20s %-12s %s\n", "----", "------", "-------") - for _, t := range tokens { - age := formatAge(t.CreatedAt) - fmt.Printf("%-20s %-12s %s\n", t.Name, t.Prefix+"...", age) - } - fmt.Println() -} - -func runTokenRevoke(cmd *cobra.Command, args []string) { - shared.InitializeLogging(shared.DefaultLogConfig()) - - storePath := cmd.Flag("token-store").Value.String() - tokenStore, err := server.NewTokenStore(storePath) - if err != nil { - fmt.Fprintf(os.Stderr, "error: failed to load token store: %v\n", err) - os.Exit(1) - } - - if err := tokenStore.Revoke(tokenName); err != nil { - fmt.Fprintf(os.Stderr, "error: failed to revoke token: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Token %q revoked.\n", tokenName) -} - -func formatAge(t time.Time) string { - d := time.Since(t) - switch { - case d < time.Minute: - return "just now" - case d < time.Hour: - return fmt.Sprintf("%d minutes ago", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%d hours ago", int(d.Hours())) - case d < 7*24*time.Hour: - return fmt.Sprintf("%d days ago", int(d.Hours()/24)) - default: - return t.Format("2006-01-02") - } -} - -func runServer(cmd *cobra.Command, args []string) { - logger := shared.GetLogger("server.main") - - s := server.NewServer() - tunnelRouter := server.NewTunnelRouter(s) - s.SetRouter(tunnelRouter) - - // Initialize API handler - apiHandler := server.NewAPIHandler(s, tunnelRouter) - tunnelRouter.SetAPIHandler(apiHandler) - - // Initialize token store if authentication is enabled - if requireAuth { - tokenStore, err := server.NewTokenStore(tokenStorePath) - if err != nil { - logger.Fatal().Err(err).Str("path", tokenStorePath).Msg("failed to initialize token store") - } - s.SetTokenStore(tokenStore) - - if tokenStore.Count() == 0 { - logger.Warn().Msg("authentication enabled but no tokens exist - run 'server token create --name ' to create one") - } - } else { - logger.Warn().Msg("authentication disabled - anyone can create tunnels. use --require-auth to enable") - } - - if enableTls { - if letsEncryptEmail == "" || dnsProvidersConfig == "" { - logger.Fatal().Msg("--letsencrypt-email and --dns-providers-config must be set when --enable-tls is true") - } - - logger.Info(). - Str("email", letsEncryptEmail). - Str("cert_dir", certDir). - Str("dns_config", dnsProvidersConfig). - Msg("tls is enabled, initializing certificate manager") - - certManager, err := server.NewCertificateManager(letsEncryptEmail, certDir, dnsProvidersConfig) - if err != nil { - logger.Fatal().Err(err).Msg("failed to initialize certificate manager") - } - - if err := certManager.PreloadCertificates(); err != nil { - logger.Fatal().Err(err).Msg("failed to preload certificates") - } - - tlsConfig := &tls.Config{ - GetCertificate: certManager.GetCertificate, - MinVersion: tls.VersionTLS12, - } - - httpsAddr := fmt.Sprintf("%s:%d", host, tlsPort) - httpsServer := &http.Server{ - Addr: httpsAddr, - Handler: tunnelRouter, - TLSConfig: tlsConfig, - } - - logger.Info().Str("address", httpsAddr).Msg("starting https server") - go func() { - if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { - logger.Fatal().Err(err).Msg("https server failed") - } - }() - - httpAddr := fmt.Sprintf("%s:%d", host, port) - httpServer := &http.Server{ - Addr: httpAddr, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "https://"+r.Host+r.URL.String(), http.StatusMovedPermanently) - }), - } - logger.Info().Str("address", httpAddr).Msg("starting http to https redirect server") - go func() { - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Fatal().Err(err).Msg("http redirect server failed") - } - }() - - } else { - httpAddr := fmt.Sprintf("%s:%d", host, port) - httpServer := &http.Server{ - Addr: httpAddr, - Handler: tunnelRouter, - } - logger.Info().Str("address", httpAddr).Msg("starting http server") - go func() { - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Fatal().Err(err).Msg("http server failed") - } - }() - } - - // wait indefinitely - select {} -} diff --git a/crates/funnel-client/Cargo.toml b/crates/funnel-client/Cargo.toml new file mode 100644 index 0000000..9713bc0 --- /dev/null +++ b/crates/funnel-client/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "funnel-client" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +funnel-core.workspace = true + +anyhow.workspace = true +axum.workspace = true +clap = { workspace = true, features = ["derive"] } +config_rs = { package = "config", version = "0.15", default-features = false, features = ["toml"] } +dirs = "6" +console = "0.15" +bytes = "1" +http.workspace = true +http-body-util = "0.1" +hyper = { version = "1", features = ["client", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +indicatif = "0.17" +quinn.workspace = true +reqwest = { workspace = true, features = ["rustls", "stream", "json"] } +rustls = { workspace = true, default-features = false, features = ["ring", "std"] } +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tokio-util.workspace = true +toml = "0.8" +tracing.workspace = true +tracing-subscriber.workspace = true +url = "2" +webpki-roots = "1" + +[dev-dependencies] +axum = { workspace = true, features = ["ws"] } +futures-util = "0.3" +tokio-tungstenite = "0.26" diff --git a/crates/funnel-client/build.rs b/crates/funnel-client/build.rs new file mode 100644 index 0000000..30a591a --- /dev/null +++ b/crates/funnel-client/build.rs @@ -0,0 +1,30 @@ +fn main() { + let hash = run("git", &["rev-parse", "--short", "HEAD"]) + .or_else(|| std::env::var("FUNNEL_GIT_HASH").ok()) + .unwrap_or_else(|| "unknown".to_string()); + + println!("cargo:rustc-env=FUNNEL_GIT_HASH={hash}"); + + // only rerun when git ref changes (new commit, branch switch) or + // when FUNNEL_GIT_HASH is set externally (nix, ci) + if let Some(git_dir) = run("git", &["rev-parse", "--git-dir"]) { + let head_path = format!("{git_dir}/HEAD"); + println!("cargo:rerun-if-changed={head_path}"); + if let Ok(head_ref) = std::fs::read_to_string(&head_path) + && let Some(ref_path) = head_ref.strip_prefix("ref: ") + { + println!("cargo:rerun-if-changed={git_dir}/{}", ref_path.trim()); + } + } + println!("cargo:rerun-if-env-changed=FUNNEL_GIT_HASH"); +} + +fn run(cmd: &str, args: &[&str]) -> Option { + std::process::Command::new(cmd) + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) +} diff --git a/crates/funnel-client/src/api_client.rs b/crates/funnel-client/src/api_client.rs new file mode 100644 index 0000000..1369b0a --- /dev/null +++ b/crates/funnel-client/src/api_client.rs @@ -0,0 +1,62 @@ +use funnel_core::api::Endpoint; +use funnel_core::protocol::PROTOCOL_VERSION; +use serde::Serialize; +use serde::de::DeserializeOwned; + +pub struct ApiClient { + base_url: String, + http: reqwest::Client, + token: Option, +} + +impl ApiClient { + pub fn new(server_url: &str, token: Option) -> Self { + Self { + base_url: server_url.trim_end_matches('/').to_string(), + http: reqwest::Client::new(), + token, + } + } + + fn url(&self, path: &str) -> String { + format!("{}/api/v{PROTOCOL_VERSION}{path}", self.base_url) + } + + fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match self.token { + Some(ref token) => builder.header("authorization", format!("Bearer {token}")), + None => builder, + } + } + + pub async fn request( + &self, + endpoint: &Endpoint<(), Resp>, + ) -> anyhow::Result { + let url = self.url(endpoint.path); + let builder = self.http.request(endpoint.method.clone(), &url); + let resp = self.apply_auth(builder).send().await?; + handle_response(resp).await + } + + #[allow(dead_code)] + pub async fn request_with( + &self, + endpoint: &Endpoint, + body: &Req, + ) -> anyhow::Result { + let url = self.url(endpoint.path); + let builder = self.http.request(endpoint.method.clone(), &url).json(body); + let resp = self.apply_auth(builder).send().await?; + handle_response(resp).await + } +} + +async fn handle_response(resp: reqwest::Response) -> anyhow::Result { + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + Ok(resp.json().await?) +} diff --git a/crates/funnel-client/src/cmd/auth.rs b/crates/funnel-client/src/cmd/auth.rs new file mode 100644 index 0000000..64c79a7 --- /dev/null +++ b/crates/funnel-client/src/cmd/auth.rs @@ -0,0 +1,122 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::Router; +use axum::extract::Query; +use axum::response::Html; +use axum::routing::get; +use serde::Deserialize; +use tokio::sync::Notify; + +use funnel_core::protocol::PROTOCOL_VERSION; + +use crate::config; + +#[derive(Deserialize)] +struct CallbackParams { + token: String, +} + +pub fn logout(ctx_override: Option<&str>) -> anyhow::Result<()> { + let cfg = config::load()?; + let name = ctx_override.unwrap_or(&cfg.current_context).to_string(); + config::clear_token(&name)?; + println!("logged out from context '{name}'"); + Ok(()) +} + +pub async fn login(context_name: &str, provider: &str) -> anyhow::Result<()> { + let cfg = config::load()?; + let resolved = config::resolve(&cfg, Some(context_name))?; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let local_addr = listener.local_addr()?; + let port = local_addr.port(); + + let login_url = format!( + "{}/auth/v{PROTOCOL_VERSION}/{provider}/authorize?cli_port={port}", + resolved.server + ); + + println!("opening browser to log in..."); + if !open_browser(&login_url) { + println!("could not open browser, visit this URL manually:"); + println!(" {login_url}"); + } + + let shutdown = Arc::new(Notify::new()); + let shutdown_signal = Arc::clone(&shutdown); + let context = context_name.to_string(); + + let app = Router::new().route( + "/callback", + get(move |Query(params): Query| { + let shutdown = shutdown_signal.clone(); + let context = context.clone(); + async move { + let result = config::set_token(&context, ¶ms.token); + shutdown.notify_one(); + match result { + Ok(()) => Html( + r#" + +

Login successful

+

You can close this tab and return to the app.

+ + "#.to_string(), + ), + Err(e) => Html(format!( + r#" + +

Error

+

Failed to save token: {e}

+ + "# + )), + } + } + }), + ); + + let server = axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ); + + let shutdown_wait = shutdown.clone(); + tokio::select! { + res = server => { res?; } + () = shutdown_wait.notified() => { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + }; + + println!("logged in successfully, token saved to context '{context_name}'"); + Ok(()) +} + +fn open_browser(url: &str) -> bool { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open").arg(url).spawn().is_ok() + } + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg(url) + .spawn() + .is_ok() + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/c", "start", url]) + .spawn() + .is_ok() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = url; + false + } +} diff --git a/crates/funnel-client/src/cmd/config.rs b/crates/funnel-client/src/cmd/config.rs new file mode 100644 index 0000000..378cb61 --- /dev/null +++ b/crates/funnel-client/src/cmd/config.rs @@ -0,0 +1,25 @@ +use crate::config; + +#[derive(clap::Subcommand)] +pub enum Command { + /// show current configuration + Show, + /// print config file path + Path, +} + +pub fn run(command: &Command) -> anyhow::Result<()> { + match command { + Command::Show => { + let cfg = config::load()?; + let content = toml::to_string_pretty(&cfg)?; + println!("# {}\n", config::config_path().display()); + println!("{content}"); + Ok(()) + } + Command::Path => { + println!("{}", config::config_path().display()); + Ok(()) + } + } +} diff --git a/crates/funnel-client/src/cmd/context.rs b/crates/funnel-client/src/cmd/context.rs new file mode 100644 index 0000000..11dd104 --- /dev/null +++ b/crates/funnel-client/src/cmd/context.rs @@ -0,0 +1,70 @@ +use crate::config; + +#[derive(clap::Subcommand)] +pub enum Command { + /// list all contexts + List, + /// switch to a different context + Use { + /// context name to switch to + name: String, + }, + /// create a new context + Create { + /// context name + name: String, + /// server url + #[arg(long)] + server: String, + }, + /// delete a context + Delete { + /// context name to delete + name: String, + }, +} + +pub fn run(command: Command) -> anyhow::Result<()> { + match command { + Command::List => { + let cfg = config::load()?; + if cfg.contexts.is_empty() { + println!("no contexts configured"); + println!(" create one with: funnel context create --server "); + return Ok(()); + } + for (name, ctx) in &cfg.contexts { + let marker = if name == &cfg.current_context { + " *" + } else { + "" + }; + let token_status = if ctx.token.is_some() { + "authenticated" + } else { + "no token" + }; + println!("{name}{marker}"); + println!(" server: {}", ctx.server); + println!(" status: {token_status}"); + println!(); + } + Ok(()) + } + Command::Use { name } => { + config::set_current_context(&name)?; + println!("switched to context '{name}'"); + Ok(()) + } + Command::Create { name, server } => { + config::create_context(&name, &server)?; + println!("created context '{name}' ({server})"); + Ok(()) + } + Command::Delete { name } => { + config::delete_context(&name)?; + println!("deleted context '{name}'"); + Ok(()) + } + } +} diff --git a/crates/funnel-client/src/cmd/http.rs b/crates/funnel-client/src/cmd/http.rs new file mode 100644 index 0000000..4a970d5 --- /dev/null +++ b/crates/funnel-client/src/cmd/http.rs @@ -0,0 +1,141 @@ +use std::sync::Arc; + +use tokio_util::sync::CancellationToken; + +use funnel_core::tunnel::id::TunnelId; + +use crate::api_client; +use crate::config; +use crate::tunnel::{client::TunnelClient, display::TunnelDisplay, runner}; + +#[derive(clap::Args)] +pub struct Args { + /// local address or port to forward to (e.g. "3000" or "localhost:3000") + pub address: String, + + /// tunnel server url (overrides config) + #[arg(short, long)] + pub server: Option, + + /// tunnel id (subdomain), generated if omitted + #[arg(short, long)] + pub id: Option, + + /// authentication token (overrides config) + #[arg(short, long)] + pub token: Option, + + /// quic port on the server (overrides discovery) + #[arg(long)] + pub quic_port: Option, + + /// skip tls certificate verification (for development) + #[arg(long)] + pub insecure: bool, + + /// associate tunnel with a team + #[arg(long)] + pub team: Option, +} + +pub async fn run(ctx_override: Option<&str>, args: Args) -> anyhow::Result<()> { + let local_addr = normalize_address(&args.address); + + let cfg = config::load().unwrap_or_default(); + let resolved = config::resolve(&cfg, ctx_override).ok(); + + let server_url = args + .server + .or_else(|| resolved.as_ref().map(|r| r.server.clone())) + .filter(|s| !s.is_empty()) + .map(|s| { + if s.contains("://") { + s + } else { + format!("http://{s}") + } + }) + .ok_or_else(|| { + anyhow::anyhow!( + "no server configured. use --server or run: funnel context create default --server " + ) + })?; + + let token = args + .token + .or_else(|| resolved.as_ref().and_then(|r| r.token.clone())); + + let quic_port = if let Some(port) = args.quic_port { + port + } else { + let client = api_client::ApiClient::new(&server_url, token.clone()); + let info = client.request(&funnel_core::api::INFO).await?; + info.quic_port + }; + + let tunnel_id = match args.id { + Some(raw) => TunnelId::new(raw)?, + None => TunnelId::generate(), + }; + + let public_url = runner::build_public_url(&server_url, &tunnel_id) + .unwrap_or_else(|| "".to_string()); + + println!("funnel\n"); + println!(" public url {public_url}"); + println!(" forwarding {local_addr}"); + println!(" tunnel id {tunnel_id}"); + if let Some(ref team_name) = args.team { + println!(" team {team_name}"); + } + println!(); + + let display = Arc::new(TunnelDisplay::new()); + + let client = TunnelClient::new( + tunnel_id, + &server_url, + local_addr, + token, + quic_port, + args.insecure, + args.team, + )?; + + let shutdown = CancellationToken::new(); + let shutdown_signal = shutdown.clone(); + + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + shutdown_signal.cancel(); + }); + + runner::run(&client, shutdown, &display).await; + + display.finish(); + + Ok(()) +} + +fn normalize_address(addr: &str) -> String { + if addr.contains(':') { + addr.to_string() + } else { + format!("localhost:{addr}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_port_only() { + assert_eq!(normalize_address("3000"), "localhost:3000"); + } + + #[test] + fn normalize_full_address() { + assert_eq!(normalize_address("127.0.0.1:8080"), "127.0.0.1:8080"); + } +} diff --git a/crates/funnel-client/src/cmd/keys.rs b/crates/funnel-client/src/cmd/keys.rs new file mode 100644 index 0000000..80e721d --- /dev/null +++ b/crates/funnel-client/src/cmd/keys.rs @@ -0,0 +1,206 @@ +use funnel_core::protocol::PROTOCOL_VERSION; +use serde::{Deserialize, Serialize}; + +#[derive(clap::Subcommand)] +pub enum Command { + /// list api keys + List, + /// create a new api key + Create { + /// name for the new key + name: String, + /// comma separated scopes (defaults to management,tunnels) + #[arg(long)] + scopes: Option, + }, + /// revoke an api key + Revoke { + /// key id to revoke + id: String, + }, +} + +pub async fn run(server: &str, token: &str, command: Command) -> anyhow::Result<()> { + match command { + Command::List => list(server, token).await, + Command::Create { name, scopes } => create(server, token, &name, scopes.as_deref()).await, + Command::Revoke { id } => revoke(server, token, &id).await, + } +} + +#[derive(Deserialize)] +struct ApiKeyView { + id: String, + name: String, + key_prefix: String, + scopes: serde_json::Value, + created_at: String, +} + +#[derive(Deserialize)] +struct CreateKeyResponse { + key: String, + info: ApiKeyView, +} + +#[derive(Serialize)] +struct CreateKeyRequest { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + scopes: Option>, +} + +fn client(token: &str) -> reqwest::Client { + reqwest::Client::builder() + .default_headers({ + let mut h = reqwest::header::HeaderMap::new(); + if let Ok(v) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) { + h.insert(reqwest::header::AUTHORIZATION, v); + } + h + }) + .build() + .unwrap_or_default() +} + +fn format_scopes(scopes: &serde_json::Value) -> String { + scopes + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default() +} + +fn format_timestamp(ts: &str) -> &str { + // trim sub-second precision and timezone suffix for readability + // "2025-01-15T10:30:00.123456Z" -> "2025-01-15T10:30:00" + let without_frac = ts.split('.').next().unwrap_or(ts); + without_frac.strip_suffix('Z').unwrap_or(without_frac) +} + +pub async fn list(server: &str, token: &str) -> anyhow::Result<()> { + let resp = client(token) + .get(format!("{server}/api/v{PROTOCOL_VERSION}/keys")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let keys: Vec = resp.json().await?; + + if keys.is_empty() { + println!("no api keys"); + return Ok(()); + } + + for (i, key) in keys.iter().enumerate() { + println!("{}", key.name); + println!(" id: {}", key.id); + println!(" prefix: {}...", key.key_prefix); + println!(" scopes: {}", format_scopes(&key.scopes)); + println!(" created: {}", format_timestamp(&key.created_at)); + if i < keys.len() - 1 { + println!(); + } + } + + if keys.len() > 1 { + println!("\n{} keys", keys.len()); + } + + Ok(()) +} + +pub async fn create( + server: &str, + token: &str, + name: &str, + scopes: Option<&str>, +) -> anyhow::Result<()> { + let scopes = scopes.map(|s| s.split(',').map(|s| s.trim().to_string()).collect()); + + let resp = client(token) + .post(format!("{server}/api/v{PROTOCOL_VERSION}/keys")) + .json(&CreateKeyRequest { + name: name.to_string(), + scopes, + }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let result: CreateKeyResponse = resp.json().await?; + println!("created key '{}'", result.info.name); + println!(" token: {}", result.key); + println!(" scopes: {}", format_scopes(&result.info.scopes)); + println!("\nsave this token, it will not be shown again."); + + Ok(()) +} + +pub async fn revoke(server: &str, token: &str, id: &str) -> anyhow::Result<()> { + let resp = client(token) + .delete(format!("{server}/api/v{PROTOCOL_VERSION}/keys/{id}")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + println!("revoked key {id}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_timestamp_trims_fractional() { + assert_eq!( + format_timestamp("2025-01-15T10:30:00.123456Z"), + "2025-01-15T10:30:00" + ); + } + + #[test] + fn format_timestamp_handles_no_fractional() { + assert_eq!( + format_timestamp("2025-01-15T10:30:00Z"), + "2025-01-15T10:30:00" + ); + } + + #[test] + fn format_timestamp_passthrough() { + assert_eq!(format_timestamp("unknown"), "unknown"); + } + + #[test] + fn format_scopes_array() { + let scopes = serde_json::json!(["management", "tunnels"]); + assert_eq!(format_scopes(&scopes), "management, tunnels"); + } + + #[test] + fn format_scopes_empty() { + let scopes = serde_json::json!([]); + assert_eq!(format_scopes(&scopes), ""); + } +} diff --git a/crates/funnel-client/src/cmd/mod.rs b/crates/funnel-client/src/cmd/mod.rs new file mode 100644 index 0000000..d9aa70e --- /dev/null +++ b/crates/funnel-client/src/cmd/mod.rs @@ -0,0 +1,10 @@ +pub mod auth; +pub mod config; +pub mod context; +pub mod http; +pub mod keys; +pub mod sessions; +pub mod status; +pub mod teams; +pub mod users; +pub mod whoami; diff --git a/crates/funnel-client/src/cmd/sessions.rs b/crates/funnel-client/src/cmd/sessions.rs new file mode 100644 index 0000000..eb24639 --- /dev/null +++ b/crates/funnel-client/src/cmd/sessions.rs @@ -0,0 +1,89 @@ +use funnel_core::protocol::PROTOCOL_VERSION; +use serde::Deserialize; + +#[derive(Deserialize)] +struct TunnelSession { + id: String, + tunnel_id: String, + connected_at: String, + disconnected_at: Option, + bytes_in: i64, + bytes_out: i64, + requests: i64, +} + +pub async fn list(server: &str, token: &str, all: bool, limit: u32) -> anyhow::Result<()> { + let mut url = format!("{server}/api/v{PROTOCOL_VERSION}/sessions?limit={limit}"); + if all { + url.push_str("&all=true"); + } + + let resp = reqwest::Client::new() + .get(&url) + .header("authorization", format!("Bearer {token}")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let sessions: Vec = resp.json().await?; + + if sessions.is_empty() { + println!("no sessions"); + return Ok(()); + } + + for (i, s) in sessions.iter().enumerate() { + let status = if s.disconnected_at.is_some() { + "closed" + } else { + "active" + }; + println!("{} ({})", s.tunnel_id, status); + println!(" id: {}", s.id); + println!(" started: {}", format_timestamp(&s.connected_at)); + if let Some(ref end) = s.disconnected_at { + println!(" ended: {}", format_timestamp(end)); + } + println!(" requests: {}", s.requests); + println!( + " traffic: {} in / {} out", + format_bytes(s.bytes_in), + format_bytes(s.bytes_out) + ); + if i < sessions.len() - 1 { + println!(); + } + } + + if sessions.len() > 1 { + println!("\n{} sessions", sessions.len()); + } + + Ok(()) +} + +fn format_timestamp(ts: &str) -> &str { + let without_frac = ts.split('.').next().unwrap_or(ts); + without_frac.strip_suffix('Z').unwrap_or(without_frac) +} + +const KIB: i64 = 1024; +const MIB: i64 = 1024 * KIB; +const GIB: i64 = 1024 * MIB; + +fn format_bytes(bytes: i64) -> String { + if bytes >= GIB { + format!("{:.1} GiB", bytes as f64 / GIB as f64) + } else if bytes >= MIB { + format!("{:.1} MiB", bytes as f64 / MIB as f64) + } else if bytes >= KIB { + format!("{:.1} KiB", bytes as f64 / KIB as f64) + } else { + format!("{bytes} B") + } +} diff --git a/crates/funnel-client/src/cmd/status.rs b/crates/funnel-client/src/cmd/status.rs new file mode 100644 index 0000000..fded933 --- /dev/null +++ b/crates/funnel-client/src/cmd/status.rs @@ -0,0 +1,138 @@ +use funnel_core::protocol::PROTOCOL_VERSION; +use serde::Deserialize; + +#[derive(Deserialize)] +struct TunnelInfo { + id: String, + uptime_secs: f64, + stats: TunnelStats, + #[serde(default)] + owner_id: Option, + #[serde(default)] + team_id: Option, +} + +#[derive(Deserialize)] +struct TunnelStats { + bytes_in: u64, + bytes_out: u64, + requests: u64, +} + +pub async fn run(server: &str, token: &str) -> anyhow::Result<()> { + let resp = reqwest::Client::new() + .get(format!("{server}/api/v{PROTOCOL_VERSION}/tunnels")) + .header("authorization", format!("Bearer {token}")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let tunnels: Vec = resp.json().await?; + + if tunnels.is_empty() { + println!("no active tunnels"); + return Ok(()); + } + + for (i, t) in tunnels.iter().enumerate() { + println!("{}", t.id); + println!(" uptime: {}", format_duration(t.uptime_secs)); + println!(" requests: {}", t.stats.requests); + println!( + " traffic: {} in / {} out", + format_bytes(t.stats.bytes_in), + format_bytes(t.stats.bytes_out) + ); + if let Some(ref owner) = t.owner_id { + println!(" owner: {owner}"); + } + if let Some(ref team) = t.team_id { + println!(" team: {team}"); + } + if i < tunnels.len() - 1 { + println!(); + } + } + + if tunnels.len() > 1 { + println!("\n{} tunnels active", tunnels.len()); + } + + Ok(()) +} + +fn format_duration(secs: f64) -> String { + let total = secs as u64; + let hours = total / 3600; + let minutes = (total % 3600) / 60; + let seconds = total % 60; + + if hours > 0 { + format!("{hours}h{minutes:02}m{seconds:02}s") + } else if minutes > 0 { + format!("{minutes}m{seconds:02}s") + } else { + format!("{seconds}s") + } +} + +const KIB: u64 = 1024; +const MIB: u64 = 1024 * KIB; +const GIB: u64 = 1024 * MIB; + +fn format_bytes(bytes: u64) -> String { + if bytes >= GIB { + format!("{:.1} GiB", bytes as f64 / GIB as f64) + } else if bytes >= MIB { + format!("{:.1} MiB", bytes as f64 / MIB as f64) + } else if bytes >= KIB { + format!("{:.1} KiB", bytes as f64 / KIB as f64) + } else { + format!("{bytes} B") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_duration_seconds() { + assert_eq!(format_duration(45.0), "45s"); + } + + #[test] + fn format_duration_minutes() { + assert_eq!(format_duration(125.0), "2m05s"); + } + + #[test] + fn format_duration_hours() { + assert_eq!(format_duration(3661.0), "1h01m01s"); + } + + #[test] + fn format_bytes_small() { + assert_eq!(format_bytes(500), "500 B"); + } + + #[test] + fn format_bytes_kib() { + assert_eq!(format_bytes(2048), "2.0 KiB"); + } + + #[test] + fn format_bytes_mib() { + assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MiB"); + } + + #[test] + fn format_bytes_gib() { + assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.0 GiB"); + } +} diff --git a/crates/funnel-client/src/cmd/teams.rs b/crates/funnel-client/src/cmd/teams.rs new file mode 100644 index 0000000..1eec3a3 --- /dev/null +++ b/crates/funnel-client/src/cmd/teams.rs @@ -0,0 +1,291 @@ +use funnel_core::protocol::PROTOCOL_VERSION; +use serde::{Deserialize, Serialize}; + +#[derive(clap::Subcommand)] +pub enum Command { + /// list teams + List, + /// create a new team + Create { + /// team name + name: String, + }, + /// delete a team + Delete { + /// team id + id: String, + }, + /// list team members + Members { + /// team id + id: String, + }, + /// add a member to a team + AddMember { + /// team id + team_id: String, + /// user id to add + user_id: String, + }, + /// remove a member from a team + RemoveMember { + /// team id + team_id: String, + /// user id to remove + user_id: String, + }, + /// set a member's role in a team + SetRole { + /// team id + team_id: String, + /// user id + user_id: String, + /// role (owner or member) + role: String, + }, +} + +pub async fn run(server: &str, token: &str, command: Command) -> anyhow::Result<()> { + match command { + Command::List => list(server, token).await, + Command::Create { name } => create(server, token, &name).await, + Command::Delete { id } => delete(server, token, &id).await, + Command::Members { id } => members(server, token, &id).await, + Command::AddMember { team_id, user_id } => { + add_member(server, token, &team_id, &user_id).await + } + Command::RemoveMember { team_id, user_id } => { + remove_member(server, token, &team_id, &user_id).await + } + Command::SetRole { + team_id, + user_id, + role, + } => set_role(server, token, &team_id, &user_id, &role).await, + } +} + +#[derive(Deserialize)] +struct Team { + id: String, + name: String, +} + +#[derive(Deserialize)] +struct TeamMembership { + user_id: String, + role: String, + created_at: String, +} + +fn client(token: &str) -> reqwest::Client { + reqwest::Client::builder() + .default_headers({ + let mut h = reqwest::header::HeaderMap::new(); + if let Ok(v) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) { + h.insert(reqwest::header::AUTHORIZATION, v); + } + h + }) + .build() + .unwrap_or_default() +} + +pub async fn list(server: &str, token: &str) -> anyhow::Result<()> { + let resp = client(token) + .get(format!("{server}/api/v{PROTOCOL_VERSION}/teams")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let teams: Vec = resp.json().await?; + + if teams.is_empty() { + println!("no teams"); + return Ok(()); + } + + for (i, t) in teams.iter().enumerate() { + println!("{}", t.name); + println!(" id: {}", t.id); + if i < teams.len() - 1 { + println!(); + } + } + + if teams.len() > 1 { + println!("\n{} teams", teams.len()); + } + + Ok(()) +} + +#[derive(Serialize)] +struct CreateTeamRequest { + name: String, +} + +pub async fn create(server: &str, token: &str, name: &str) -> anyhow::Result<()> { + let resp = client(token) + .post(format!("{server}/api/v{PROTOCOL_VERSION}/teams")) + .json(&CreateTeamRequest { + name: name.to_string(), + }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let team: Team = resp.json().await?; + println!("created team '{}' ({})", team.name, team.id); + Ok(()) +} + +pub async fn delete(server: &str, token: &str, id: &str) -> anyhow::Result<()> { + let resp = client(token) + .delete(format!("{server}/api/v{PROTOCOL_VERSION}/teams/{id}")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + println!("deleted team {id}"); + Ok(()) +} + +pub async fn members(server: &str, token: &str, id: &str) -> anyhow::Result<()> { + let resp = client(token) + .get(format!( + "{server}/api/v{PROTOCOL_VERSION}/teams/{id}/members" + )) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let members: Vec = resp.json().await?; + + if members.is_empty() { + println!("no members"); + return Ok(()); + } + + for m in &members { + println!("{} ({})", m.user_id, m.role); + println!(" joined: {}", format_timestamp(&m.created_at)); + } + + if members.len() > 1 { + println!("\n{} members", members.len()); + } + + Ok(()) +} + +fn format_timestamp(ts: &str) -> &str { + let without_frac = ts.split('.').next().unwrap_or(ts); + without_frac.strip_suffix('Z').unwrap_or(without_frac) +} + +#[derive(Serialize)] +struct AddMemberRequest { + user_id: String, +} + +pub async fn add_member( + server: &str, + token: &str, + team_id: &str, + user_id: &str, +) -> anyhow::Result<()> { + let resp = client(token) + .post(format!( + "{server}/api/v{PROTOCOL_VERSION}/teams/{team_id}/members" + )) + .json(&AddMemberRequest { + user_id: user_id.to_string(), + }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + println!("added user {user_id} to team {team_id}"); + Ok(()) +} + +pub async fn remove_member( + server: &str, + token: &str, + team_id: &str, + user_id: &str, +) -> anyhow::Result<()> { + let resp = client(token) + .delete(format!( + "{server}/api/v{PROTOCOL_VERSION}/teams/{team_id}/members/{user_id}" + )) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + println!("removed user {user_id} from team {team_id}"); + Ok(()) +} + +#[derive(Serialize)] +struct SetRoleRequest { + role: String, +} + +pub async fn set_role( + server: &str, + token: &str, + team_id: &str, + user_id: &str, + role: &str, +) -> anyhow::Result<()> { + let resp = client(token) + .put(format!( + "{server}/api/v{PROTOCOL_VERSION}/teams/{team_id}/members/{user_id}/role" + )) + .json(&SetRoleRequest { + role: role.to_string(), + }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + println!("set role '{role}' for user {user_id} in team {team_id}"); + Ok(()) +} diff --git a/crates/funnel-client/src/cmd/users.rs b/crates/funnel-client/src/cmd/users.rs new file mode 100644 index 0000000..54f2c63 --- /dev/null +++ b/crates/funnel-client/src/cmd/users.rs @@ -0,0 +1,166 @@ +use funnel_core::protocol::PROTOCOL_VERSION; +use serde::{Deserialize, Serialize}; + +#[derive(clap::Subcommand)] +pub enum Command { + /// list all users + List { + /// maximum number of users to show + #[arg(long, default_value_t = 50)] + limit: u32, + }, + /// set a user's role + SetRole { + /// user id + id: String, + /// new role (admin or member) + role: String, + }, + /// deactivate a user + Deactivate { + /// user id + id: String, + }, + /// reactivate a user + Reactivate { + /// user id + id: String, + }, +} + +pub async fn run(server: &str, token: &str, command: Command) -> anyhow::Result<()> { + match command { + Command::List { limit } => list(server, token, limit).await, + Command::SetRole { id, role } => set_role(server, token, &id, &role).await, + Command::Deactivate { id } => deactivate(server, token, &id).await, + Command::Reactivate { id } => reactivate(server, token, &id).await, + } +} + +#[derive(Deserialize)] +struct User { + id: String, + email: String, + name: Option, + role: String, + deactivated_at: Option, +} + +fn client(token: &str) -> reqwest::Client { + reqwest::Client::builder() + .default_headers({ + let mut h = reqwest::header::HeaderMap::new(); + if let Ok(v) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) { + h.insert(reqwest::header::AUTHORIZATION, v); + } + h + }) + .build() + .unwrap_or_default() +} + +pub async fn list(server: &str, token: &str, limit: u32) -> anyhow::Result<()> { + let resp = client(token) + .get(format!( + "{server}/api/v{PROTOCOL_VERSION}/users?limit={limit}" + )) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let users: Vec = resp.json().await?; + + if users.is_empty() { + println!("no users"); + return Ok(()); + } + + for (i, u) in users.iter().enumerate() { + let name = u.name.as_deref().unwrap_or("(no name)"); + let status = if u.deactivated_at.is_some() { + " [deactivated]" + } else { + "" + }; + println!("{} ({}){}", u.email, u.role, status); + println!(" id: {}", u.id); + println!(" name: {name}"); + if i < users.len() - 1 { + println!(); + } + } + + if users.len() > 1 { + println!("\n{} users", users.len()); + } + + Ok(()) +} + +#[derive(Serialize)] +struct SetRoleRequest { + role: String, +} + +pub async fn set_role(server: &str, token: &str, id: &str, role: &str) -> anyhow::Result<()> { + let resp = client(token) + .put(format!("{server}/api/v{PROTOCOL_VERSION}/users/{id}/role")) + .json(&SetRoleRequest { + role: role.to_string(), + }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let user: User = resp.json().await?; + println!("updated {} to role '{}'", user.email, user.role); + Ok(()) +} + +pub async fn deactivate(server: &str, token: &str, id: &str) -> anyhow::Result<()> { + let resp = client(token) + .post(format!( + "{server}/api/v{PROTOCOL_VERSION}/users/{id}/deactivate" + )) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let user: User = resp.json().await?; + println!("deactivated {}", user.email); + Ok(()) +} + +pub async fn reactivate(server: &str, token: &str, id: &str) -> anyhow::Result<()> { + let resp = client(token) + .post(format!( + "{server}/api/v{PROTOCOL_VERSION}/users/{id}/reactivate" + )) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let user: User = resp.json().await?; + println!("reactivated {}", user.email); + Ok(()) +} diff --git a/crates/funnel-client/src/cmd/whoami.rs b/crates/funnel-client/src/cmd/whoami.rs new file mode 100644 index 0000000..c8d427c --- /dev/null +++ b/crates/funnel-client/src/cmd/whoami.rs @@ -0,0 +1,41 @@ +use funnel_core::protocol::PROTOCOL_VERSION; +use serde::Deserialize; + +#[derive(Deserialize)] +struct UserInfo { + email: String, + name: Option, + role: String, +} + +pub async fn run(server: &str, token: &str, context_name: &str) -> anyhow::Result<()> { + let resp = reqwest::Client::new() + .get(format!("{server}/api/v{PROTOCOL_VERSION}/me")) + .header("authorization", format!("Bearer {token}")) + .send() + .await?; + + if resp.status().as_u16() == 404 { + println!("no user profile found (api key without linked user)"); + println!(" context: {context_name} ({server})"); + return Ok(()); + } + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let user: UserInfo = resp.json().await?; + + if let Some(name) = &user.name { + println!("{name} <{}>", user.email); + } else { + println!("{}", user.email); + } + println!(" role: {}", user.role); + println!(" context: {context_name} ({server})"); + + Ok(()) +} diff --git a/crates/funnel-client/src/config.rs b/crates/funnel-client/src/config.rs new file mode 100644 index 0000000..dfcb85b --- /dev/null +++ b/crates/funnel-client/src/config.rs @@ -0,0 +1,279 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +const CONFIG_DIR: &str = "funnel"; +const CONFIG_FILE: &str = "config.toml"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunnelConfig { + #[serde(default = "default_context_name")] + pub current_context: String, + #[serde(default)] + pub contexts: HashMap, +} + +impl Default for FunnelConfig { + fn default() -> Self { + Self { + current_context: default_context_name(), + contexts: HashMap::new(), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Context { + #[serde(default)] + pub server: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +fn default_context_name() -> String { + "default".into() +} + +pub struct ResolvedContext { + pub name: String, + pub server: String, + pub token: Option, +} + +pub fn config_path() -> PathBuf { + if let Some(dir) = dirs::config_dir() { + return dir.join(CONFIG_DIR).join(CONFIG_FILE); + } + PathBuf::from(CONFIG_FILE) +} + +pub fn load() -> anyhow::Result { + let path = config_path(); + + let mut builder = config_rs::Config::builder().set_default("current_context", "default")?; + + if path.exists() { + builder = builder.add_source(config_rs::File::from(path)); + } + + if let Ok(ctx) = std::env::var("FUNNEL_CONTEXT") { + builder = builder.set_override("current_context", ctx)?; + } + + let settings = builder.build()?; + let config: FunnelConfig = settings.try_deserialize()?; + Ok(config) +} + +pub fn save(config: &FunnelConfig) -> anyhow::Result<()> { + let path = config_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(config)?; + std::fs::write(&path, content)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + + Ok(()) +} + +/// resolve the active context, applying env var overrides for server and token +pub fn resolve( + config: &FunnelConfig, + context_override: Option<&str>, +) -> anyhow::Result { + let name = context_override.unwrap_or(&config.current_context); + + let ctx = config.contexts.get(name).ok_or_else(|| { + anyhow::anyhow!( + "context '{name}' not found, run: funnel context create {name} --server " + ) + })?; + + let server = std::env::var("FUNNEL_SERVER").unwrap_or_else(|_| ctx.server.clone()); + + let token = std::env::var("FUNNEL_TOKEN") + .ok() + .or_else(|| ctx.token.clone()); + + if server.is_empty() { + anyhow::bail!("server not configured for context '{name}'"); + } + + Ok(ResolvedContext { + name: name.to_string(), + server, + token, + }) +} + +/// resolve context and require a valid token +pub fn resolve_authenticated( + config: &FunnelConfig, + context_override: Option<&str>, +) -> anyhow::Result<(ResolvedContext, String)> { + let resolved = resolve(config, context_override)?; + let token = resolved.token.clone().ok_or_else(|| { + anyhow::anyhow!( + "not logged in for context '{}', run: funnel login", + resolved.name + ) + })?; + Ok((resolved, token)) +} + +pub fn set_token(context_name: &str, token: &str) -> anyhow::Result<()> { + let mut config = load()?; + let ctx = config.contexts.entry(context_name.to_string()).or_default(); + ctx.token = Some(token.to_string()); + save(&config) +} + +pub fn clear_token(context_name: &str) -> anyhow::Result<()> { + let mut config = load()?; + let ctx = config + .contexts + .get_mut(context_name) + .ok_or_else(|| anyhow::anyhow!("context '{context_name}' not found"))?; + ctx.token = None; + save(&config) +} + +pub fn set_current_context(name: &str) -> anyhow::Result<()> { + let mut config = load()?; + if !config.contexts.contains_key(name) { + anyhow::bail!("context '{name}' does not exist"); + } + config.current_context = name.to_string(); + save(&config) +} + +pub fn create_context(name: &str, server: &str) -> anyhow::Result<()> { + let mut config = load()?; + if config.contexts.contains_key(name) { + anyhow::bail!("context '{name}' already exists"); + } + config.contexts.insert( + name.to_string(), + Context { + server: server.to_string(), + ..Default::default() + }, + ); + // first context becomes the current one automatically + if config.contexts.len() == 1 { + config.current_context = name.to_string(); + } + save(&config) +} + +pub fn delete_context(name: &str) -> anyhow::Result<()> { + let mut config = load()?; + if config.contexts.remove(name).is_none() { + anyhow::bail!("context '{name}' does not exist"); + } + if config.current_context == name { + config.current_context = config + .contexts + .keys() + .next() + .cloned() + .unwrap_or_else(default_context_name); + } + save(&config) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn default_config_has_no_contexts() { + let config = FunnelConfig::default(); + assert!(config.contexts.is_empty()); + assert_eq!(config.current_context, "default"); + } + + #[test] + fn roundtrip_toml() { + let mut config = FunnelConfig::default(); + config.contexts.insert( + "default".to_string(), + Context { + server: "https://tunnel.example.com".to_string(), + token: Some("fnl_test123".to_string()), + }, + ); + + let serialized = toml::to_string_pretty(&config).expect("serialize"); + let parsed: FunnelConfig = toml::from_str(&serialized).expect("deserialize"); + + let ctx = parsed.contexts.get("default").expect("default context"); + assert_eq!(ctx.server, "https://tunnel.example.com"); + assert_eq!(ctx.token.as_deref(), Some("fnl_test123")); + } + + #[test] + fn config_path_is_not_empty() { + let path = config_path(); + assert!(!path.as_os_str().is_empty()); + } + + #[test] + fn resolve_missing_context_errors() { + let config = FunnelConfig::default(); + assert!(resolve(&config, None).is_err()); + } + + #[test] + fn resolve_uses_current_context() { + let mut config = FunnelConfig::default(); + config.contexts.insert( + "default".to_string(), + Context { + server: "https://example.com".to_string(), + token: Some("tok".to_string()), + }, + ); + + let resolved = resolve(&config, None).expect("resolve"); + assert_eq!(resolved.name, "default"); + assert_eq!(resolved.server, "https://example.com"); + } + + #[test] + fn resolve_override_context() { + let mut config = FunnelConfig::default(); + config.contexts.insert( + "staging".to_string(), + Context { + server: "https://staging.example.com".to_string(), + ..Default::default() + }, + ); + + let resolved = resolve(&config, Some("staging")).expect("resolve"); + assert_eq!(resolved.name, "staging"); + assert_eq!(resolved.server, "https://staging.example.com"); + } + + #[test] + fn resolve_authenticated_requires_token() { + let mut config = FunnelConfig::default(); + config.contexts.insert( + "default".to_string(), + Context { + server: "https://example.com".to_string(), + ..Default::default() + }, + ); + assert!(resolve_authenticated(&config, None).is_err()); + } +} diff --git a/crates/funnel-client/src/main.rs b/crates/funnel-client/src/main.rs new file mode 100644 index 0000000..a5b1d09 --- /dev/null +++ b/crates/funnel-client/src/main.rs @@ -0,0 +1,152 @@ +mod api_client; +mod cmd; +mod config; +mod tunnel; + +use clap::{Parser, Subcommand}; +use tracing_subscriber::EnvFilter; + +fn build_version() -> &'static str { + Box::leak( + format!( + "{} ({}) protocol v{}", + env!("CARGO_PKG_VERSION"), + env!("FUNNEL_GIT_HASH"), + funnel_core::protocol::PROTOCOL_VERSION, + ) + .into_boxed_str(), + ) +} + +#[derive(Parser)] +#[command( + name = "funnel", + about = "expose local services through secure tunnels", + version = build_version(), +)] +struct Cli { + /// context to use (overrides current_context in config) + #[arg(long, short, global = true)] + context: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// create an http tunnel to a local service + Http(cmd::http::Args), + /// log in via oauth + Login { + /// oauth provider name + #[arg(long, default_value = "github")] + provider: String, + }, + /// log out (clear token for current context) + Logout, + /// show the currently authenticated user + Whoami, + /// show active tunnels on the server + Status, + /// manage api keys + Keys { + #[command(subcommand)] + command: cmd::keys::Command, + }, + /// view tunnel sessions + Sessions { + /// show all sessions (admin only) + #[arg(long)] + all: bool, + + /// maximum number of sessions to show + #[arg(long, default_value_t = 50)] + limit: u32, + }, + /// manage users (admin only) + Users { + #[command(subcommand)] + command: cmd::users::Command, + }, + /// manage teams + Teams { + #[command(subcommand)] + command: cmd::teams::Command, + }, + /// manage server contexts + Context { + #[command(subcommand)] + command: cmd::context::Command, + }, + /// view configuration + Config { + #[command(subcommand)] + command: cmd::config::Command, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .map_err(|_| anyhow::anyhow!("failed to install default crypto provider"))?; + + let cli = Cli::parse(); + + let default_filter = if matches!(cli.command, Command::Http(_)) { + "error" + } else { + "info" + }; + + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter)), + ) + .init(); + + let ctx = cli.context.as_deref(); + + match cli.command { + Command::Http(args) => cmd::http::run(ctx, args).await, + Command::Login { provider } => { + let cfg = config::load()?; + let name = ctx.unwrap_or(&cfg.current_context).to_string(); + cmd::auth::login(&name, &provider).await + } + Command::Logout => cmd::auth::logout(ctx), + Command::Whoami => { + let cfg = config::load()?; + let (resolved, token) = config::resolve_authenticated(&cfg, ctx)?; + cmd::whoami::run(&resolved.server, &token, &resolved.name).await + } + Command::Status => { + let cfg = config::load()?; + let (resolved, token) = config::resolve_authenticated(&cfg, ctx)?; + cmd::status::run(&resolved.server, &token).await + } + Command::Keys { command } => { + let cfg = config::load()?; + let (resolved, token) = config::resolve_authenticated(&cfg, ctx)?; + cmd::keys::run(&resolved.server, &token, command).await + } + Command::Sessions { all, limit } => { + let cfg = config::load()?; + let (resolved, token) = config::resolve_authenticated(&cfg, ctx)?; + cmd::sessions::list(&resolved.server, &token, all, limit).await + } + Command::Users { command } => { + let cfg = config::load()?; + let (resolved, token) = config::resolve_authenticated(&cfg, ctx)?; + cmd::users::run(&resolved.server, &token, command).await + } + Command::Teams { command } => { + let cfg = config::load()?; + let (resolved, token) = config::resolve_authenticated(&cfg, ctx)?; + cmd::teams::run(&resolved.server, &token, command).await + } + Command::Context { command } => cmd::context::run(command), + Command::Config { command } => cmd::config::run(&command), + } +} diff --git a/crates/funnel-client/src/tunnel/client.rs b/crates/funnel-client/src/tunnel/client.rs new file mode 100644 index 0000000..fbc2063 --- /dev/null +++ b/crates/funnel-client/src/tunnel/client.rs @@ -0,0 +1,363 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use http_body_util::BodyExt; +use hyper::body::Incoming; +use hyper_util::rt::TokioIo; +use tokio::io; +use tokio_util::sync::CancellationToken; +use url::Url; + +use funnel_core::protocol::frame; +use funnel_core::protocol::handshake::{Handshake, HandshakeResponse}; +use funnel_core::protocol::request::RequestMeta; +use funnel_core::protocol::{PROTOCOL_VERSION, QUIC_ALPN}; +use funnel_core::tunnel::id::TunnelId; + +use super::display::{RequestResult, TunnelDisplay}; +use super::forwarder::{ForwardResult, ForwardUpgradeResult, Forwarder}; + +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_CONCURRENT_REQUESTS: usize = 128; + +pub struct TunnelClient { + pub tunnel_id: TunnelId, + local_addr: String, + token: Option, + quic_addr: SocketAddr, + host: String, + endpoint: quinn::Endpoint, + team: Option, +} + +impl TunnelClient { + pub fn new( + tunnel_id: TunnelId, + server_url: &str, + local_addr: String, + token: Option, + quic_port: u16, + insecure: bool, + team: Option, + ) -> anyhow::Result { + let url = Url::parse(server_url)?; + let host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("no host in server url"))? + .to_string(); + + let quic_addr = resolve_quic_addr(&host, quic_port)?; + let skip_verify = insecure || is_loopback(&host, &quic_addr); + let client_config = build_client_config(skip_verify)?; + + // bind address must match the remote address family + let bind_addr: SocketAddr = if quic_addr.is_ipv6() { + SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, 0)) + } else { + SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, 0)) + }; + + let mut endpoint = quinn::Endpoint::client(bind_addr)?; + endpoint.set_default_client_config(client_config); + + Ok(Self { + tunnel_id, + local_addr, + token, + quic_addr, + host, + endpoint, + team, + }) + } + + /// connect to the server via quic and run the tunnel until it disconnects. + pub async fn run( + &self, + cancel: CancellationToken, + display: &Arc, + ) -> anyhow::Result<()> { + let conn = self.connect().await?; + let forwarder = Arc::new(Forwarder::new(self.local_addr.clone())); + let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_REQUESTS)); + + display.set_message("waiting for requests..."); + + loop { + tokio::select! { + bi = conn.accept_bi() => { + let (send, recv) = match bi { + Ok(pair) => pair, + Err(e) => { + tracing::debug!(error = %e, "connection closed"); + break; + } + }; + + let fwd = Arc::clone(&forwarder); + let sem = Arc::clone(&semaphore); + let cancel = cancel.clone(); + let display = Arc::clone(display); + + tokio::spawn(async move { + let _permit = tokio::select! { + permit = sem.acquire() => match permit { + Ok(p) => p, + Err(_) => return, + }, + () = cancel.cancelled() => return, + }; + + match handle_stream(send, recv, &fwd).await { + Ok(result) => display.log_request(&result), + Err(e) => display.println(&format!("stream error: {e}")), + } + }); + } + () = cancel.cancelled() => break, + } + } + + conn.close(quinn::VarInt::from_u32(0), b"client shutdown"); + Ok(()) + } + + async fn connect(&self) -> anyhow::Result { + tracing::debug!(addr = %self.quic_addr, host = %self.host, "connecting via quic"); + + let connecting = self.endpoint.connect(self.quic_addr, &self.host)?; + + let conn = tokio::time::timeout(HANDSHAKE_TIMEOUT, connecting) + .await + .map_err(|_| anyhow::anyhow!("quic handshake timed out after {HANDSHAKE_TIMEOUT:?}")) + .and_then(|r| r.map_err(Into::into))?; + + let (mut send, mut recv) = conn.open_bi().await?; + + let handshake = Handshake { + tunnel_id: self.tunnel_id.clone(), + token: self.token.clone(), + team: self.team.clone(), + version: Some(PROTOCOL_VERSION), + }; + + frame::write_meta(&mut send, &handshake).await?; + + let resp: HandshakeResponse = frame::read_meta(&mut recv).await?; + resp.into_result()?; + + tracing::info!(tunnel_id = %self.tunnel_id, "quic tunnel connected"); + + // keep the control stream alive in the background; dropping send + // would finish the stream, which the server interprets as disconnect + tokio::spawn(async move { + let _send = send; + let mut buf = [0u8; 1]; + let _ = recv.read(&mut buf).await; + }); + + Ok(conn) + } +} + +async fn handle_stream( + mut send: quinn::SendStream, + mut recv: quinn::RecvStream, + forwarder: &Forwarder, +) -> anyhow::Result { + let start = Instant::now(); + let meta: RequestMeta = frame::read_meta(&mut recv).await?; + + let method = meta.method.clone(); + let path = meta.path.clone(); + + if meta.upgrade { + return handle_upgrade_stream(send, recv, forwarder, &meta, &method, &path, start).await; + } + + // read full body from quic for normal requests + let body = recv.read_to_end(64 * 1024 * 1024).await?; + let body = Bytes::from(body); + + match forwarder.forward(meta, body).await { + ForwardResult::Success { + meta: resp_meta, + body: incoming, + conn, + } => { + frame::write_meta(&mut send, &resp_meta).await?; + stream_body_to_quic(incoming, &mut send).await?; + send.finish()?; + forwarder.release(conn); + Ok(RequestResult { + method, + path, + status: resp_meta.status, + duration: start.elapsed(), + }) + } + ForwardResult::LocalError { + meta: resp_meta, + body: resp_body, + } => { + frame::write_meta(&mut send, &resp_meta).await?; + send.write_all(&resp_body).await?; + send.finish()?; + Ok(RequestResult { + method, + path, + status: resp_meta.status, + duration: start.elapsed(), + }) + } + } +} + +async fn stream_body_to_quic( + mut body: Incoming, + send: &mut quinn::SendStream, +) -> anyhow::Result<()> { + while let Some(frame) = body.frame().await { + let frame = frame?; + if let Some(data) = frame.data_ref() { + send.write_all(data).await?; + } + } + Ok(()) +} + +async fn handle_upgrade_stream( + mut send: quinn::SendStream, + mut recv: quinn::RecvStream, + forwarder: &Forwarder, + meta: &RequestMeta, + method: &str, + path: &str, + start: Instant, +) -> anyhow::Result { + let result = forwarder.forward_upgrade(meta).await?; + + match result { + ForwardUpgradeResult::Upgraded(upgrade) => { + frame::write_meta(&mut send, &upgrade.meta).await?; + + let status = upgrade.meta.status; + + let upgraded_io = TokioIo::new(upgrade.upgraded); + let (mut local_read, mut local_write) = io::split(upgraded_io); + + let quic_to_local = io::copy(&mut recv, &mut local_write); + let local_to_quic = io::copy(&mut local_read, &mut send); + + tokio::select! { + r = quic_to_local => { + if let Err(e) = r { + tracing::debug!(error = %e, "quic to local copy ended"); + } + } + r = local_to_quic => { + if let Err(e) = r { + tracing::debug!(error = %e, "local to quic copy ended"); + } + } + } + + Ok(RequestResult { + method: method.to_string(), + path: path.to_string(), + status, + duration: start.elapsed(), + }) + } + ForwardUpgradeResult::Rejected(resp_meta, resp_body) => { + frame::write_meta(&mut send, &resp_meta).await?; + send.write_all(&resp_body).await?; + send.finish()?; + + Ok(RequestResult { + method: method.to_string(), + path: path.to_string(), + status: resp_meta.status, + duration: start.elapsed(), + }) + } + } +} + +fn is_loopback(host: &str, addr: &SocketAddr) -> bool { + matches!(host, "localhost" | "127.0.0.1" | "::1") || addr.ip().is_loopback() +} + +fn resolve_quic_addr(host: &str, port: u16) -> anyhow::Result { + let addr_str = format!("{host}:{port}"); + addr_str.parse().or_else(|_| { + use std::net::ToSocketAddrs; + addr_str + .to_socket_addrs()? + .next() + .ok_or_else(|| anyhow::anyhow!("failed to resolve host: {host}")) + }) +} + +fn build_client_config(insecure: bool) -> anyhow::Result { + let mut crypto = if insecure { + rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(SkipVerification)) + .with_no_client_auth() + } else { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth() + }; + + crypto.alpn_protocols = vec![QUIC_ALPN.to_vec()]; + + let quic_config = quinn::crypto::rustls::QuicClientConfig::try_from(crypto)?; + Ok(quinn::ClientConfig::new(Arc::new(quic_config))) +} + +/// certificate verifier that accepts any certificate (for dev/insecure mode) +#[derive(Debug)] +struct SkipVerification; + +impl rustls::client::danger::ServerCertVerifier for SkipVerification { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} diff --git a/crates/funnel-client/src/tunnel/display.rs b/crates/funnel-client/src/tunnel/display.rs new file mode 100644 index 0000000..ecccc37 --- /dev/null +++ b/crates/funnel-client/src/tunnel/display.rs @@ -0,0 +1,126 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use console::Style; +use indicatif::{ProgressBar, ProgressStyle}; + +const SPINNER_TICK_MS: u64 = 80; +const SPINNER_FRAMES: &[&str] = &["โ ‹", "โ ™", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ‡", "โ "]; + +pub struct TunnelDisplay { + pb: ProgressBar, + requests: AtomicU64, + started: Instant, +} + +pub struct RequestResult { + pub method: String, + pub path: String, + pub status: u16, + pub duration: Duration, +} + +impl TunnelDisplay { + pub fn new() -> Self { + let pb = ProgressBar::new_spinner(); + let style = ProgressStyle::default_spinner() + .tick_strings(SPINNER_FRAMES) + .template("{spinner} {msg}") + .unwrap_or_else(|_| ProgressStyle::default_spinner()); + pb.set_style(style); + pb.enable_steady_tick(Duration::from_millis(SPINNER_TICK_MS)); + pb.set_message("waiting for requests..."); + + Self { + pb, + requests: AtomicU64::new(0), + started: Instant::now(), + } + } + + pub fn set_message(&self, msg: &str) { + self.pb.set_message(msg.to_string()); + } + + pub fn println(&self, msg: &str) { + self.pb.println(msg); + } + + pub fn log_request(&self, result: &RequestResult) { + self.requests.fetch_add(1, Ordering::Relaxed); + + let status_style = match result.status { + 200..=299 => Style::new().green(), + 300..=499 => Style::new().yellow(), + _ => Style::new().red(), + }; + + let duration = format_duration(result.duration); + let status = status_style.apply_to(result.status); + + self.pb.println(format!( + "{} {} {} {}", + result.method, result.path, status, duration + )); + } + + pub fn finish(&self) { + let reqs = self.requests.load(Ordering::Relaxed); + let uptime = self.started.elapsed(); + + self.pb.finish_and_clear(); + + println!(); + println!("session summary"); + println!(" requests {reqs}"); + println!(" uptime {}", format_uptime(uptime)); + } +} + +fn format_duration(d: Duration) -> String { + let ms = d.as_millis(); + if ms < 1000 { + format!("{ms}ms") + } else { + format!("{:.1}s", d.as_secs_f64()) + } +} + +fn format_uptime(d: Duration) -> String { + let total_secs = d.as_secs(); + let hours = total_secs / 3600; + let mins = (total_secs % 3600) / 60; + let secs = total_secs % 60; + + if hours > 0 { + format!("{hours}h {mins}m {secs}s") + } else if mins > 0 { + format!("{mins}m {secs}s") + } else { + format!("{secs}s") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_duration_millis() { + assert_eq!(format_duration(Duration::from_millis(12)), "12ms"); + assert_eq!(format_duration(Duration::from_millis(999)), "999ms"); + } + + #[test] + fn format_duration_seconds() { + assert_eq!(format_duration(Duration::from_millis(1200)), "1.2s"); + assert_eq!(format_duration(Duration::from_secs(5)), "5.0s"); + } + + #[test] + fn format_uptime_units() { + assert_eq!(format_uptime(Duration::from_secs(45)), "45s"); + assert_eq!(format_uptime(Duration::from_secs(125)), "2m 5s"); + assert_eq!(format_uptime(Duration::from_secs(3661)), "1h 1m 1s"); + } +} diff --git a/crates/funnel-client/src/tunnel/forwarder.rs b/crates/funnel-client/src/tunnel/forwarder.rs new file mode 100644 index 0000000..3972a22 --- /dev/null +++ b/crates/funnel-client/src/tunnel/forwarder.rs @@ -0,0 +1,557 @@ +use std::collections::HashMap; +use std::sync::{Mutex, PoisonError}; + +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper_util::rt::TokioIo; +use tokio::net::TcpStream; + +use funnel_core::protocol::request::{RequestMeta, ResponseMeta}; + +const HOP_BY_HOP_HEADERS: &[&str] = &[ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "proxy-connection", +]; + +const MAX_IDLE_CONNECTIONS: usize = 8; + +pub struct PooledConnection { + sender: hyper::client::conn::http1::SendRequest>, +} + +pub struct Forwarder { + local_addr: String, + pool: Mutex>, +} + +pub struct UpgradeResult { + pub meta: ResponseMeta, + pub upgraded: hyper::upgrade::Upgraded, +} + +pub enum ForwardUpgradeResult { + Upgraded(UpgradeResult), + Rejected(ResponseMeta, Vec), +} + +pub enum ForwardResult { + Success { + meta: ResponseMeta, + body: Incoming, + conn: PooledConnection, + }, + LocalError { + meta: ResponseMeta, + body: Vec, + }, +} + +impl Forwarder { + pub const fn new(local_addr: String) -> Self { + Self { + local_addr, + pool: Mutex::new(Vec::new()), + } + } + + async fn acquire(&self) -> anyhow::Result { + loop { + let candidate = { + let mut pool = self.pool.lock().unwrap_or_else(PoisonError::into_inner); + pool.pop() + }; + match candidate { + Some(conn) if conn.sender.is_ready() => return Ok(conn), + Some(_) => {} // stale, discard and try next + None => break, + } + } + + let stream = TcpStream::connect(&self.local_addr).await?; + let io = TokioIo::new(stream); + let (sender, conn) = hyper::client::conn::http1::handshake(io).await?; + tokio::spawn(async move { + if let Err(e) = conn.await { + tracing::debug!(error = %e, "connection task ended"); + } + }); + + Ok(PooledConnection { sender }) + } + + pub fn release(&self, conn: PooledConnection) { + if !conn.sender.is_ready() { + return; + } + let mut pool = self.pool.lock().unwrap_or_else(PoisonError::into_inner); + if pool.len() < MAX_IDLE_CONNECTIONS { + pool.push(conn); + } + } + + pub async fn forward(&self, meta: RequestMeta, body: Bytes) -> ForwardResult { + match self.try_forward(&meta, body).await { + Ok((resp_meta, incoming, conn)) => ForwardResult::Success { + meta: resp_meta, + body: incoming, + conn, + }, + Err(e) => { + let (resp_meta, resp_body) = + error_response(502, &format!("local service error: {e}")); + ForwardResult::LocalError { + meta: resp_meta, + body: resp_body, + } + } + } + } + + async fn try_forward( + &self, + meta: &RequestMeta, + body: Bytes, + ) -> anyhow::Result<(ResponseMeta, Incoming, PooledConnection)> { + let mut conn = self.acquire().await?; + + let req = build_request(meta, &self.local_addr, body, false)?; + let resp = conn.sender.send_request(req).await?; + + let status = resp.status().as_u16(); + let headers = collect_response_headers(resp.headers(), false); + let resp_meta = ResponseMeta { status, headers }; + + Ok((resp_meta, resp.into_body(), conn)) + } + + pub async fn forward_upgrade( + &self, + meta: &RequestMeta, + ) -> anyhow::Result { + // upgrades are one shot, no pool reuse + let stream = TcpStream::connect(&self.local_addr).await?; + let io = TokioIo::new(stream); + + let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?; + tokio::spawn(async move { + if let Err(e) = conn.with_upgrades().await { + tracing::debug!(error = %e, "upgrade connection task ended"); + } + }); + + let req = build_request(meta, &self.local_addr, Bytes::new(), true)?; + let resp = sender.send_request(req).await?; + + let status = resp.status().as_u16(); + let is_101 = resp.status() == hyper::StatusCode::SWITCHING_PROTOCOLS; + let headers = collect_response_headers(resp.headers(), is_101); + let resp_meta = ResponseMeta { status, headers }; + + if is_101 { + let upgraded = hyper::upgrade::on(resp).await?; + Ok(ForwardUpgradeResult::Upgraded(UpgradeResult { + meta: resp_meta, + upgraded, + })) + } else { + let resp_body = resp.into_body().collect().await?.to_bytes().to_vec(); + Ok(ForwardUpgradeResult::Rejected(resp_meta, resp_body)) + } + } +} + +fn build_request( + meta: &RequestMeta, + local_addr: &str, + body: Bytes, + is_upgrade: bool, +) -> anyhow::Result>> { + let method: http::Method = meta.method.parse()?; + let mut builder = hyper::Request::builder().method(method).uri(&meta.path); + + if let Some(headers) = builder.headers_mut() { + for (name, values) in &meta.headers { + if !is_upgrade && is_hop_by_hop(name) { + continue; + } + if name.eq_ignore_ascii_case("host") { + continue; + } + for value in values { + if let (Ok(n), Ok(v)) = ( + http::HeaderName::try_from(name.as_str()), + http::HeaderValue::from_str(value), + ) { + headers.append(n, v); + } + } + } + headers.insert(http::header::HOST, http::HeaderValue::from_str(local_addr)?); + } + + Ok(builder.body(Full::new(body))?) +} + +fn is_hop_by_hop(name: &str) -> bool { + HOP_BY_HOP_HEADERS + .iter() + .any(|h| h.eq_ignore_ascii_case(name)) +} + +fn collect_response_headers( + headers: &http::HeaderMap, + keep_hop_by_hop: bool, +) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for (name, value) in headers { + if !keep_hop_by_hop && is_hop_by_hop(name.as_str()) { + continue; + } + let val = value.to_str().unwrap_or("").to_string(); + map.entry(name.to_string()).or_default().push(val); + } + map +} + +fn error_response(status: u16, msg: &str) -> (ResponseMeta, Vec) { + let mut headers = HashMap::new(); + headers.insert("content-type".to_string(), vec!["text/plain".to_string()]); + (ResponseMeta { status, headers }, msg.as_bytes().to_vec()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + use std::net::TcpListener; + + use axum::Router; + use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; + use axum::response::IntoResponse; + use axum::routing::{get, post}; + use futures_util::StreamExt; + use http_body_util::BodyExt; + + type TestResult = Result<(), Box>; + + fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .and_then(|l| l.local_addr()) + .map_or(0, |a| a.port()) + } + + async fn start_test_server(app: Router) -> String { + let port = free_port(); + let addr = format!("127.0.0.1:{port}"); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .expect("bind test server"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("test server crashed"); + }); + addr + } + + fn test_meta(method: &str, path: &str) -> RequestMeta { + RequestMeta { + method: method.into(), + path: path.into(), + headers: HashMap::new(), + upgrade: false, + } + } + + fn upgrade_meta(path: &str) -> RequestMeta { + let mut headers = HashMap::new(); + headers.insert("connection".into(), vec!["Upgrade".into()]); + headers.insert("upgrade".into(), vec!["websocket".into()]); + headers.insert( + "sec-websocket-key".into(), + vec!["dGhlIHNhbXBsZSBub25jZQ==".into()], + ); + headers.insert("sec-websocket-version".into(), vec!["13".into()]); + RequestMeta { + method: "GET".into(), + path: path.into(), + headers, + upgrade: true, + } + } + + /// helper to drain an Incoming body from a ForwardResult::Success + async fn collect_success(result: ForwardResult) -> (ResponseMeta, Vec, PooledConnection) { + match result { + ForwardResult::Success { meta, body, conn } => { + let bytes = body.collect().await.unwrap().to_bytes().to_vec(); + (meta, bytes, conn) + } + ForwardResult::LocalError { meta, .. } => { + panic!( + "expected Success, got LocalError with status {}", + meta.status + ); + } + } + } + + #[test] + fn hop_by_hop_detection() { + assert!(is_hop_by_hop("Connection")); + assert!(is_hop_by_hop("transfer-encoding")); + assert!(is_hop_by_hop("Keep-Alive")); + assert!(!is_hop_by_hop("Content-Type")); + assert!(!is_hop_by_hop("Authorization")); + } + + #[test] + fn error_response_has_correct_status() { + let (meta, body) = error_response(502, "test error"); + assert_eq!(meta.status, 502); + assert_eq!(body, b"test error"); + assert!(meta.headers.contains_key("content-type")); + } + + #[tokio::test] + async fn forward_get_request() -> TestResult { + let app = Router::new().route("/hello", get(|| async { "hello" })); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let result = fwd.forward(test_meta("GET", "/hello"), Bytes::new()).await; + let (meta, body, _conn) = collect_success(result).await; + + assert_eq!(meta.status, 200); + assert_eq!(body, b"hello"); + Ok(()) + } + + #[tokio::test] + async fn forward_post_with_body() -> TestResult { + let app = Router::new().route( + "/echo", + post(|body: String| async move { format!("got: {body}") }), + ); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let result = fwd + .forward(test_meta("POST", "/echo"), Bytes::from("payload")) + .await; + let (meta, body, _conn) = collect_success(result).await; + + assert_eq!(meta.status, 200); + assert_eq!(body, b"got: payload"); + Ok(()) + } + + #[tokio::test] + async fn forward_preserves_response_headers() -> TestResult { + let app = Router::new().route( + "/headers", + get(|| async { + ( + [("x-custom", "test-val"), ("content-type", "text/plain")], + "ok", + ) + }), + ); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let result = fwd + .forward(test_meta("GET", "/headers"), Bytes::new()) + .await; + let (meta, _, _conn) = collect_success(result).await; + + assert_eq!(meta.status, 200); + assert_eq!( + meta.headers.get("x-custom").map(Vec::as_slice), + Some(["test-val".to_string()].as_slice()) + ); + Ok(()) + } + + #[tokio::test] + async fn forward_strips_hop_by_hop_response_headers() -> TestResult { + let app = Router::new().route( + "/hop", + get(|| async { ([("connection", "keep-alive"), ("x-real", "value")], "ok") }), + ); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let result = fwd.forward(test_meta("GET", "/hop"), Bytes::new()).await; + let (meta, _, _conn) = collect_success(result).await; + + assert!(!meta.headers.contains_key("connection")); + assert!(meta.headers.contains_key("x-real")); + Ok(()) + } + + #[tokio::test] + async fn forward_returns_404() -> TestResult { + let app = Router::new(); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let result = fwd + .forward(test_meta("GET", "/missing"), Bytes::new()) + .await; + let (meta, _, _conn) = collect_success(result).await; + + assert_eq!(meta.status, 404); + Ok(()) + } + + #[tokio::test] + async fn forward_unreachable_returns_502() -> TestResult { + let fwd = Forwarder::new("127.0.0.1:1".to_string()); + + let result = fwd.forward(test_meta("GET", "/"), Bytes::new()).await; + + match result { + ForwardResult::LocalError { meta, body } => { + assert_eq!(meta.status, 502); + assert!(String::from_utf8_lossy(&body).contains("local service error")); + } + ForwardResult::Success { .. } => { + panic!("expected LocalError for unreachable host"); + } + } + Ok(()) + } + + #[tokio::test] + async fn forward_sends_request_headers() -> TestResult { + let app = Router::new().route( + "/check", + get(|headers: axum::http::HeaderMap| async move { + headers + .get("x-test") + .and_then(|v| v.to_str().ok()) + .unwrap_or("missing") + .to_string() + }), + ); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let mut meta = test_meta("GET", "/check"); + meta.headers.insert("x-test".into(), vec!["present".into()]); + + let result = fwd.forward(meta, Bytes::new()).await; + let (resp, body, _conn) = collect_success(result).await; + + assert_eq!(resp.status, 200); + assert_eq!(body, b"present"); + Ok(()) + } + + #[tokio::test] + async fn connection_pool_reuses_connections() -> TestResult { + let app = Router::new().route("/ok", get(|| async { "ok" })); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + // first request + let result = fwd.forward(test_meta("GET", "/ok"), Bytes::new()).await; + let (meta, _, conn) = collect_success(result).await; + assert_eq!(meta.status, 200); + fwd.release(conn); + + // pool should have one connection now + assert_eq!(fwd.pool.lock().unwrap().len(), 1); + + // second request should reuse the pooled connection + let result = fwd.forward(test_meta("GET", "/ok"), Bytes::new()).await; + let (meta, _, _conn) = collect_success(result).await; + assert_eq!(meta.status, 200); + + // pool should be empty since the connection is in use + assert_eq!(fwd.pool.lock().unwrap().len(), 0); + Ok(()) + } + + async fn ws_echo_handler(ws: WebSocketUpgrade) -> impl IntoResponse { + ws.on_upgrade(ws_echo) + } + + async fn ws_echo(mut socket: WebSocket) { + while let Some(Ok(msg)) = socket.next().await { + if matches!(msg, Message::Close(_)) { + break; + } + if matches!(msg, Message::Text(_) | Message::Binary(_)) + && socket.send(msg).await.is_err() + { + break; + } + } + } + + #[tokio::test] + async fn forward_upgrade_websocket() -> TestResult { + let app = Router::new().route("/ws", get(ws_echo_handler)); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let meta = upgrade_meta("/ws"); + let result = fwd.forward_upgrade(&meta).await?; + + match result { + ForwardUpgradeResult::Upgraded(u) => { + assert_eq!(u.meta.status, 101); + // verify upgrade headers are preserved + assert!(u.meta.headers.contains_key("upgrade")); + assert!(u.meta.headers.contains_key("connection")); + } + ForwardUpgradeResult::Rejected(meta, _) => { + panic!( + "expected upgrade, got rejection with status {}", + meta.status + ); + } + } + Ok(()) + } + + #[tokio::test] + async fn forward_upgrade_rejected_by_non_ws_endpoint() -> TestResult { + let app = Router::new().route("/not-ws", get(|| async { "not a websocket" })); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let meta = upgrade_meta("/not-ws"); + let result = fwd.forward_upgrade(&meta).await?; + + match result { + ForwardUpgradeResult::Rejected(meta, _) => { + assert_ne!(meta.status, 101); + } + ForwardUpgradeResult::Upgraded(_) => { + panic!("expected rejection from non-ws endpoint"); + } + } + Ok(()) + } + + #[tokio::test] + async fn forward_upgrade_unreachable_returns_error() { + let fwd = Forwarder::new("127.0.0.1:1".to_string()); + let meta = upgrade_meta("/ws"); + + let result = fwd.forward_upgrade(&meta).await; + assert!(result.is_err()); + } +} diff --git a/crates/funnel-client/src/tunnel/mod.rs b/crates/funnel-client/src/tunnel/mod.rs new file mode 100644 index 0000000..d7e9e1b --- /dev/null +++ b/crates/funnel-client/src/tunnel/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod display; +pub mod forwarder; +pub mod runner; diff --git a/crates/funnel-client/src/tunnel/runner.rs b/crates/funnel-client/src/tunnel/runner.rs new file mode 100644 index 0000000..393db5c --- /dev/null +++ b/crates/funnel-client/src/tunnel/runner.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; +use std::time::Duration; + +use tokio_util::sync::CancellationToken; +use url::Url; + +use funnel_core::tunnel::id::TunnelId; + +use super::client::TunnelClient; +use super::display::TunnelDisplay; + +const INITIAL_BACKOFF: Duration = Duration::from_secs(1); +const MAX_BACKOFF: Duration = Duration::from_secs(30); + +pub async fn run(client: &TunnelClient, shutdown: CancellationToken, display: &Arc) { + let mut attempt: u32 = 0; + + loop { + if shutdown.is_cancelled() { + break; + } + + display.set_message("connecting to server..."); + + let run_cancel = shutdown.child_token(); + match client.run(run_cancel, display).await { + Ok(()) => { + attempt = 0; + display.println("connection lost, reconnecting..."); + } + Err(e) => { + display.println(&format!("connection failed: {e}")); + } + } + + let delay = backoff_delay(attempt); + attempt = attempt.saturating_add(1); + + display.set_message(&format!("reconnecting in {}s...", delay.as_secs())); + + tokio::select! { + () = tokio::time::sleep(delay) => {}, + () = shutdown.cancelled() => break, + } + } +} + +fn backoff_delay(attempt: u32) -> Duration { + let secs = 2u64.saturating_pow(attempt); + let delay = Duration::from_secs(secs).min(MAX_BACKOFF); + delay.max(INITIAL_BACKOFF) +} + +pub fn build_public_url(server_url: &str, tunnel_id: &TunnelId) -> Option { + let url = Url::parse(server_url).ok()?; + let host = url.host_str()?; + let scheme = if url.scheme() == "https" { + "https" + } else { + "http" + }; + + match url.port() { + Some(port) => Some(format!("{scheme}://{tunnel_id}.{host}:{port}")), + None => Some(format!("{scheme}://{tunnel_id}.{host}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoff_increases_exponentially() { + assert_eq!(backoff_delay(0), Duration::from_secs(1)); + assert_eq!(backoff_delay(1), Duration::from_secs(2)); + assert_eq!(backoff_delay(2), Duration::from_secs(4)); + assert_eq!(backoff_delay(3), Duration::from_secs(8)); + } + + #[test] + fn backoff_caps_at_max() { + assert_eq!(backoff_delay(10), MAX_BACKOFF); + assert_eq!(backoff_delay(20), MAX_BACKOFF); + } + + type TestResult = Result<(), Box>; + + #[test] + fn public_url_http() -> TestResult { + let id = TunnelId::new("my-tunnel")?; + let url = build_public_url("http://tunnel.example.com", &id); + assert_eq!(url.as_deref(), Some("http://my-tunnel.tunnel.example.com")); + Ok(()) + } + + #[test] + fn public_url_https() -> TestResult { + let id = TunnelId::new("my-tunnel")?; + let url = build_public_url("https://tunnel.example.com", &id); + assert_eq!(url.as_deref(), Some("https://my-tunnel.tunnel.example.com")); + Ok(()) + } + + #[test] + fn public_url_with_port() -> TestResult { + let id = TunnelId::new("abc")?; + let url = build_public_url("http://localhost:8080", &id); + assert_eq!(url.as_deref(), Some("http://abc.localhost:8080")); + Ok(()) + } +} diff --git a/crates/funnel-core/Cargo.toml b/crates/funnel-core/Cargo.toml new file mode 100644 index 0000000..8c948e4 --- /dev/null +++ b/crates/funnel-core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "funnel-core" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[features] +openapi = ["utoipa"] + +[dependencies] +base64 = "0.22" +getrandom = "0.3" +hex = "0.4" +http.workspace = true +nanoid = "0.4" +rmp-serde.workspace = true +serde.workspace = true +sha2 = "0.10" +thiserror.workspace = true +tokio = { version = "1", features = ["io-util"] } +utoipa = { workspace = true, optional = true } + +[dev-dependencies] +pretty_assertions.workspace = true +serde_json.workspace = true +tokio = { version = "1", features = ["io-util", "rt", "macros"] } diff --git a/crates/funnel-core/src/api/mod.rs b/crates/funnel-core/src/api/mod.rs new file mode 100644 index 0000000..8424531 --- /dev/null +++ b/crates/funnel-core/src/api/mod.rs @@ -0,0 +1,19 @@ +mod types; + +pub use types::*; + +use std::marker::PhantomData; + +use http::Method; + +pub struct Endpoint { + pub method: Method, + pub path: &'static str, + _marker: PhantomData Resp>, +} + +pub const INFO: Endpoint<(), ServerInfo> = Endpoint { + method: Method::GET, + path: "/info", + _marker: PhantomData, +}; diff --git a/crates/funnel-core/src/api/types.rs b/crates/funnel-core/src/api/types.rs new file mode 100644 index 0000000..3991a10 --- /dev/null +++ b/crates/funnel-core/src/api/types.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ServerInfo { + pub version: u32, + pub quic_port: u16, +} diff --git a/crates/funnel-core/src/auth/mod.rs b/crates/funnel-core/src/auth/mod.rs new file mode 100644 index 0000000..79c66ba --- /dev/null +++ b/crates/funnel-core/src/auth/mod.rs @@ -0,0 +1 @@ +pub mod token; diff --git a/crates/funnel-core/src/auth/token.rs b/crates/funnel-core/src/auth/token.rs new file mode 100644 index 0000000..a804b26 --- /dev/null +++ b/crates/funnel-core/src/auth/token.rs @@ -0,0 +1,117 @@ +use std::fmt; + +use base64::prelude::*; +use serde::{Deserialize, Serialize}; + +const PREFIX: &str = "sk_"; +const RANDOM_BYTES: usize = 32; +const VISIBLE_PREFIX_LEN: usize = 10; + +/// the visible prefix of an api key, safe for display and logging. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApiKeyPrefix(String); + +impl ApiKeyPrefix { + pub fn from_key(full_key: &str) -> Self { + let end = full_key.len().min(VISIBLE_PREFIX_LEN); + Self(full_key[..end].to_string()) + } +} + +impl AsRef for ApiKeyPrefix { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ApiKeyPrefix { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}...", self.0) + } +} + +pub fn generate_api_key() -> Result { + let mut bytes = [0u8; RANDOM_BYTES]; + getrandom::fill(&mut bytes)?; + Ok(format!( + "{}{}", + PREFIX, + BASE64_URL_SAFE_NO_PAD.encode(bytes) + )) +} + +/// sha256 is fine here since api keys are high entropy random tokens, +/// not user chosen passwords, so slow hashing is unnecessary. +pub fn hash_token(token: &str) -> String { + use sha2::{Digest, Sha256}; + let hash = Sha256::digest(token.as_bytes()); + hex::encode(hash) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_key_has_prefix() -> Result<(), getrandom::Error> { + let key = generate_api_key()?; + assert!(key.starts_with(PREFIX)); + Ok(()) + } + + #[test] + fn generate_key_has_sufficient_length() -> Result<(), getrandom::Error> { + let key = generate_api_key()?; + // prefix (3) + base64 of 32 bytes (43) = 46 chars + assert!(key.len() >= 40); + Ok(()) + } + + #[test] + fn generated_keys_are_unique() -> Result<(), getrandom::Error> { + let a = generate_api_key()?; + let b = generate_api_key()?; + assert_ne!(a, b); + Ok(()) + } + + #[test] + fn hash_is_deterministic() { + let key = "sk_test_token_123"; + assert_eq!(hash_token(key), hash_token(key)); + } + + #[test] + fn hash_differs_for_different_inputs() { + assert_ne!(hash_token("sk_aaa"), hash_token("sk_bbb")); + } + + #[test] + fn hash_is_hex_encoded_sha256() { + let hash = hash_token("test"); + // 32 bytes = 64 hex chars + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn prefix_from_key() -> Result<(), getrandom::Error> { + let key = generate_api_key()?; + let prefix = ApiKeyPrefix::from_key(&key); + assert_eq!(prefix.as_ref().len(), VISIBLE_PREFIX_LEN); + assert!(key.starts_with(prefix.as_ref())); + Ok(()) + } + + #[test] + fn prefix_display() { + let prefix = ApiKeyPrefix::from_key("sk_abcdefghij_rest"); + assert_eq!(format!("{prefix}"), "sk_abcdefg..."); + } + + #[test] + fn prefix_from_short_key() { + let prefix = ApiKeyPrefix::from_key("sk_ab"); + assert_eq!(prefix.as_ref(), "sk_ab"); + } +} diff --git a/crates/funnel-core/src/lib.rs b/crates/funnel-core/src/lib.rs new file mode 100644 index 0000000..927ad3d --- /dev/null +++ b/crates/funnel-core/src/lib.rs @@ -0,0 +1,4 @@ +pub mod api; +pub mod auth; +pub mod protocol; +pub mod tunnel; diff --git a/crates/funnel-core/src/protocol/frame.rs b/crates/funnel-core/src/protocol/frame.rs new file mode 100644 index 0000000..03be5b6 --- /dev/null +++ b/crates/funnel-core/src/protocol/frame.rs @@ -0,0 +1,122 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +#[derive(Debug, Error)] +pub enum FrameError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("frame too large: {size} bytes (max {max})")] + TooLarge { size: u32, max: u32 }, + + #[error("empty frame")] + Empty, + + #[error("decode error: {0}")] + Decode(#[from] rmp_serde::decode::Error), + + #[error("encode error: {0}")] + Encode(#[from] rmp_serde::encode::Error), +} + +pub async fn write_frame( + writer: &mut (impl AsyncWrite + Unpin), + data: &[u8], +) -> Result<(), FrameError> { + let len = data.len() as u32; + writer.write_all(&len.to_be_bytes()).await?; + writer.write_all(data).await?; + Ok(()) +} + +pub async fn read_frame( + reader: &mut (impl AsyncRead + Unpin), + max_size: u32, +) -> Result, FrameError> { + let mut len_buf = [0u8; 4]; + reader.read_exact(&mut len_buf).await?; + let len = u32::from_be_bytes(len_buf); + + if len == 0 { + return Err(FrameError::Empty); + } + + if len > max_size { + return Err(FrameError::TooLarge { + size: len, + max: max_size, + }); + } + + let mut buf = vec![0u8; len as usize]; + reader.read_exact(&mut buf).await?; + Ok(buf) +} + +pub async fn write_meta( + writer: &mut (impl AsyncWrite + Unpin), + value: &impl Serialize, +) -> Result<(), FrameError> { + let data = rmp_serde::to_vec_named(value)?; + write_frame(writer, &data).await +} + +const MAX_META_SIZE: u32 = 1024 * 1024; + +pub async fn read_meta Deserialize<'de>>( + reader: &mut (impl AsyncRead + Unpin), +) -> Result { + let data = read_frame(reader, MAX_META_SIZE).await?; + Ok(rmp_serde::from_slice(&data)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + type TestResult = Result<(), Box>; + + #[tokio::test] + async fn frame_roundtrip() -> TestResult { + let data = b"hello world"; + let mut buf = Vec::new(); + write_frame(&mut buf, data).await?; + + let mut cursor = std::io::Cursor::new(buf); + let result = read_frame(&mut cursor, 1024).await?; + assert_eq!(result, data); + Ok(()) + } + + #[tokio::test] + async fn frame_too_large() -> TestResult { + let data = vec![0u8; 100]; + let mut buf = Vec::new(); + write_frame(&mut buf, &data).await?; + + let mut cursor = std::io::Cursor::new(buf); + let result = read_frame(&mut cursor, 50).await; + assert!(matches!( + result, + Err(FrameError::TooLarge { size: 100, max: 50 }) + )); + Ok(()) + } + + #[tokio::test] + async fn meta_roundtrip() -> TestResult { + use std::collections::HashMap; + + let original: HashMap = + std::iter::once(("key".into(), "value".into())).collect(); + + let mut buf = Vec::new(); + write_meta(&mut buf, &original).await?; + + let mut cursor = std::io::Cursor::new(buf); + let decoded: HashMap = read_meta(&mut cursor).await?; + assert_eq!(decoded, original); + Ok(()) + } +} diff --git a/crates/funnel-core/src/protocol/handshake.rs b/crates/funnel-core/src/protocol/handshake.rs new file mode 100644 index 0000000..829063e --- /dev/null +++ b/crates/funnel-core/src/protocol/handshake.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +use crate::tunnel::id::TunnelId; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Handshake { + pub tunnel_id: TunnelId, + pub token: Option, + #[serde(default)] + pub team: Option, + #[serde(default)] + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status")] +pub enum HandshakeResponse { + #[serde(rename = "ok")] + Ok, + #[serde(rename = "rejected")] + Rejected { reason: String }, +} + +impl HandshakeResponse { + pub const fn ok() -> Self { + Self::Ok + } + + pub fn rejected(reason: impl Into) -> Self { + Self::Rejected { + reason: reason.into(), + } + } + + pub fn into_result(self) -> Result<(), HandshakeRejected> { + match self { + Self::Ok => Ok(()), + Self::Rejected { reason } => Err(HandshakeRejected { reason }), + } + } +} + +#[derive(Debug, thiserror::Error)] +#[error("handshake rejected: {reason}")] +pub struct HandshakeRejected { + pub reason: String, +} diff --git a/crates/funnel-core/src/protocol/mod.rs b/crates/funnel-core/src/protocol/mod.rs new file mode 100644 index 0000000..f35df78 --- /dev/null +++ b/crates/funnel-core/src/protocol/mod.rs @@ -0,0 +1,10 @@ +pub mod frame; +pub mod handshake; +pub mod request; + +/// unified version number shared across protocol, schema, and API +pub const PROTOCOL_VERSION: u32 = 1; + +/// application level protocol name used for QUIC ALPN negotiation +pub static QUIC_ALPN: std::sync::LazyLock> = + std::sync::LazyLock::new(|| format!("funnel/{PROTOCOL_VERSION}").into_bytes()); diff --git a/crates/funnel-core/src/protocol/request.rs b/crates/funnel-core/src/protocol/request.rs new file mode 100644 index 0000000..4f861ae --- /dev/null +++ b/crates/funnel-core/src/protocol/request.rs @@ -0,0 +1,163 @@ +use std::collections::HashMap; +use std::hash::BuildHasher; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, thiserror::Error)] +pub enum ProtocolTypeError { + #[error("invalid http method: {0}")] + InvalidMethod(String), + + #[error("invalid http status code: {0}")] + InvalidStatus(u16), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestMeta { + pub method: String, + pub path: String, + pub headers: HashMap>, + #[serde(default)] + pub upgrade: bool, +} + +impl RequestMeta { + pub fn http_method(&self) -> Result { + self.method + .parse() + .map_err(|_| ProtocolTypeError::InvalidMethod(self.method.clone())) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseMeta { + pub status: u16, + pub headers: HashMap>, +} + +impl ResponseMeta { + pub fn http_status(&self) -> Result { + http::StatusCode::from_u16(self.status) + .map_err(|_| ProtocolTypeError::InvalidStatus(self.status)) + } +} + +/// convert a protocol header map into an http `HeaderMap`, skipping invalid entries. +pub fn to_header_map(headers: &HashMap, S>) -> http::HeaderMap { + let mut map = http::HeaderMap::new(); + for (name, values) in headers { + for value in values { + if let (Ok(name), Ok(value)) = ( + http::HeaderName::try_from(name.as_str()), + http::HeaderValue::from_str(value), + ) { + map.append(name, value); + } + } + } + map +} + +#[cfg(test)] +mod tests { + use super::*; + + type TestResult = Result<(), Box>; + + #[test] + fn request_meta_roundtrip() -> TestResult { + let meta = RequestMeta { + method: "POST".into(), + path: "/api/data".into(), + headers: { + let mut h = HashMap::new(); + h.insert("content-type".into(), vec!["application/json".into()]); + h + }, + upgrade: false, + }; + + let encoded = rmp_serde::to_vec_named(&meta)?; + let decoded: RequestMeta = rmp_serde::from_slice(&encoded)?; + assert_eq!(decoded.method, "POST"); + assert_eq!(decoded.path, "/api/data"); + Ok(()) + } + + #[test] + fn response_meta_roundtrip() -> TestResult { + let meta = ResponseMeta { + status: 200, + headers: HashMap::new(), + }; + + let encoded = rmp_serde::to_vec_named(&meta)?; + let decoded: ResponseMeta = rmp_serde::from_slice(&encoded)?; + assert_eq!(decoded.status, 200); + Ok(()) + } + + #[test] + fn valid_http_method() { + let meta = RequestMeta { + method: "GET".into(), + path: "/".into(), + headers: HashMap::new(), + upgrade: false, + }; + assert_eq!(meta.http_method().ok(), Some(http::Method::GET)); + } + + #[test] + fn invalid_http_method() { + let meta = RequestMeta { + method: String::new(), + path: "/".into(), + headers: HashMap::new(), + upgrade: false, + }; + assert!(meta.http_method().is_err()); + } + + #[test] + fn valid_http_status() { + let meta = ResponseMeta { + status: 404, + headers: HashMap::new(), + }; + assert_eq!(meta.http_status().ok(), Some(http::StatusCode::NOT_FOUND)); + } + + #[test] + fn invalid_http_status() { + let meta = ResponseMeta { + status: 9999, + headers: HashMap::new(), + }; + assert!(meta.http_status().is_err()); + } + + #[test] + fn to_header_map_converts_valid_entries() { + let mut headers = HashMap::new(); + headers.insert("content-type".into(), vec!["text/html".into()]); + headers.insert("x-custom".into(), vec!["a".into(), "b".into()]); + + let map = to_header_map(&headers); + assert_eq!( + map.get("content-type").and_then(|v| v.to_str().ok()), + Some("text/html") + ); + assert_eq!(map.get_all("x-custom").iter().count(), 2); + } + + #[test] + fn to_header_map_skips_invalid() { + let mut headers = HashMap::new(); + headers.insert("valid".into(), vec!["ok".into()]); + headers.insert(String::new(), vec!["bad name".into()]); + + let map = to_header_map(&headers); + assert_eq!(map.len(), 1); + } +} diff --git a/crates/funnel-core/src/tunnel/id.rs b/crates/funnel-core/src/tunnel/id.rs new file mode 100644 index 0000000..1f293f9 --- /dev/null +++ b/crates/funnel-core/src/tunnel/id.rs @@ -0,0 +1,209 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, thiserror::Error)] +pub enum TunnelIdError { + #[error("tunnel ID cannot be empty")] + Empty, + + #[error("tunnel ID must be at least 3 characters, got {0}")] + TooShort(usize), + + #[error("tunnel ID must be at most 63 characters, got {0}")] + TooLong(usize), + + #[error("tunnel ID must be lowercase")] + NotLowercase, + + #[error( + "tunnel ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen" + )] + InvalidFormat, +} + +const DOMAIN_SAFE_ALPHABET: &[char] = &[ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', + 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', +]; + +const DEFAULT_LENGTH: usize = 8; +const MIN_LENGTH: usize = 3; +const MAX_LENGTH: usize = 63; + +/// a validated tunnel identifier, guaranteed to be a valid dns subdomain label. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct TunnelId(String); + +impl TunnelId { + pub fn new(raw: impl Into) -> Result { + let s = raw.into(); + Self::validate(&s)?; + Ok(Self(s)) + } + + pub fn generate() -> Self { + let id = nanoid::nanoid!(DEFAULT_LENGTH, DOMAIN_SAFE_ALPHABET); + Self(id) + } + + fn validate(s: &str) -> Result<(), TunnelIdError> { + if s.is_empty() { + return Err(TunnelIdError::Empty); + } + if s.len() < MIN_LENGTH { + return Err(TunnelIdError::TooShort(s.len())); + } + if s.len() > MAX_LENGTH { + return Err(TunnelIdError::TooLong(s.len())); + } + if s != s.to_lowercase() { + return Err(TunnelIdError::NotLowercase); + } + + let bytes = s.as_bytes(); + let is_alnum = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit(); + let is_label_char = |b: u8| is_alnum(b) || b == b'-'; + + if !is_alnum(bytes[0]) + || !is_alnum(bytes[bytes.len() - 1]) + || !bytes[1..bytes.len() - 1].iter().all(|&b| is_label_char(b)) + { + return Err(TunnelIdError::InvalidFormat); + } + + Ok(()) + } +} + +impl AsRef for TunnelId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for TunnelId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl From for String { + fn from(id: TunnelId) -> Self { + id.0 + } +} + +impl TryFrom for TunnelId { + type Error = TunnelIdError; + + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_ids() { + assert!(TunnelId::new("abc").is_ok()); + assert!(TunnelId::new("my-tunnel").is_ok()); + assert!(TunnelId::new("test-123").is_ok()); + assert!(TunnelId::new("a1b").is_ok()); + assert!(TunnelId::new("a".repeat(MAX_LENGTH)).is_ok()); + } + + #[test] + fn empty_id() { + assert!(matches!(TunnelId::new(""), Err(TunnelIdError::Empty))); + } + + #[test] + fn too_short() { + assert!(matches!( + TunnelId::new("ab"), + Err(TunnelIdError::TooShort(2)) + )); + assert!(matches!( + TunnelId::new("a"), + Err(TunnelIdError::TooShort(1)) + )); + } + + #[test] + fn too_long() { + let long = "a".repeat(MAX_LENGTH + 1); + assert!(matches!( + TunnelId::new(long), + Err(TunnelIdError::TooLong(64)) + )); + } + + #[test] + fn uppercase_rejected() { + assert!(matches!( + TunnelId::new("MyTunnel"), + Err(TunnelIdError::NotLowercase) + )); + } + + #[test] + fn invalid_format() { + assert!(matches!( + TunnelId::new("-abc"), + Err(TunnelIdError::InvalidFormat) + )); + assert!(matches!( + TunnelId::new("abc-"), + Err(TunnelIdError::InvalidFormat) + )); + assert!(matches!( + TunnelId::new("ab_c"), + Err(TunnelIdError::InvalidFormat) + )); + assert!(matches!( + TunnelId::new("ab c"), + Err(TunnelIdError::InvalidFormat) + )); + } + + #[test] + fn generate_produces_valid_id() { + for _ in 0..100 { + let id = TunnelId::generate(); + assert_eq!(id.as_ref().len(), DEFAULT_LENGTH); + assert!(TunnelId::new(id.as_ref()).is_ok()); + } + } + + type TestResult = Result<(), Box>; + + #[test] + fn display_and_into_string() -> TestResult { + let id = TunnelId::new("my-tunnel")?; + assert_eq!(id.to_string(), "my-tunnel"); + let s: String = id.into(); + assert_eq!(s, "my-tunnel"); + Ok(()) + } + + #[test] + fn serde_roundtrip() -> TestResult { + let id = TunnelId::new("test-123")?; + let json = serde_json::to_string(&id)?; + assert_eq!(json, "\"test-123\""); + + let parsed: TunnelId = serde_json::from_str(&json)?; + assert_eq!(parsed.as_ref(), "test-123"); + Ok(()) + } + + #[test] + fn serde_rejects_invalid() { + let result: Result = serde_json::from_str("\"AB\""); + assert!(result.is_err()); + } +} diff --git a/crates/funnel-core/src/tunnel/mod.rs b/crates/funnel-core/src/tunnel/mod.rs new file mode 100644 index 0000000..fd6bb6c --- /dev/null +++ b/crates/funnel-core/src/tunnel/mod.rs @@ -0,0 +1 @@ +pub mod id; diff --git a/crates/funnel-e2e/Cargo.toml b/crates/funnel-e2e/Cargo.toml new file mode 100644 index 0000000..cc7ba78 --- /dev/null +++ b/crates/funnel-e2e/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "funnel-e2e" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[dev-dependencies] +tokio.workspace = true +tokio-tungstenite = "0.26" +reqwest = { workspace = true, features = ["rustls", "json"] } +serde_json.workspace = true +axum = { workspace = true, features = ["ws"] } +futures-util = "0.3" diff --git a/crates/funnel-e2e/funnel.db b/crates/funnel-e2e/funnel.db new file mode 100644 index 0000000..84967ee Binary files /dev/null and b/crates/funnel-e2e/funnel.db differ diff --git a/crates/funnel-e2e/funnel.db-wal b/crates/funnel-e2e/funnel.db-wal new file mode 100644 index 0000000..cc86ec9 Binary files /dev/null and b/crates/funnel-e2e/funnel.db-wal differ diff --git a/crates/funnel-e2e/src/lib.rs b/crates/funnel-e2e/src/lib.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/funnel-e2e/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/funnel-e2e/tests/e2e/auth.rs b/crates/funnel-e2e/tests/e2e/auth.rs new file mode 100644 index 0000000..2ad64df --- /dev/null +++ b/crates/funnel-e2e/tests/e2e/auth.rs @@ -0,0 +1,305 @@ +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; + +use crate::harness::{binary_path, free_port, log_file, wait_for_tcp}; + +type TestResult = Result<(), Box>; + +struct AuthTestEnv { + server_process: Child, + server_log: PathBuf, + turso_db_path: PathBuf, + http_port: u16, + seed_key: String, + client: reqwest::Client, +} + +impl AuthTestEnv { + async fn start() -> Result> { + let http_port = free_port()?; + let quic_port = free_port()?; + let turso_db_path = std::env::temp_dir().join(format!( + "funnel-auth-e2e-{}-{}.db", + std::process::id(), + http_port, + )); + + let (stderr_file, log_path) = log_file("auth-server")?; + + let mut child = Command::new(binary_path("funnel-server")?) + .args([ + "--port", + &http_port.to_string(), + "--quic-port", + &quic_port.to_string(), + "--host", + "127.0.0.1", + "--turso-db-path", + &turso_db_path.to_string_lossy(), + "--seed-api-key", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::from(stderr_file)) + .spawn()?; + + let stdout = child.stdout.take().ok_or("stdout not piped")?; + let mut reader = BufReader::new(stdout); + let mut seed_key = String::new(); + reader.read_line(&mut seed_key)?; + let seed_key = seed_key.trim().to_string(); + + wait_for_tcp(http_port).await; + + Ok(Self { + server_process: child, + server_log: log_path, + turso_db_path, + http_port, + seed_key, + client: reqwest::Client::new(), + }) + } + + fn url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{}", self.http_port, path) + } + + fn auth_header(&self) -> String { + format!("Bearer {}", self.seed_key) + } + + fn dump_logs(&self) { + if let Ok(content) = std::fs::read_to_string(&self.server_log) + && !content.is_empty() + { + eprintln!("\n--- auth server stderr ---\n{content}\n--- end ---\n"); + } + } +} + +impl Drop for AuthTestEnv { + fn drop(&mut self) { + let _ = self.server_process.kill(); + let _ = self.server_process.wait(); + + if std::thread::panicking() { + self.dump_logs(); + } + + let _ = std::fs::remove_file(&self.server_log); + let _ = std::fs::remove_file(&self.turso_db_path); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn keys_require_auth() -> TestResult { + let env = AuthTestEnv::start().await?; + + let get = env.client.get(env.url("/api/v1/keys")).send().await?; + assert_eq!(get.status(), 401); + + let post = env + .client + .post(env.url("/api/v1/keys")) + .json(&serde_json::json!({"name": "test"})) + .send() + .await?; + assert_eq!(post.status(), 401); + + let delete = env + .client + .delete(env.url("/api/v1/keys/00000000-0000-0000-0000-000000000000")) + .send() + .await?; + assert_eq!(delete.status(), 401); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_bearer_token_rejected() -> TestResult { + let env = AuthTestEnv::start().await?; + + let resp = env + .client + .get(env.url("/api/v1/keys")) + .header("authorization", "Bearer fnl_bogustoken1234567890") + .send() + .await?; + assert_eq!(resp.status(), 401); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn create_and_list_keys() -> TestResult { + let env = AuthTestEnv::start().await?; + + let resp = env + .client + .post(env.url("/api/v1/keys")) + .header("authorization", env.auth_header()) + .json(&serde_json::json!({"name": "my-key"})) + .send() + .await?; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await?; + assert!(body["key"].as_str().is_some()); + assert_eq!(body["info"]["name"], "my-key"); + + // seed key + new key + let resp = env + .client + .get(env.url("/api/v1/keys")) + .header("authorization", env.auth_header()) + .send() + .await?; + assert_eq!(resp.status(), 200); + let keys: Vec = resp.json().await?; + assert_eq!(keys.len(), 2); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn revoke_key() -> TestResult { + let env = AuthTestEnv::start().await?; + + let resp = env + .client + .post(env.url("/api/v1/keys")) + .header("authorization", env.auth_header()) + .json(&serde_json::json!({"name": "to-revoke"})) + .send() + .await?; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await?; + let key_id = body["info"]["id"].as_str().ok_or("missing key id")?; + + let resp = env + .client + .delete(env.url(&format!("/api/v1/keys/{key_id}"))) + .header("authorization", env.auth_header()) + .send() + .await?; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await?; + assert_eq!(body["revoked"], true); + + // only seed key remains + let resp = env + .client + .get(env.url("/api/v1/keys")) + .header("authorization", env.auth_header()) + .send() + .await?; + let keys: Vec = resp.json().await?; + assert_eq!(keys.len(), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn revoked_key_cannot_authenticate() -> TestResult { + let env = AuthTestEnv::start().await?; + + // create a second key + let resp = env + .client + .post(env.url("/api/v1/keys")) + .header("authorization", env.auth_header()) + .json(&serde_json::json!({"name": "ephemeral"})) + .send() + .await?; + let body: serde_json::Value = resp.json().await?; + let new_key = body["key"].as_str().ok_or("missing key")?.to_string(); + let new_key_id = body["info"]["id"].as_str().ok_or("missing key id")?; + + // verify it works + let resp = env + .client + .get(env.url("/api/v1/keys")) + .header("authorization", format!("Bearer {new_key}")) + .send() + .await?; + assert_eq!(resp.status(), 200); + + // revoke it + env.client + .delete(env.url(&format!("/api/v1/keys/{new_key_id}"))) + .header("authorization", env.auth_header()) + .send() + .await?; + + // revoked key should be rejected + let resp = env + .client + .get(env.url("/api/v1/keys")) + .header("authorization", format!("Bearer {new_key}")) + .send() + .await?; + assert_eq!(resp.status(), 401); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn me_requires_auth() -> TestResult { + let env = AuthTestEnv::start().await?; + + let resp = env.client.get(env.url("/api/v1/me")).send().await?; + assert_eq!(resp.status(), 401); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn me_returns_seed_user() -> TestResult { + let env = AuthTestEnv::start().await?; + + // seed key creates a proper system user + let resp = env + .client + .get(env.url("/api/v1/me")) + .header("authorization", env.auth_header()) + .send() + .await?; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await?; + assert_eq!(body["email"], "system@funnel.local"); + assert_eq!(body["role"], "admin"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn oauth_authorize_returns_404_when_not_configured() -> TestResult { + let env = AuthTestEnv::start().await?; + + let resp = env + .client + .get(env.url("/auth/v1/github/authorize?cli_port=9999")) + .send() + .await?; + assert_eq!(resp.status(), 404); + + let body = resp.text().await?; + assert!(body.contains("oauth not configured")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn oauth_callback_returns_404_when_not_configured() -> TestResult { + let env = AuthTestEnv::start().await?; + + let resp = env + .client + .get(env.url("/auth/v1/github/callback?code=test&state=test")) + .send() + .await?; + assert_eq!(resp.status(), 404); + + Ok(()) +} diff --git a/crates/funnel-e2e/tests/e2e/harness.rs b/crates/funnel-e2e/tests/e2e/harness.rs new file mode 100644 index 0000000..9705a71 --- /dev/null +++ b/crates/funnel-e2e/tests/e2e/harness.rs @@ -0,0 +1,294 @@ +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use axum::Router; +use axum::extract::DefaultBodyLimit; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use futures_util::StreamExt; +use tokio::task::JoinHandle; + +const TUNNEL_ID: &str = "e2etest"; +const READY_TIMEOUT: Duration = Duration::from_secs(30); +const POLL_INTERVAL: Duration = Duration::from_millis(100); +const MOCK_BODY_LIMIT: usize = 4 * 1024 * 1024; + +pub struct TestEnv { + server_process: Child, + client_process: Child, + local_server_handle: JoinHandle<()>, + server_log: PathBuf, + client_log: PathBuf, + turso_db_path: PathBuf, + pub http_port: u16, + pub host_header: String, + pub client: reqwest::Client, + #[allow(dead_code)] + pub seed_key: String, +} + +impl TestEnv { + pub async fn start() -> Result> { + let (local_handle, local_port) = start_mock_server()?; + let http_port = free_port()?; + let quic_port = free_port()?; + let host_header = format!("{TUNNEL_ID}.localhost:{http_port}"); + let turso_db_path = std::env::temp_dir().join(format!( + "funnel-e2e-{}-{}.db", + std::process::id(), + http_port, + )); + + let (server_process, server_log, seed_key) = + start_server_process(http_port, quic_port, &turso_db_path)?; + wait_for_tcp(http_port).await; + + let (client_process, client_log) = + start_client_process(local_port, http_port, TUNNEL_ID, &seed_key)?; + + let client = reqwest::Client::new(); + wait_for_tunnel(&client, http_port, &host_header).await; + + Ok(Self { + server_process, + client_process, + local_server_handle: local_handle, + server_log, + client_log, + turso_db_path, + http_port, + host_header, + client, + seed_key, + }) + } + + pub fn tunnel_request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { + let url = format!("http://127.0.0.1:{}{}", self.http_port, path); + self.client + .request(method, &url) + .header("host", &self.host_header) + } + + pub fn server_url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{}", self.http_port, path) + } + + fn dump_logs(&self) { + if let Ok(content) = std::fs::read_to_string(&self.server_log) + && !content.is_empty() + { + eprintln!("\n--- funnel-server stderr ---"); + eprintln!("{content}"); + eprintln!("--- end funnel-server stderr ---\n"); + } + if let Ok(content) = std::fs::read_to_string(&self.client_log) + && !content.is_empty() + { + eprintln!("\n--- funnel-client stderr ---"); + eprintln!("{content}"); + eprintln!("--- end funnel-client stderr ---\n"); + } + } +} + +impl Drop for TestEnv { + fn drop(&mut self) { + let _ = self.server_process.kill(); + let _ = self.client_process.kill(); + self.local_server_handle.abort(); + let _ = self.server_process.wait(); + let _ = self.client_process.wait(); + + if std::thread::panicking() { + self.dump_logs(); + } + + let _ = std::fs::remove_file(&self.server_log); + let _ = std::fs::remove_file(&self.client_log); + let _ = std::fs::remove_file(&self.turso_db_path); + } +} + +pub fn free_port() -> Result { + Ok(TcpListener::bind("127.0.0.1:0")?.local_addr()?.port()) +} + +pub fn binary_path(name: &str) -> Result { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .and_then(|p| p.parent()) + .ok_or("could not resolve workspace root from CARGO_MANIFEST_DIR")?; + let path = workspace_root.join("target").join("debug").join(name); + if !path.exists() { + return Err(format!( + "binary not found at {}: run `cargo build -p funnel-server -p funnel-client` first", + path.display() + )); + } + Ok(path) +} + +pub fn log_file(name: &str) -> Result<(File, PathBuf), std::io::Error> { + let path = std::env::temp_dir().join(format!("funnel-{name}-{}.log", std::process::id())); + let file = File::create(&path)?; + Ok((file, path)) +} + +fn start_mock_server() -> Result<(JoinHandle<()>, u16), std::io::Error> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + listener.set_nonblocking(true)?; + + let tokio_listener = tokio::net::TcpListener::from_std(listener)?; + + let handle = tokio::spawn(async move { + let app = mock_app(); + if let Err(e) = axum::serve(tokio_listener, app).await { + eprintln!("mock server error: {e}"); + } + }); + + Ok((handle, port)) +} + +fn mock_app() -> Router { + Router::new() + .route("/hello", get(|| async { "hello from local service" })) + .route( + "/echo", + post(|body: String| async move { format!("echo: {body}") }), + ) + .route( + "/headers", + get(|headers: axum::http::HeaderMap| async move { + let map: std::collections::HashMap = headers + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .collect(); + ([("x-custom-header", "test-value")], axum::Json(map)) + }), + ) + .route( + "/large", + post(|body: axum::body::Bytes| async move { body }), + ) + .route("/ws-echo", get(ws_echo_handler)) + .layer(DefaultBodyLimit::max(MOCK_BODY_LIMIT)) +} + +async fn ws_echo_handler(ws: WebSocketUpgrade) -> impl IntoResponse { + ws.on_upgrade(ws_echo) +} + +async fn ws_echo(mut socket: WebSocket) { + while let Some(Ok(msg)) = socket.next().await { + let should_echo = matches!(msg, Message::Text(_) | Message::Binary(_)); + if !should_echo { + if matches!(msg, Message::Close(_)) { + break; + } + continue; + } + if socket.send(msg).await.is_err() { + break; + } + } +} + +fn start_server_process( + http_port: u16, + quic_port: u16, + turso_db_path: &Path, +) -> Result<(Child, PathBuf, String), Box> { + let (stderr_file, log_path) = log_file("server")?; + + let mut child = Command::new(binary_path("funnel-server")?) + .args([ + "--port", + &http_port.to_string(), + "--quic-port", + &quic_port.to_string(), + "--host", + "127.0.0.1", + "--turso-db-path", + &turso_db_path.to_string_lossy(), + "--seed-api-key", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::from(stderr_file)) + .spawn()?; + + let stdout = child.stdout.take().ok_or("stdout not piped")?; + let mut reader = BufReader::new(stdout); + let mut seed_key = String::new(); + reader.read_line(&mut seed_key)?; + let seed_key = seed_key.trim().to_string(); + + Ok((child, log_path, seed_key)) +} + +fn start_client_process( + local_port: u16, + server_http_port: u16, + tunnel_id: &str, + token: &str, +) -> Result<(Child, PathBuf), Box> { + let (stderr_file, log_path) = log_file("client")?; + + let child = Command::new(binary_path("funnel-client")?) + .args([ + "http", + &format!("127.0.0.1:{local_port}"), + "--server", + &format!("http://127.0.0.1:{server_http_port}"), + "--id", + tunnel_id, + "--insecure", + "--token", + token, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::from(stderr_file)) + .spawn()?; + + Ok((child, log_path)) +} + +pub async fn wait_for_tcp(port: u16) { + let deadline = tokio::time::Instant::now() + READY_TIMEOUT; + loop { + assert!( + tokio::time::Instant::now() <= deadline, + "tcp port {port} did not become ready" + ); + if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")) + .await + .is_ok() + { + return; + } + tokio::time::sleep(POLL_INTERVAL).await; + } +} + +async fn wait_for_tunnel(client: &reqwest::Client, http_port: u16, host_header: &str) { + let url = format!("http://127.0.0.1:{http_port}/hello"); + let deadline = tokio::time::Instant::now() + READY_TIMEOUT; + loop { + assert!( + tokio::time::Instant::now() <= deadline, + "tunnel did not become ready" + ); + match client.get(&url).header("host", host_header).send().await { + Ok(resp) if resp.status().is_success() => return, + _ => tokio::time::sleep(POLL_INTERVAL).await, + } + } +} diff --git a/crates/funnel-e2e/tests/e2e/main.rs b/crates/funnel-e2e/tests/e2e/main.rs new file mode 100644 index 0000000..bff3ca7 --- /dev/null +++ b/crates/funnel-e2e/tests/e2e/main.rs @@ -0,0 +1,194 @@ +mod auth; +mod harness; + +use futures_util::{SinkExt, StreamExt}; +use harness::TestEnv; +use reqwest::Method; +use tokio_tungstenite::tungstenite; + +type TestResult = Result<(), Box>; + +#[tokio::test(flavor = "multi_thread")] +async fn basic_get() -> TestResult { + let env = TestEnv::start().await?; + + let resp = env.tunnel_request(Method::GET, "/hello").send().await?; + + assert_eq!(resp.status(), 200); + assert_eq!(resp.text().await?, "hello from local service"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn post_echo() -> TestResult { + let env = TestEnv::start().await?; + + let resp = env + .tunnel_request(Method::POST, "/echo") + .body("test body") + .send() + .await?; + + assert_eq!(resp.status(), 200); + assert_eq!(resp.text().await?, "echo: test body"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn large_body() -> TestResult { + let env = TestEnv::start().await?; + let size = 2_500_000; + let payload = vec![b'x'; size]; + + let resp = env + .tunnel_request(Method::POST, "/large") + .body(payload) + .send() + .await?; + + assert_eq!(resp.status(), 200); + let body = resp.bytes().await?; + assert_eq!(body.len(), size); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_requests() -> TestResult { + let env = TestEnv::start().await?; + let mut set = tokio::task::JoinSet::new(); + + for _ in 0..10 { + let client = env.client.clone(); + let url = format!("http://127.0.0.1:{}/hello", env.http_port); + let host = env.host_header.clone(); + + set.spawn(async move { client.get(&url).header("host", &host).send().await }); + } + + let mut ok_count = 0; + while let Some(result) = set.join_next().await { + let resp = result??; + assert_eq!(resp.status(), 200); + ok_count += 1; + } + assert_eq!(ok_count, 10); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn response_headers() -> TestResult { + let env = TestEnv::start().await?; + + let resp = env.tunnel_request(Method::GET, "/headers").send().await?; + + assert_eq!(resp.status(), 200); + let custom = resp.headers().get("x-custom-header"); + assert_eq!(custom.and_then(|v| v.to_str().ok()), Some("test-value")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn metrics_endpoint() -> TestResult { + let env = TestEnv::start().await?; + + let resp = env + .client + .get(env.server_url("/api/v1/metrics")) + .send() + .await?; + + assert_eq!(resp.status(), 200); + let body = resp.text().await?; + assert!(body.contains("funnel_requests_total")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn unknown_tunnel_returns_404() -> TestResult { + let env = TestEnv::start().await?; + let url = format!("http://127.0.0.1:{}/hello", env.http_port); + + let resp = env + .client + .get(&url) + .header("host", format!("nonexistent.localhost:{}", env.http_port)) + .send() + .await?; + + assert_eq!(resp.status(), 404); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn websocket_echo() -> TestResult { + let env = TestEnv::start().await?; + + let request = ws_request(&env, "/ws-echo")?; + let (mut ws, _resp) = tokio_tungstenite::connect_async(request).await?; + + ws.send(tungstenite::Message::Text("hello websocket".into())) + .await?; + + let msg = ws.next().await.ok_or("no message received")??; + + assert_eq!(msg.into_text()?, "hello websocket"); + + ws.close(None).await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn websocket_multiple_messages() -> TestResult { + let env = TestEnv::start().await?; + + let request = ws_request(&env, "/ws-echo")?; + let (mut ws, _) = tokio_tungstenite::connect_async(request).await?; + + for i in 0..5 { + let msg = format!("msg {i}"); + ws.send(tungstenite::Message::Text(msg.clone().into())) + .await?; + + let resp = ws.next().await.ok_or("no message received")??; + assert_eq!(resp.into_text()?, msg); + } + + ws.close(None).await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn websocket_binary_data() -> TestResult { + let env = TestEnv::start().await?; + + let request = ws_request(&env, "/ws-echo")?; + let (mut ws, _) = tokio_tungstenite::connect_async(request).await?; + + let payload = vec![0u8, 1, 2, 255, 254, 253]; + ws.send(tungstenite::Message::Binary(payload.clone().into())) + .await?; + + let resp = ws.next().await.ok_or("no message received")??; + assert_eq!(resp.into_data().to_vec(), payload); + + ws.close(None).await?; + Ok(()) +} + +fn ws_request( + env: &TestEnv, + path: &str, +) -> Result, tungstenite::http::Error> { + let url = format!("ws://127.0.0.1:{}{path}", env.http_port); + tungstenite::http::Request::builder() + .uri(&url) + .header("host", &env.host_header) + .header("connection", "Upgrade") + .header("upgrade", "websocket") + .header("sec-websocket-version", "13") + .header( + "sec-websocket-key", + tungstenite::handshake::client::generate_key(), + ) + .body(()) +} diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml new file mode 100644 index 0000000..88bf450 --- /dev/null +++ b/crates/funnel-server/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "funnel-server" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +funnel-core = { workspace = true, features = ["openapi"] } + +anyhow.workspace = true +axum.workspace = true +chrono.workspace = true +clap.workspace = true +dashmap = "6" +getrandom = "0.3" +hex = "0.4" +http-body-util = "0.1" +hyper = { version = "1", features = ["server", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +ipnetwork.workspace = true +metrics = "0.24" +metrics-exporter-prometheus = "0.16" +quinn.workspace = true +rcgen = "0.13" +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true +turso.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber = { workspace = true, features = ["json"] } +urlencoding = "2" +utoipa.workspace = true +scalar_api_reference.workspace = true +uuid.workspace = true + +# tls +aws-config = "1" +aws-sdk-route53 = "1" +axum-server = { version = "0.8", features = ["tls-rustls"] } +instant-acme = { version = "0.8", features = ["rcgen"] } +lru = "0.16" +reqwest = { workspace = true, features = ["rustls", "json", "query", "form"] } +rustls = { workspace = true, features = ["ring", "logging", "std", "tls12"] } +rustls-pki-types = "1" +x509-parser = "0.18" + +[dev-dependencies] +axum-test = "20" +pretty_assertions.workspace = true +sqlx.workspace = true diff --git a/crates/funnel-server/build.rs b/crates/funnel-server/build.rs new file mode 100644 index 0000000..30a591a --- /dev/null +++ b/crates/funnel-server/build.rs @@ -0,0 +1,30 @@ +fn main() { + let hash = run("git", &["rev-parse", "--short", "HEAD"]) + .or_else(|| std::env::var("FUNNEL_GIT_HASH").ok()) + .unwrap_or_else(|| "unknown".to_string()); + + println!("cargo:rustc-env=FUNNEL_GIT_HASH={hash}"); + + // only rerun when git ref changes (new commit, branch switch) or + // when FUNNEL_GIT_HASH is set externally (nix, ci) + if let Some(git_dir) = run("git", &["rev-parse", "--git-dir"]) { + let head_path = format!("{git_dir}/HEAD"); + println!("cargo:rerun-if-changed={head_path}"); + if let Ok(head_ref) = std::fs::read_to_string(&head_path) + && let Some(ref_path) = head_ref.strip_prefix("ref: ") + { + println!("cargo:rerun-if-changed={git_dir}/{}", ref_path.trim()); + } + } + println!("cargo:rerun-if-env-changed=FUNNEL_GIT_HASH"); +} + +fn run(cmd: &str, args: &[&str]) -> Option { + std::process::Command::new(cmd) + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) +} diff --git a/crates/funnel-server/src/api/accounts.rs b/crates/funnel-server/src/api/accounts.rs new file mode 100644 index 0000000..f258a49 --- /dev/null +++ b/crates/funnel-server/src/api/accounts.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; +use serde::Serialize; +use uuid::Uuid; + +use crate::app::AppState; +use crate::auth::{Management, Scoped}; +use crate::error::{ApiErrorBody, AppError}; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct AccountView { + pub id: Uuid, + pub provider: String, + pub created_at: chrono::DateTime, +} + +#[utoipa::path( + get, + path = "/accounts", + operation_id = "list_accounts", + tag = "Profile", + security(("bearer" = [])), + responses( + (status = 200, description = "Linked OAuth accounts", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + ) +)] +pub async fn list( + State(state): State>, + auth: Scoped, +) -> Result>, AppError> { + let accounts = state.accounts.list_for_user(auth.user_id).await?; + + let views: Vec = accounts + .into_iter() + .map(|a| AccountView { + id: a.id, + provider: a.provider, + created_at: a.created_at, + }) + .collect(); + + Ok(Json(views)) +} diff --git a/crates/funnel-server/src/api/health.rs b/crates/funnel-server/src/api/health.rs new file mode 100644 index 0000000..33e2d88 --- /dev/null +++ b/crates/funnel-server/src/api/health.rs @@ -0,0 +1,29 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; +use serde::Serialize; + +use crate::app::AppState; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct HealthResponse { + pub status: &'static str, + pub uptime_secs: u64, +} + +#[utoipa::path( + get, + path = "/health", + operation_id = "get_health", + tag = "Server", + responses( + (status = 200, description = "Server is healthy", body = HealthResponse), + ) +)] +pub async fn handler(State(state): State>) -> Json { + Json(HealthResponse { + status: state.health.status(), + uptime_secs: state.health.uptime_secs(), + }) +} diff --git a/crates/funnel-server/src/api/info.rs b/crates/funnel-server/src/api/info.rs new file mode 100644 index 0000000..eefecc4 --- /dev/null +++ b/crates/funnel-server/src/api/info.rs @@ -0,0 +1,25 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; + +use funnel_core::api::ServerInfo; +use funnel_core::protocol::PROTOCOL_VERSION; + +use crate::app::AppState; + +#[utoipa::path( + get, + path = "/info", + operation_id = "get_server_info", + tag = "Server", + responses( + (status = 200, description = "Server version and QUIC port", body = ServerInfo), + ) +)] +pub async fn handler(State(state): State>) -> Json { + Json(ServerInfo { + version: PROTOCOL_VERSION, + quic_port: state.quic_port, + }) +} diff --git a/crates/funnel-server/src/api/keys.rs b/crates/funnel-server/src/api/keys.rs new file mode 100644 index 0000000..3f1a03c --- /dev/null +++ b/crates/funnel-server/src/api/keys.rs @@ -0,0 +1,102 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, State}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::app::AppState; +use crate::auth::{Management, Scoped}; +use crate::db::api_keys::{ApiKeyView, default_scopes}; +use crate::error::{ApiErrorBody, AppError}; + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreateKeyRequest { + pub name: String, + pub scopes: Option>, + pub expires_at: Option>, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct CreateKeyResponse { + pub key: String, + pub info: ApiKeyView, +} + +#[utoipa::path( + get, + path = "/keys", + operation_id = "list_keys", + tag = "API Keys", + security(("bearer" = [])), + responses( + (status = 200, description = "List of API keys for the current user", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + ) +)] +pub async fn list( + State(state): State>, + auth: Scoped, +) -> Result>, AppError> { + let keys = state.api_keys.list_for_user(auth.user_id).await?; + Ok(Json(keys)) +} + +#[utoipa::path( + post, + path = "/keys", + operation_id = "create_key", + tag = "API Keys", + security(("bearer" = [])), + request_body = CreateKeyRequest, + responses( + (status = 200, description = "API key created", body = CreateKeyResponse), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + ) +)] +pub async fn create( + State(state): State>, + auth: Scoped, + Json(req): Json, +) -> Result, AppError> { + let scopes = req.scopes.map_or_else(default_scopes, |s| { + serde_json::Value::Array(s.into_iter().map(serde_json::Value::String).collect()) + }); + + let (plaintext, info) = state + .api_keys + .create(auth.user_id, &req.name, &scopes, req.expires_at) + .await?; + + Ok(Json(CreateKeyResponse { + key: plaintext, + info, + })) +} + +#[utoipa::path( + delete, + path = "/keys/{id}", + operation_id = "revoke_key", + tag = "API Keys", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "API key ID")), + responses( + (status = 200, description = "API key revoked", body = Object), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 404, description = "Key not found", body = ApiErrorBody), + ) +)] +pub async fn revoke( + State(state): State>, + auth: Scoped, + Path(id): Path, +) -> Result, AppError> { + let revoked = state.api_keys.revoke(id, auth.user_id).await?; + if !revoked { + return Err(AppError::NotFound("api key not found".into())); + } + + Ok(Json(serde_json::json!({ "revoked": true }))) +} diff --git a/crates/funnel-server/src/api/me.rs b/crates/funnel-server/src/api/me.rs new file mode 100644 index 0000000..a784c7e --- /dev/null +++ b/crates/funnel-server/src/api/me.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; + +use crate::app::AppState; +use crate::auth::{Management, Scoped}; +use crate::db::users::User; +use crate::error::{ApiErrorBody, AppError}; + +#[utoipa::path( + get, + path = "/me", + operation_id = "get_current_user", + tag = "Profile", + security(("bearer" = [])), + responses( + (status = 200, description = "Current user profile", body = User), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 404, description = "User not found", body = ApiErrorBody), + ) +)] +pub async fn handler( + State(state): State>, + auth: Scoped, +) -> Result, AppError> { + state + .users + .find_by_id(auth.user_id) + .await? + .ok_or(AppError::NotFound("user not found".into())) + .map(Json) +} diff --git a/crates/funnel-server/src/api/mod.rs b/crates/funnel-server/src/api/mod.rs new file mode 100644 index 0000000..489144e --- /dev/null +++ b/crates/funnel-server/src/api/mod.rs @@ -0,0 +1,10 @@ +pub mod accounts; +pub mod health; +pub mod info; +pub mod keys; +pub mod me; +pub mod oauth; +pub mod sessions; +pub mod teams; +pub mod tunnels; +pub mod users; diff --git a/crates/funnel-server/src/api/oauth.rs b/crates/funnel-server/src/api/oauth.rs new file mode 100644 index 0000000..5ef5822 --- /dev/null +++ b/crates/funnel-server/src/api/oauth.rs @@ -0,0 +1,586 @@ +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use serde::Deserialize; + +use crate::app::AppState; +use crate::db::accounts::NewAccount; +use crate::db::api_keys::default_scopes; +use crate::db::users::NewUser; +use funnel_core::protocol::PROTOCOL_VERSION; + +#[derive(Deserialize)] +pub struct AuthorizeParams { + cli_port: u16, +} + +#[derive(Deserialize)] +pub struct CallbackParams { + code: String, + state: String, +} + +fn html_error(status: StatusCode, message: &str) -> Response { + ( + status, + Html(format!( + r"

Error

{message}

", + )), + ) + .into_response() +} + +pub async fn authorize( + State(state): State>, + Path(provider): Path, + Query(params): Query, +) -> Response { + let Some(oauth) = &state.oauth_state else { + return html_error(StatusCode::NOT_FOUND, "oauth not configured"); + }; + + let Some(p) = oauth.providers.get(&provider) else { + return html_error( + StatusCode::NOT_FOUND, + &format!("unknown provider: {provider}"), + ); + }; + + let token = match crate::auth::oauth::OAuthState::generate_state_token() { + Ok(t) => t, + Err(e) => { + tracing::error!(error = %e, "failed to generate state token"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + }; + + oauth.insert_pending(token.clone(), params.cli_port); + + let redirect_uri = format!( + "{}/auth/v{PROTOCOL_VERSION}/{}/callback", + oauth.base_url, provider + ); + let url = p.authorize_url(&redirect_uri, &token); + + Redirect::temporary(&url).into_response() +} + +pub async fn callback( + State(state): State>, + Path(provider): Path, + Query(params): Query, +) -> Response { + let Some(oauth) = &state.oauth_state else { + return html_error(StatusCode::NOT_FOUND, "oauth not configured"); + }; + + let Some(p) = oauth.providers.get(&provider) else { + return html_error( + StatusCode::NOT_FOUND, + &format!("unknown provider: {provider}"), + ); + }; + + let Some(pending) = oauth.take_pending(¶ms.state) else { + return html_error(StatusCode::BAD_REQUEST, "invalid or expired state"); + }; + + let redirect_uri = format!( + "{}/auth/v{PROTOCOL_VERSION}/{}/callback", + oauth.base_url, provider + ); + + let access_token = match p.exchange_code(¶ms.code, &redirect_uri).await { + Ok(t) => t, + Err(e) => { + tracing::error!(error = %e, "oauth code exchange failed"); + return html_error( + StatusCode::BAD_GATEWAY, + "failed to exchange authorization code", + ); + } + }; + + let info = match p.fetch_user_info(&access_token).await { + Ok(i) => i, + Err(e) => { + tracing::error!(error = %e, "oauth user info fetch failed"); + return html_error(StatusCode::BAD_GATEWAY, "failed to fetch user info"); + } + }; + + // look up existing account by (provider, provider_account_id) + let existing_account = match state + .accounts + .find_by_provider(&info.provider, &info.provider_id) + .await + { + Ok(a) => a, + Err(e) => { + tracing::error!(error = %e, "failed to look up account"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + }; + + let user = if let Some(account) = existing_account { + // account exists, update linked user profile + match state + .users + .update_profile( + account.user_id, + info.name.as_deref(), + info.avatar_url.as_deref(), + ) + .await + { + Ok(u) => u, + Err(e) => { + tracing::error!(error = %e, "failed to update user profile"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + } + } else { + // no account for this provider, check if user exists by email + let existing_user = match state.users.find_by_email(&info.email).await { + Ok(u) => u, + Err(e) => { + tracing::error!(error = %e, "failed to look up user by email"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + }; + + let user = if let Some(user) = existing_user { + // user exists from a different provider, link this account + match state + .users + .update_profile(user.id, info.name.as_deref(), info.avatar_url.as_deref()) + .await + { + Ok(u) => u, + Err(e) => { + tracing::error!(error = %e, "failed to update user profile"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + } + } else { + // completely new user + match state + .users + .create(NewUser { + email: info.email, + name: info.name, + avatar_url: info.avatar_url, + }) + .await + { + Ok(u) => u, + Err(e) => { + tracing::error!(error = %e, "failed to create user"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + } + }; + + if let Err(e) = state + .accounts + .create(NewAccount { + user_id: user.id, + provider: info.provider, + provider_account_id: info.provider_id, + metadata: serde_json::json!({}), + }) + .await + { + tracing::error!(error = %e, "failed to create account link"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + + // auto promote first user to admin, or match initial admin email + let should_promote = match state.users.count().await { + Ok(1) => true, + Ok(_) => { + state + .initial_admin_email + .as_ref() + .is_some_and(|email| email == &user.email) + && !user.is_admin() + } + Err(_) => false, + }; + + if should_promote { + match state.users.update_role(user.id, "admin").await { + Ok(promoted) => { + tracing::info!( + user_id = %promoted.id, + email = %promoted.email, + "auto promoted user to admin" + ); + promoted + } + Err(e) => { + tracing::error!(error = %e, "failed to promote user to admin"); + user + } + } + } else { + user + } + }; + + // revoke any existing cli key before issuing a new one + let _ = state.api_keys.revoke_by_name(user.id, "cli").await; + + let (plaintext, _) = match state + .api_keys + .create(user.id, "cli", &default_scopes(), None) + .await + { + Ok(k) => k, + Err(e) => { + tracing::error!(error = %e, "failed to create api key"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + }; + + let cli_redirect = format!( + "http://127.0.0.1:{}/callback?token={}", + pending.cli_port, plaintext + ); + + Html(format!( + r#" + +

login successful, redirecting...

+"#, + )) + .into_response() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use axum::Router; + use axum::routing::get; + + use crate::api; + use crate::app::AppState; + use crate::auth::oauth::{OAuthError, OAuthProvider, OAuthState, OAuthUserInfo}; + use crate::store::BoxFuture; + use crate::store::health::UptimeHealthReporter; + use crate::store::turso; + use crate::tunnel::manager::TunnelManager; + + struct MockProvider; + + impl OAuthProvider for MockProvider { + fn name(&self) -> &'static str { + "mock" + } + + fn authorize_url(&self, redirect_uri: &str, state: &str) -> String { + format!("https://mock.example.com/auth?redirect_uri={redirect_uri}&state={state}") + } + + fn exchange_code( + &self, + code: &str, + _redirect_uri: &str, + ) -> BoxFuture<'_, Result> { + let code = code.to_string(); + Box::pin(async move { + if code == "valid_code" { + Ok("mock_access_token".into()) + } else { + Err(OAuthError::Provider("bad code".into())) + } + }) + } + + fn fetch_user_info( + &self, + _access_token: &str, + ) -> BoxFuture<'_, Result> { + Box::pin(async { + Ok(OAuthUserInfo { + email: "test@example.com".into(), + name: Some("Test User".into()), + avatar_url: None, + provider: "mock".into(), + provider_id: "12345".into(), + }) + }) + } + } + + async fn test_state(with_oauth: bool) -> Arc { + let oauth_state = if with_oauth { + let mut providers: HashMap> = HashMap::new(); + providers.insert("mock".into(), Arc::new(MockProvider)); + Some(Arc::new(OAuthState::new( + providers, + "http://localhost:8080".into(), + ))) + } else { + None + }; + + let db = turso::open(":memory:") + .await + .unwrap_or_else(|e| panic!("open turso: {e}")); + + Arc::new(AppState { + tunnels: Arc::new(TunnelManager::new()), + api_keys: Arc::new(turso::api_key_store::TursoApiKeyStore::new(Arc::clone(&db))), + users: Arc::new(turso::user_store::TursoUserStore::new(Arc::clone(&db))), + accounts: Arc::new(turso::account_store::TursoAccountStore::new(Arc::clone( + &db, + ))), + sessions: Arc::new(turso::session_recorder::TursoSessionRecorder::new( + Arc::clone(&db), + )), + teams: Arc::new(turso::team_store::TursoTeamStore::new(db)), + health: Arc::new(UptimeHealthReporter::new()), + is_tls: false, + oauth_state, + initial_admin_email: None, + quic_port: 4433, + }) + } + + fn test_router(state: Arc) -> Router { + Router::new() + .route("/auth/{provider}/authorize", get(super::authorize)) + .route("/auth/{provider}/callback", get(super::callback)) + .route("/api/me", get(api::me::handler)) + .route("/api/keys", get(api::keys::list).post(api::keys::create)) + .with_state(state) + } + + #[tokio::test] + async fn authorize_redirects_to_provider() { + let state = test_state(true).await; + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server + .get("/auth/mock/authorize") + .add_query_param("cli_port", 9999) + .await; + + assert_eq!(resp.status_code(), 307); + let location = resp.header("location").to_str().unwrap().to_string(); + assert!(location.starts_with("https://mock.example.com/auth?")); + assert!(location.contains("redirect_uri=")); + assert!(location.contains("state=")); + } + + #[tokio::test] + async fn authorize_unknown_provider_returns_404() { + let state = test_state(true).await; + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server + .get("/auth/nonexistent/authorize") + .add_query_param("cli_port", 9999) + .await; + + assert_eq!(resp.status_code(), 404); + let body = resp.text(); + assert!(body.contains("unknown provider")); + } + + #[tokio::test] + async fn authorize_without_oauth_returns_404() { + let state = test_state(false).await; + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server + .get("/auth/mock/authorize") + .add_query_param("cli_port", 9999) + .await; + + assert_eq!(resp.status_code(), 404); + let body = resp.text(); + assert!(body.contains("oauth not configured")); + } + + #[tokio::test] + async fn callback_with_invalid_state_returns_400() { + let state = test_state(true).await; + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "bogus_state") + .await; + + assert_eq!(resp.status_code(), 400); + let body = resp.text(); + assert!(body.contains("invalid or expired state")); + } + + #[tokio::test] + async fn callback_with_bad_code_returns_502() { + let state = test_state(true).await; + let oauth = state.oauth_state.as_ref().unwrap(); + oauth.insert_pending("test_state_token".into(), 9999); + + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "invalid_code") + .add_query_param("state", "test_state_token") + .await; + + assert_eq!(resp.status_code(), 502); + } + + #[tokio::test] + async fn callback_full_flow_creates_user_and_returns_token() { + let state = test_state(true).await; + let oauth = state.oauth_state.as_ref().unwrap(); + oauth.insert_pending("valid_state".into(), 7777); + + let server = axum_test::TestServer::new(test_router(Arc::clone(&state))); + + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "valid_state") + .await; + + assert_eq!(resp.status_code(), 200); + let body = resp.text(); + assert!(body.contains("login successful")); + assert!(body.contains("127.0.0.1:7777/callback?token=sk_")); + + // verify account was created + let accounts = state + .accounts + .list_for_user( + state + .users + .find_by_email("test@example.com") + .await + .unwrap() + .unwrap() + .id, + ) + .await + .unwrap(); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].provider, "mock"); + assert_eq!(accounts[0].provider_account_id, "12345"); + } + + #[tokio::test] + async fn callback_state_is_consumed() { + let state = test_state(true).await; + let oauth = state.oauth_state.as_ref().unwrap(); + oauth.insert_pending("one_time".into(), 7777); + + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "one_time") + .await; + assert_eq!(resp.status_code(), 200); + + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "one_time") + .await; + assert_eq!(resp.status_code(), 400); + } + + #[tokio::test] + async fn callback_second_login_updates_profile() { + let state = test_state(true).await; + let oauth = state.oauth_state.as_ref().unwrap(); + + // first login + oauth.insert_pending("first".into(), 7777); + let server = axum_test::TestServer::new(test_router(Arc::clone(&state))); + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "first") + .await; + assert_eq!(resp.status_code(), 200); + + let user = state + .users + .find_by_email("test@example.com") + .await + .unwrap() + .unwrap(); + + // second login with same provider reuses user + oauth.insert_pending("second".into(), 7777); + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "second") + .await; + assert_eq!(resp.status_code(), 200); + + // still only one account + let accounts = state.accounts.list_for_user(user.id).await.unwrap(); + assert_eq!(accounts.len(), 1); + + // only one active cli key (old one was revoked) + let keys = state.api_keys.list_for_user(user.id).await.unwrap(); + assert_eq!(keys.iter().filter(|k| k.name == "cli").count(), 1); + } + + #[tokio::test] + async fn me_requires_auth() { + let state = test_state(false).await; + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server.get("/api/me").await; + assert_eq!(resp.status_code(), 401); + } + + #[tokio::test] + async fn me_returns_user_after_oauth_login() { + let state = test_state(true).await; + let oauth = state.oauth_state.as_ref().unwrap(); + oauth.insert_pending("login_state".into(), 7777); + + let server = axum_test::TestServer::new(test_router(Arc::clone(&state))); + + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "login_state") + .await; + assert_eq!(resp.status_code(), 200); + + let body = resp.text(); + let token_start = body.find("token=sk_").unwrap() + "token=".len(); + let token_end = body[token_start..].find('"').unwrap() + token_start; + let token = &body[token_start..token_end]; + + let resp = server + .get("/api/me") + .add_header("authorization", format!("Bearer {token}")) + .await; + assert_eq!(resp.status_code(), 200); + + let user: serde_json::Value = resp.json(); + assert_eq!(user["email"], "test@example.com"); + assert_eq!(user["name"], "Test User"); + assert_eq!(user["role"], "admin"); + } +} diff --git a/crates/funnel-server/src/api/sessions.rs b/crates/funnel-server/src/api/sessions.rs new file mode 100644 index 0000000..430c85b --- /dev/null +++ b/crates/funnel-server/src/api/sessions.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Query, State}; +use serde::Deserialize; + +use crate::app::AppState; +use crate::auth::{Management, Scoped}; +use crate::db::tunnel_sessions::TunnelSession; +use crate::error::{ApiErrorBody, AppError}; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct ListParams { + #[serde(default = "default_limit")] + limit: i64, + #[serde(default)] + all: bool, +} + +const fn default_limit() -> i64 { + 50 +} + +#[utoipa::path( + get, + path = "/sessions", + operation_id = "list_sessions", + tag = "Profile", + security(("bearer" = [])), + params(ListParams), + responses( + (status = 200, description = "Tunnel session history", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + ) +)] +pub async fn list( + State(state): State>, + auth: Scoped, + Query(params): Query, +) -> Result>, AppError> { + let sessions = if params.all && auth.is_admin() { + state.sessions.list_all(params.limit).await? + } else { + state + .sessions + .list_for_user(auth.user_id, params.limit) + .await? + }; + + Ok(Json(sessions)) +} diff --git a/crates/funnel-server/src/api/teams.rs b/crates/funnel-server/src/api/teams.rs new file mode 100644 index 0000000..433c858 --- /dev/null +++ b/crates/funnel-server/src/api/teams.rs @@ -0,0 +1,299 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, State}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::app::AppState; +use crate::auth::{Management, RequireAdmin, Scoped}; +use crate::db::teams::{TEAM_ROLE_MEMBER, TEAM_ROLE_OWNER, Team, TeamMembership}; +use crate::error::{ApiErrorBody, AppError}; + +async fn can_manage_team( + state: &AppState, + team_id: Uuid, + auth: &Scoped, +) -> Result { + if auth.is_admin() { + return Ok(true); + } + let membership = state.teams.find_membership(team_id, auth.user_id).await?; + Ok(membership.is_some_and(|m| m.role == TEAM_ROLE_OWNER)) +} + +#[utoipa::path( + get, + path = "/teams", + operation_id = "list_teams", + tag = "Teams", + security(("bearer" = [])), + responses( + (status = 200, description = "List of teams", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + ) +)] +pub async fn list( + State(state): State>, + auth: Scoped, +) -> Result>, AppError> { + let teams = if auth.is_admin() { + state.teams.list_all().await? + } else { + state.teams.list_teams_for_user(auth.user_id).await? + }; + Ok(Json(teams)) +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreateTeamRequest { + pub name: String, + pub owner_id: Option, +} + +#[utoipa::path( + post, + path = "/teams", + operation_id = "create_team", + tag = "Teams", + security(("bearer" = [])), + request_body = CreateTeamRequest, + responses( + (status = 200, description = "Team created", body = Team), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Admin role required", body = ApiErrorBody), + (status = 409, description = "Team name already exists", body = ApiErrorBody), + ) +)] +pub async fn create( + State(state): State>, + admin: RequireAdmin, + Json(req): Json, +) -> Result, AppError> { + let team = state.teams.create(&req.name).await?; + let owner_id = req.owner_id.unwrap_or(admin.user_id); + state + .teams + .add_member(team.id, owner_id, TEAM_ROLE_OWNER) + .await?; + Ok(Json(team)) +} + +#[utoipa::path( + delete, + path = "/teams/{id}", + operation_id = "delete_team", + tag = "Teams", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "Team ID")), + responses( + (status = 200, description = "Team deleted", body = Object), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Admin role required", body = ApiErrorBody), + (status = 404, description = "Team not found", body = ApiErrorBody), + ) +)] +pub async fn delete( + State(state): State>, + _admin: RequireAdmin, + Path(id): Path, +) -> Result, AppError> { + let deleted = state.teams.delete(id).await?; + if !deleted { + return Err(AppError::NotFound("team not found".into())); + } + Ok(Json(serde_json::json!({ "deleted": true }))) +} + +#[utoipa::path( + get, + path = "/teams/{id}/members", + operation_id = "list_team_members", + tag = "Teams", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "Team ID")), + responses( + (status = 200, description = "List of team members", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Not a team member", body = ApiErrorBody), + (status = 404, description = "Team not found", body = ApiErrorBody), + ) +)] +pub async fn list_members( + State(state): State>, + auth: Scoped, + Path(id): Path, +) -> Result>, AppError> { + state + .teams + .find_by_id(id) + .await? + .ok_or_else(|| AppError::NotFound("team not found".into()))?; + + if !auth.is_admin() { + let is_member = state.teams.is_member(id, auth.user_id).await?; + if !is_member { + return Err(AppError::Forbidden); + } + } + + let members = state.teams.list_members(id).await?; + Ok(Json(members)) +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct AddMemberRequest { + pub user_id: Uuid, +} + +#[utoipa::path( + post, + path = "/teams/{id}/members", + operation_id = "add_team_member", + tag = "Teams", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "Team ID")), + request_body = AddMemberRequest, + responses( + (status = 200, description = "Member added", body = TeamMembership), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Not a team owner", body = ApiErrorBody), + (status = 404, description = "Team not found", body = ApiErrorBody), + (status = 409, description = "Already a member", body = ApiErrorBody), + ) +)] +pub async fn add_member( + State(state): State>, + auth: Scoped, + Path(id): Path, + Json(req): Json, +) -> Result, AppError> { + state + .teams + .find_by_id(id) + .await? + .ok_or_else(|| AppError::NotFound("team not found".into()))?; + + if !can_manage_team(&state, id, &auth).await? { + return Err(AppError::Forbidden); + } + + let membership = state + .teams + .add_member(id, req.user_id, TEAM_ROLE_MEMBER) + .await?; + Ok(Json(membership)) +} + +#[utoipa::path( + delete, + path = "/teams/{id}/members/{user_id}", + operation_id = "remove_team_member", + tag = "Teams", + security(("bearer" = [])), + params( + ("id" = Uuid, Path, description = "Team ID"), + ("user_id" = Uuid, Path, description = "User ID"), + ), + responses( + (status = 200, description = "Member removed", body = Object), + (status = 400, description = "Cannot remove last owner", body = ApiErrorBody), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Not a team owner", body = ApiErrorBody), + (status = 404, description = "Membership not found", body = ApiErrorBody), + ) +)] +pub async fn remove_member( + State(state): State>, + auth: Scoped, + Path((id, user_id)): Path<(Uuid, Uuid)>, +) -> Result, AppError> { + if !can_manage_team(&state, id, &auth).await? { + return Err(AppError::Forbidden); + } + + // prevent removing the last owner + let membership = state.teams.find_membership(id, user_id).await?; + if let Some(ref m) = membership + && m.role == TEAM_ROLE_OWNER + { + let owner_count = state.teams.count_owners(id).await?; + if owner_count <= 1 { + return Err(AppError::BadRequest( + "cannot remove the last owner of a team".into(), + )); + } + } + + let removed = state.teams.remove_member(id, user_id).await?; + if !removed { + return Err(AppError::NotFound("membership not found".into())); + } + Ok(Json(serde_json::json!({ "removed": true }))) +} + +#[derive(Deserialize, utoipa::ToSchema)] +#[schema(as = SetTeamMemberRoleRequest)] +pub struct SetRoleRequest { + pub role: String, +} + +#[utoipa::path( + put, + path = "/teams/{id}/members/{user_id}/role", + operation_id = "set_team_member_role", + tag = "Teams", + security(("bearer" = [])), + params( + ("id" = Uuid, Path, description = "Team ID"), + ("user_id" = Uuid, Path, description = "User ID"), + ), + request_body = SetRoleRequest, + responses( + (status = 200, description = "Member role updated", body = TeamMembership), + (status = 400, description = "Invalid role or last owner", body = ApiErrorBody), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Not a team owner", body = ApiErrorBody), + (status = 404, description = "Membership not found", body = ApiErrorBody), + ) +)] +pub async fn set_member_role( + State(state): State>, + auth: Scoped, + Path((id, user_id)): Path<(Uuid, Uuid)>, + Json(req): Json, +) -> Result, AppError> { + if req.role != TEAM_ROLE_OWNER && req.role != TEAM_ROLE_MEMBER { + return Err(AppError::BadRequest(format!( + "invalid role '{}', must be '{}' or '{}'", + req.role, TEAM_ROLE_OWNER, TEAM_ROLE_MEMBER + ))); + } + + if !can_manage_team(&state, id, &auth).await? { + return Err(AppError::Forbidden); + } + + // prevent demoting the last owner + if req.role == TEAM_ROLE_MEMBER { + let current = state + .teams + .find_membership(id, user_id) + .await? + .ok_or_else(|| AppError::NotFound("membership not found".into()))?; + if current.role == TEAM_ROLE_OWNER { + let owner_count = state.teams.count_owners(id).await?; + if owner_count <= 1 { + return Err(AppError::BadRequest( + "cannot demote the last owner of a team".into(), + )); + } + } + } + + let membership = state + .teams + .update_member_role(id, user_id, &req.role) + .await?; + Ok(Json(membership)) +} diff --git a/crates/funnel-server/src/api/tunnels.rs b/crates/funnel-server/src/api/tunnels.rs new file mode 100644 index 0000000..6293ae2 --- /dev/null +++ b/crates/funnel-server/src/api/tunnels.rs @@ -0,0 +1,158 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, State}; + +use funnel_core::tunnel::id::TunnelId; + +use crate::app::AppState; +use crate::auth::{Management, Scoped}; +use crate::error::{ApiErrorBody, AppError}; +use crate::tunnel::manager::TunnelInfo; + +#[utoipa::path( + get, + path = "/tunnels", + operation_id = "list_tunnels", + tag = "Tunnels", + security(("bearer" = [])), + responses( + (status = 200, description = "List of active tunnels", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Forbidden", body = ApiErrorBody), + ) +)] +pub async fn list( + State(state): State>, + auth: Scoped, +) -> Result>, AppError> { + let all = state.tunnels.list(); + + if auth.is_admin() { + return Ok(Json(all)); + } + + let team_ids = state + .teams + .get_team_ids_for_user(auth.user_id) + .await + .map_err(AppError::Store)?; + + let visible: Vec = all + .into_iter() + .filter(|t| { + t.owner_id == auth.user_id || t.team_id.is_some_and(|tid| team_ids.contains(&tid)) + }) + .collect(); + + Ok(Json(visible)) +} + +fn can_access(tunnel: &TunnelInfo, user_id: uuid::Uuid, team_ids: &[uuid::Uuid]) -> bool { + tunnel.owner_id == user_id || tunnel.team_id.is_some_and(|tid| team_ids.contains(&tid)) +} + +#[utoipa::path( + get, + path = "/tunnels/{id}", + operation_id = "get_tunnel", + tag = "Tunnels", + security(("bearer" = [])), + params(("id" = String, Path, description = "Tunnel ID")), + responses( + (status = 200, description = "Tunnel details", body = TunnelInfo), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Forbidden", body = ApiErrorBody), + (status = 404, description = "Tunnel not found", body = ApiErrorBody), + ) +)] +pub async fn get_tunnel( + State(state): State>, + auth: Scoped, + Path(id): Path, +) -> Result, AppError> { + let tunnel_id = TunnelId::new(id.clone())?; + + let tunnel = state + .tunnels + .get(&tunnel_id) + .ok_or_else(|| AppError::TunnelNotFound(id))?; + + let info = TunnelInfo { + id: tunnel.id().to_string(), + uptime_secs: tunnel.connected_at().elapsed().as_secs_f64(), + stats: tunnel.stats(), + owner_id: tunnel.owner_id(), + team_id: tunnel.team_id(), + }; + + if !auth.is_admin() { + let team_ids = state + .teams + .get_team_ids_for_user(auth.user_id) + .await + .map_err(AppError::Store)?; + if !can_access(&info, auth.user_id, &team_ids) { + return Err(AppError::Forbidden); + } + } + + Ok(Json(info)) +} + +#[utoipa::path( + delete, + path = "/tunnels/{id}", + operation_id = "delete_tunnel", + tag = "Tunnels", + security(("bearer" = [])), + params(("id" = String, Path, description = "Tunnel ID")), + responses( + (status = 200, description = "Tunnel disconnected", body = Object), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Forbidden", body = ApiErrorBody), + (status = 404, description = "Tunnel not found", body = ApiErrorBody), + ) +)] +pub async fn delete( + State(state): State>, + auth: Scoped, + Path(id): Path, +) -> Result, AppError> { + let tunnel_id = TunnelId::new(id.clone())?; + + let tunnel = state + .tunnels + .get(&tunnel_id) + .ok_or_else(|| AppError::TunnelNotFound(id))?; + + if !auth.is_admin() { + let info = TunnelInfo { + id: tunnel.id().to_string(), + uptime_secs: 0.0, + stats: tunnel.stats(), + owner_id: tunnel.owner_id(), + team_id: tunnel.team_id(), + }; + let team_ids = state + .teams + .get_team_ids_for_user(auth.user_id) + .await + .map_err(AppError::Store)?; + if !can_access(&info, auth.user_id, &team_ids) { + return Err(AppError::Forbidden); + } + } + + // drop the Arc from get() before removing + drop(tunnel); + + let tunnel = state + .tunnels + .remove(&tunnel_id) + .ok_or_else(|| AppError::TunnelNotFound(tunnel_id.to_string()))?; + + tunnel.close(); + + Ok(Json(serde_json::json!({ "deleted": true }))) +} diff --git a/crates/funnel-server/src/api/users.rs b/crates/funnel-server/src/api/users.rs new file mode 100644 index 0000000..7eafdef --- /dev/null +++ b/crates/funnel-server/src/api/users.rs @@ -0,0 +1,159 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, Query, State}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::app::AppState; +use crate::auth::RequireAdmin; +use crate::db::users::{ROLE_ADMIN, ROLE_MEMBER, User}; +use crate::error::{ApiErrorBody, AppError}; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct ListParams { + #[serde(default = "default_limit")] + limit: i64, +} + +const fn default_limit() -> i64 { + 50 +} + +#[utoipa::path( + get, + path = "/users", + operation_id = "list_users", + tag = "Users", + security(("bearer" = [])), + params(ListParams), + responses( + (status = 200, description = "List of all users", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Admin role required", body = ApiErrorBody), + ) +)] +pub async fn list( + State(state): State>, + _admin: RequireAdmin, + Query(params): Query, +) -> Result>, AppError> { + let users = state.users.list_all(params.limit).await?; + Ok(Json(users)) +} + +#[derive(Deserialize, utoipa::ToSchema)] +#[schema(as = SetUserRoleRequest)] +pub struct SetRoleRequest { + pub role: String, +} + +#[utoipa::path( + put, + path = "/users/{id}/role", + operation_id = "set_user_role", + tag = "Users", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "User ID")), + request_body = SetRoleRequest, + responses( + (status = 200, description = "User role updated", body = User), + (status = 400, description = "Invalid role or last admin", body = ApiErrorBody), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Admin role required", body = ApiErrorBody), + (status = 404, description = "User not found", body = ApiErrorBody), + ) +)] +pub async fn set_role( + State(state): State>, + _admin: RequireAdmin, + Path(id): Path, + Json(req): Json, +) -> Result, AppError> { + if req.role != ROLE_ADMIN && req.role != ROLE_MEMBER { + return Err(AppError::BadRequest(format!( + "invalid role: {}, must be '{}' or '{}'", + req.role, ROLE_ADMIN, ROLE_MEMBER + ))); + } + + // prevent demotion of the last admin + if req.role == ROLE_MEMBER { + let target = state + .users + .find_by_id(id) + .await? + .ok_or_else(|| AppError::NotFound("user not found".into()))?; + + if target.is_admin() { + let admin_count = state.users.count_admins().await?; + if admin_count <= 1 { + return Err(AppError::BadRequest("cannot demote the last admin".into())); + } + } + } + + let user = state.users.update_role(id, &req.role).await?; + Ok(Json(user)) +} + +#[utoipa::path( + post, + path = "/users/{id}/deactivate", + operation_id = "deactivate_user", + tag = "Users", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "User ID")), + responses( + (status = 200, description = "User deactivated", body = User), + (status = 400, description = "Cannot deactivate last admin", body = ApiErrorBody), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Admin role required", body = ApiErrorBody), + (status = 404, description = "User not found", body = ApiErrorBody), + ) +)] +pub async fn deactivate( + State(state): State>, + _admin: RequireAdmin, + Path(id): Path, +) -> Result, AppError> { + let target = state + .users + .find_by_id(id) + .await? + .ok_or_else(|| AppError::NotFound("user not found".into()))?; + + if target.is_admin() { + let admin_count = state.users.count_admins().await?; + if admin_count <= 1 { + return Err(AppError::BadRequest( + "cannot deactivate the last admin".into(), + )); + } + } + + let user = state.users.deactivate(id).await?; + Ok(Json(user)) +} + +#[utoipa::path( + post, + path = "/users/{id}/reactivate", + operation_id = "reactivate_user", + tag = "Users", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "User ID")), + responses( + (status = 200, description = "User reactivated", body = User), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Admin role required", body = ApiErrorBody), + ) +)] +pub async fn reactivate( + State(state): State>, + _admin: RequireAdmin, + Path(id): Path, +) -> Result, AppError> { + let user = state.users.reactivate(id).await?; + Ok(Json(user)) +} diff --git a/crates/funnel-server/src/app.rs b/crates/funnel-server/src/app.rs new file mode 100644 index 0000000..e4812ec --- /dev/null +++ b/crates/funnel-server/src/app.rs @@ -0,0 +1,144 @@ +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::extract::State; +use axum::http::Request; +use axum::middleware::{self, Next}; +use axum::response::Response; +use axum::routing::get; +use metrics_exporter_prometheus::PrometheusHandle; +use scalar_api_reference::axum::scalar_response; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; + +use funnel_core::protocol::PROTOCOL_VERSION; +use funnel_core::tunnel::id::TunnelId; + +use crate::api; +use crate::auth::oauth::OAuthState; +use crate::metrics; +use crate::proxy; +use crate::store::account_store::AccountStore; +use crate::store::api_key_store::ApiKeyStore; +use crate::store::health::HealthReporter; +use crate::store::session_recorder::SessionRecorder; +use crate::store::team_store::TeamStore; +use crate::store::tunnel_registry::TunnelRegistry; +use crate::store::user_store::UserStore; + +pub struct AppState { + pub tunnels: Arc, + pub api_keys: Arc, + pub users: Arc, + pub accounts: Arc, + pub sessions: Arc, + pub teams: Arc, + pub health: Arc, + pub is_tls: bool, + pub oauth_state: Option>, + pub initial_admin_email: Option, + pub quic_port: u16, +} + +pub fn build_router(state: Arc, metrics_handle: PrometheusHandle) -> Router { + let api_routes = Router::new() + .route("/health", get(api::health::handler)) + .route("/info", get(api::info::handler)) + .route("/tunnels", get(api::tunnels::list)) + .route( + "/tunnels/{id}", + get(api::tunnels::get_tunnel).delete(api::tunnels::delete), + ) + .route("/keys", get(api::keys::list).post(api::keys::create)) + .route("/keys/{id}", axum::routing::delete(api::keys::revoke)) + .route("/me", get(api::me::handler)) + .route("/accounts", get(api::accounts::list)) + .route("/sessions", get(api::sessions::list)) + .route("/users", get(api::users::list)) + .route("/users/{id}/role", axum::routing::put(api::users::set_role)) + .route( + "/users/{id}/deactivate", + axum::routing::post(api::users::deactivate), + ) + .route( + "/users/{id}/reactivate", + axum::routing::post(api::users::reactivate), + ) + .route("/teams", get(api::teams::list).post(api::teams::create)) + .route("/teams/{id}", axum::routing::delete(api::teams::delete)) + .route( + "/teams/{id}/members", + get(api::teams::list_members).post(api::teams::add_member), + ) + .route( + "/teams/{id}/members/{user_id}", + axum::routing::delete(api::teams::remove_member), + ) + .route( + "/teams/{id}/members/{user_id}/role", + axum::routing::put(api::teams::set_member_role), + ) + .route("/metrics", get(metrics::handler).with_state(metrics_handle)); + + let auth_routes = Router::new() + .route("/{provider}/authorize", get(api::oauth::authorize)) + .route("/{provider}/callback", get(api::oauth::callback)); + + let api_prefix = format!("/api/v{PROTOCOL_VERSION}"); + let auth_prefix = format!("/auth/v{PROTOCOL_VERSION}"); + + let scalar_config = serde_json::json!({ + "url": format!("{api_prefix}/openapi.json"), + "hideClientButton": true, + "agent": { "disabled": true }, + "mcp": { "disabled": true }, + }); + + let scalar_routes = Router::new() + .route( + "/scalar", + get(move || { + let config = scalar_config.clone(); + async move { scalar_response(&config, None) } + }), + ) + .route("/openapi.json", get(crate::openapi::json_handler)); + + let api_routes = api_routes.merge(scalar_routes); + + Router::new() + .nest(&api_prefix, api_routes) + .nest(&auth_prefix, auth_routes) + .fallback(proxy::router::handle_tunnel_request) + .layer(middleware::from_fn_with_state( + Arc::clone(&state), + tunnel_routing, + )) + .with_state(state) + .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::permissive()) +} + +/// if the first label of the host header matches a registered tunnel, +/// route directly to the tunnel proxy, bypassing api/auth routes +async fn tunnel_routing( + State(state): State>, + mut request: Request, + next: Next, +) -> Response { + let tunnel_id = request + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .and_then(proxy::router::extract_subdomain) + .and_then(|sub| TunnelId::new(sub).ok()) + .filter(|id| state.tunnels.get(id).is_some()); + + if let Some(id) = tunnel_id { + request.extensions_mut().insert(id); + proxy::router::handle_tunnel_request(State(state), request).await + } else { + next.run(request).await + } +} diff --git a/crates/funnel-server/src/auth/generic.rs b/crates/funnel-server/src/auth/generic.rs new file mode 100644 index 0000000..0dfead2 --- /dev/null +++ b/crates/funnel-server/src/auth/generic.rs @@ -0,0 +1,267 @@ +use serde::Deserialize; + +use crate::store::BoxFuture; + +use super::oauth::{OAuthError, OAuthProvider, OAuthUserInfo}; + +pub struct GenericProviderConfig { + pub name: String, + pub client_id: String, + pub client_secret: String, + pub authorize_url: String, + pub token_url: String, + pub userinfo_url: String, + pub scopes: String, + /// json field name for the user's unique id (default: "sub") + pub id_field: String, + /// json field name for email (default: "email") + pub email_field: String, + /// json field name for display name (default: "name") + pub name_field: String, + /// json field name for avatar url (default: "picture") + pub avatar_field: String, +} + +impl GenericProviderConfig { + #[cfg(test)] + pub fn with_defaults( + name: String, + client_id: String, + client_secret: String, + authorize_url: String, + token_url: String, + userinfo_url: String, + scopes: String, + ) -> Self { + Self { + name, + client_id, + client_secret, + authorize_url, + token_url, + userinfo_url, + scopes, + id_field: "sub".into(), + email_field: "email".into(), + name_field: "name".into(), + avatar_field: "picture".into(), + } + } +} + +pub struct GenericProvider { + config: GenericProviderConfig, + client: reqwest::Client, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: Option, + error: Option, + error_description: Option, +} + +impl GenericProvider { + pub fn new(config: GenericProviderConfig) -> Self { + let client = reqwest::Client::builder() + .user_agent("funnel-server") + .build() + .unwrap_or_default(); + + Self { config, client } + } + + fn extract_string(obj: &serde_json::Value, field: &str) -> Option { + let val = obj.get(field)?; + // handle both string and numeric ids + val.as_str() + .map(ToString::to_string) + .or_else(|| val.as_u64().map(|n| n.to_string())) + .or_else(|| val.as_i64().map(|n| n.to_string())) + } +} + +impl OAuthProvider for GenericProvider { + fn name(&self) -> &'static str { + // leak is fine here, provider names are static for the process lifetime + Box::leak(self.config.name.clone().into_boxed_str()) + } + + fn authorize_url(&self, redirect_uri: &str, state: &str) -> String { + format!( + "{}?client_id={}&redirect_uri={}&state={}&scope={}&response_type=code", + self.config.authorize_url, + urlencoding::encode(&self.config.client_id), + urlencoding::encode(redirect_uri), + urlencoding::encode(state), + urlencoding::encode(&self.config.scopes), + ) + } + + fn exchange_code( + &self, + code: &str, + redirect_uri: &str, + ) -> BoxFuture<'_, Result> { + let code = code.to_string(); + let redirect_uri = redirect_uri.to_string(); + + Box::pin(async move { + let resp: TokenResponse = self + .client + .post(&self.config.token_url) + .header("accept", "application/json") + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", &self.config.client_id), + ("client_secret", &self.config.client_secret), + ("code", &code), + ("redirect_uri", &redirect_uri), + ]) + .send() + .await? + .json() + .await?; + + if let Some(err) = resp.error { + let detail = resp.error_description.unwrap_or_default(); + return Err(OAuthError::Provider(format!("{err}: {detail}"))); + } + + resp.access_token + .ok_or_else(|| OAuthError::MissingField("access_token".into())) + }) + } + + fn fetch_user_info( + &self, + access_token: &str, + ) -> BoxFuture<'_, Result> { + let token = access_token.to_string(); + + Box::pin(async move { + let user: serde_json::Value = self + .client + .get(&self.config.userinfo_url) + .bearer_auth(&token) + .send() + .await? + .json() + .await?; + + let provider_id = Self::extract_string(&user, &self.config.id_field) + .ok_or_else(|| OAuthError::MissingField(self.config.id_field.clone()))?; + + let email = Self::extract_string(&user, &self.config.email_field) + .ok_or_else(|| OAuthError::MissingField(self.config.email_field.clone()))?; + + let name = Self::extract_string(&user, &self.config.name_field); + let avatar_url = Self::extract_string(&user, &self.config.avatar_field); + + Ok(OAuthUserInfo { + email, + name, + avatar_url, + provider: self.config.name.clone(), + provider_id, + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> GenericProviderConfig { + GenericProviderConfig::with_defaults( + "test".into(), + "client123".into(), + "secret456".into(), + "https://auth.example.com/authorize".into(), + "https://auth.example.com/token".into(), + "https://auth.example.com/userinfo".into(), + "openid email profile".into(), + ) + } + + #[test] + fn authorize_url_includes_all_params() { + let provider = GenericProvider::new(test_config()); + let url = provider.authorize_url("https://myapp.com/callback", "state123"); + + assert!(url.starts_with("https://auth.example.com/authorize?")); + assert!(url.contains("client_id=client123")); + assert!(url.contains("state=state123")); + assert!(url.contains("response_type=code")); + assert!(url.contains("scope=openid")); + } + + #[test] + fn authorize_url_encodes_special_chars() { + let mut config = test_config(); + config.scopes = "openid email profile".into(); + let provider = GenericProvider::new(config); + let url = provider.authorize_url("https://myapp.com/cb?a=1", "s&t"); + + assert!(url.contains("redirect_uri=https%3A%2F%2Fmyapp.com%2Fcb%3Fa%3D1")); + assert!(url.contains("state=s%26t")); + } + + #[test] + fn extract_string_from_string_field() { + let json = serde_json::json!({"sub": "user-123"}); + assert_eq!( + GenericProvider::extract_string(&json, "sub"), + Some("user-123".into()) + ); + } + + #[test] + fn extract_string_from_numeric_field() { + let json = serde_json::json!({"id": 42}); + assert_eq!( + GenericProvider::extract_string(&json, "id"), + Some("42".into()) + ); + } + + #[test] + fn extract_string_returns_none_for_missing() { + let json = serde_json::json!({"foo": "bar"}); + assert!(GenericProvider::extract_string(&json, "missing").is_none()); + } + + #[test] + fn custom_field_mapping() { + let mut config = test_config(); + config.id_field = "user_id".into(); + config.email_field = "mail".into(); + config.name_field = "display_name".into(); + config.avatar_field = "avatar".into(); + + let json = serde_json::json!({ + "user_id": "u-1", + "mail": "user@corp.com", + "display_name": "User One", + "avatar": "https://img.example.com/1.png" + }); + + assert_eq!( + GenericProvider::extract_string(&json, &config.id_field), + Some("u-1".into()) + ); + assert_eq!( + GenericProvider::extract_string(&json, &config.email_field), + Some("user@corp.com".into()) + ); + assert_eq!( + GenericProvider::extract_string(&json, &config.name_field), + Some("User One".into()) + ); + assert_eq!( + GenericProvider::extract_string(&json, &config.avatar_field), + Some("https://img.example.com/1.png".into()) + ); + } +} diff --git a/crates/funnel-server/src/auth/github.rs b/crates/funnel-server/src/auth/github.rs new file mode 100644 index 0000000..39e9564 --- /dev/null +++ b/crates/funnel-server/src/auth/github.rs @@ -0,0 +1,143 @@ +use serde::Deserialize; + +use crate::store::BoxFuture; + +use super::oauth::{OAuthError, OAuthProvider, OAuthUserInfo}; + +pub struct OAuthConfig { + pub client_id: String, + pub client_secret: String, +} + +pub struct GitHubProvider { + config: OAuthConfig, + client: reqwest::Client, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: Option, + error: Option, + error_description: Option, +} + +#[derive(Deserialize)] +struct GitHubUser { + id: u64, + email: Option, + name: Option, + avatar_url: Option, +} + +#[derive(Deserialize)] +struct GitHubEmail { + email: String, + primary: bool, + verified: bool, +} + +impl GitHubProvider { + pub fn new(config: OAuthConfig) -> Self { + let client = reqwest::Client::builder() + .user_agent("funnel-server") + .build() + .unwrap_or_default(); + + Self { config, client } + } +} + +impl OAuthProvider for GitHubProvider { + fn name(&self) -> &'static str { + "github" + } + + fn authorize_url(&self, redirect_uri: &str, state: &str) -> String { + format!( + "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&state={}&scope=user:email", + urlencoding::encode(&self.config.client_id), + urlencoding::encode(redirect_uri), + urlencoding::encode(state), + ) + } + + fn exchange_code( + &self, + code: &str, + redirect_uri: &str, + ) -> BoxFuture<'_, Result> { + let code = code.to_string(); + let redirect_uri = redirect_uri.to_string(); + + Box::pin(async move { + let resp: TokenResponse = self + .client + .post("https://github.com/login/oauth/access_token") + .header("accept", "application/json") + .form(&[ + ("client_id", self.config.client_id.as_str()), + ("client_secret", self.config.client_secret.as_str()), + ("code", &code), + ("redirect_uri", &redirect_uri), + ]) + .send() + .await? + .json() + .await?; + + if let Some(err) = resp.error { + let detail = resp.error_description.unwrap_or_default(); + return Err(OAuthError::Provider(format!("{err}: {detail}"))); + } + + resp.access_token + .ok_or_else(|| OAuthError::MissingField("access_token".into())) + }) + } + + fn fetch_user_info( + &self, + access_token: &str, + ) -> BoxFuture<'_, Result> { + let token = access_token.to_string(); + + Box::pin(async move { + let user: GitHubUser = self + .client + .get("https://api.github.com/user") + .bearer_auth(&token) + .send() + .await? + .json() + .await?; + + let email = if let Some(email) = user.email { + email + } else { + // fetch from /user/emails for users with private emails + let emails: Vec = self + .client + .get("https://api.github.com/user/emails") + .bearer_auth(&token) + .send() + .await? + .json() + .await?; + + emails + .into_iter() + .find(|e| e.primary && e.verified) + .map(|e| e.email) + .ok_or_else(|| OAuthError::MissingField("email".into()))? + }; + + Ok(OAuthUserInfo { + email, + name: user.name, + avatar_url: user.avatar_url, + provider: "github".into(), + provider_id: user.id.to_string(), + }) + }) + } +} diff --git a/crates/funnel-server/src/auth/mod.rs b/crates/funnel-server/src/auth/mod.rs new file mode 100644 index 0000000..ab8b1ec --- /dev/null +++ b/crates/funnel-server/src/auth/mod.rs @@ -0,0 +1,158 @@ +pub mod generic; +pub mod github; +pub mod oauth; + +use std::sync::Arc; + +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use uuid::Uuid; + +use crate::app::AppState; +use crate::error::AppError; + +pub struct AuthUser { + pub user_id: Uuid, + pub scopes: Vec, + pub role: String, +} + +impl AuthUser { + fn has_scope(&self, scope: &str) -> bool { + self.scopes.iter().any(|s| s == scope) + } + + pub fn is_admin(&self) -> bool { + self.role == "admin" + } +} + +impl FromRequestParts> for AuthUser { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let token = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or(AppError::Unauthorized)?; + + let api_key = state + .api_keys + .validate(token) + .await + .map_err(AppError::Store)? + .ok_or(AppError::Unauthorized)?; + + let user = state + .users + .find_by_id(api_key.user_id) + .await + .map_err(AppError::Store)? + .ok_or(AppError::Unauthorized)?; + + if !user.is_active() { + return Err(AppError::Unauthorized); + } + + let scopes = api_key + .scopes + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(ToString::to_string)) + .collect() + }) + .unwrap_or_default(); + + Ok(Self { + user_id: api_key.user_id, + scopes, + role: user.role, + }) + } +} + +// scope aware extractors using the type system +// +// handlers declare their required scope in the type signature: +// async fn list(auth: Scoped) -> ... +// +// the extractor validates auth and checks the scope, returning 403 if missing. + +pub trait Scope: Send + Sync + 'static { + const NAME: &'static str; +} + +pub struct Management; +impl Scope for Management { + const NAME: &'static str = "management"; +} + +#[allow(dead_code)] +pub struct Tunnels; +impl Scope for Tunnels { + const NAME: &'static str = "tunnels"; +} + +pub struct Scoped { + pub user_id: Uuid, + pub role: String, + _scope: std::marker::PhantomData, +} + +impl Scoped { + pub fn is_admin(&self) -> bool { + self.role == "admin" + } +} + +impl FromRequestParts> for Scoped { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let auth = AuthUser::from_request_parts(parts, state).await?; + + if !auth.has_scope(S::NAME) { + return Err(AppError::Forbidden); + } + + Ok(Self { + user_id: auth.user_id, + role: auth.role, + _scope: std::marker::PhantomData, + }) + } +} + +/// extractor that requires management scope AND admin role +pub struct RequireAdmin { + #[allow(dead_code)] + pub user_id: Uuid, +} + +impl FromRequestParts> for RequireAdmin { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let auth = AuthUser::from_request_parts(parts, state).await?; + + if !auth.has_scope("management") || !auth.is_admin() { + return Err(AppError::Forbidden); + } + + Ok(Self { + user_id: auth.user_id, + }) + } +} diff --git a/crates/funnel-server/src/auth/oauth.rs b/crates/funnel-server/src/auth/oauth.rs new file mode 100644 index 0000000..c304803 --- /dev/null +++ b/crates/funnel-server/src/auth/oauth.rs @@ -0,0 +1,168 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; + +use dashmap::DashMap; + +use crate::store::BoxFuture; + +#[derive(Debug, Clone)] +pub struct OAuthUserInfo { + pub email: String, + pub name: Option, + pub avatar_url: Option, + pub provider: String, + pub provider_id: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum OAuthError { + #[error("http request failed: {0}")] + Request(#[from] reqwest::Error), + + #[error("provider returned an error: {0}")] + Provider(String), + + #[error("missing required field: {0}")] + MissingField(String), +} + +#[allow(dead_code)] +pub trait OAuthProvider: Send + Sync { + fn name(&self) -> &'static str; + fn authorize_url(&self, redirect_uri: &str, state: &str) -> String; + fn exchange_code( + &self, + code: &str, + redirect_uri: &str, + ) -> BoxFuture<'_, Result>; + fn fetch_user_info( + &self, + access_token: &str, + ) -> BoxFuture<'_, Result>; +} + +pub struct PendingAuth { + pub cli_port: u16, + pub created_at: Instant, +} + +const STATE_TOKEN_BYTES: usize = 32; +const PENDING_TTL_SECS: u64 = 600; + +pub struct OAuthState { + pub providers: HashMap>, + pub pending: DashMap, + pub base_url: String, +} + +impl OAuthState { + pub fn new(providers: HashMap>, base_url: String) -> Self { + Self { + providers, + pending: DashMap::new(), + base_url, + } + } + + pub fn generate_state_token() -> Result { + let mut bytes = [0u8; STATE_TOKEN_BYTES]; + getrandom::fill(&mut bytes)?; + Ok(hex::encode(bytes)) + } + + pub fn insert_pending(&self, state: String, cli_port: u16) { + self.cleanup_expired(); + self.pending.insert( + state, + PendingAuth { + cli_port, + created_at: Instant::now(), + }, + ); + } + + pub fn take_pending(&self, state: &str) -> Option { + self.pending.remove(state).map(|(_, v)| v) + } + + fn cleanup_expired(&self) { + self.pending + .retain(|_, v| v.created_at.elapsed().as_secs() < PENDING_TTL_SECS); + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::unchecked_time_subtraction +)] +mod tests { + use super::*; + + fn empty_state() -> OAuthState { + OAuthState::new(HashMap::new(), "http://localhost".into()) + } + + #[test] + fn generate_state_token_produces_64_hex_chars() { + let token = OAuthState::generate_state_token().unwrap(); + assert_eq!(token.len(), STATE_TOKEN_BYTES * 2); + assert!(token.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn generated_tokens_are_unique() { + let a = OAuthState::generate_state_token().unwrap(); + let b = OAuthState::generate_state_token().unwrap(); + assert_ne!(a, b); + } + + #[test] + fn insert_and_take_pending() { + let state = empty_state(); + + state.insert_pending("tok_1".into(), 9000); + + let pending = state.take_pending("tok_1"); + assert!(pending.is_some()); + assert_eq!(pending.unwrap().cli_port, 9000); + } + + #[test] + fn take_pending_removes_entry() { + let state = empty_state(); + + state.insert_pending("tok_2".into(), 9001); + let _ = state.take_pending("tok_2"); + + assert!(state.take_pending("tok_2").is_none()); + } + + #[test] + fn take_pending_returns_none_for_unknown() { + let state = empty_state(); + assert!(state.take_pending("nonexistent").is_none()); + } + + #[test] + fn expired_entries_are_cleaned_up() { + let state = empty_state(); + + // insert an already expired entry by manipulating directly + state.pending.insert( + "old".into(), + PendingAuth { + cli_port: 1234, + created_at: Instant::now() - std::time::Duration::from_secs(PENDING_TTL_SECS + 1), + }, + ); + + // inserting a new one triggers cleanup + state.insert_pending("fresh".into(), 5678); + + assert!(state.take_pending("old").is_none()); + assert!(state.take_pending("fresh").is_some()); + } +} diff --git a/crates/funnel-server/src/db/accounts.rs b/crates/funnel-server/src/db/accounts.rs new file mode 100644 index 0000000..f0751b6 --- /dev/null +++ b/crates/funnel-server/src/db/accounts.rs @@ -0,0 +1,61 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct Account { + pub id: Uuid, + pub user_id: Uuid, + pub provider: String, + pub provider_account_id: String, + pub metadata: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub struct NewAccount { + pub user_id: Uuid, + pub provider: String, + pub provider_account_id: String, + pub metadata: serde_json::Value, +} + +pub async fn find_by_provider( + pool: &PgPool, + provider: &str, + provider_account_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Account>( + "SELECT * FROM accounts WHERE provider = $1 AND provider_account_id = $2", + ) + .bind(provider) + .bind(provider_account_id) + .fetch_optional(pool) + .await +} + +pub async fn create(pool: &PgPool, new_account: NewAccount) -> Result { + sqlx::query_as::<_, Account>( + r" + INSERT INTO accounts (user_id, provider, provider_account_id, metadata) + VALUES ($1, $2, $3, $4) + RETURNING * + ", + ) + .bind(new_account.user_id) + .bind(&new_account.provider) + .bind(&new_account.provider_account_id) + .bind(&new_account.metadata) + .fetch_one(pool) + .await +} + +pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Account>( + "SELECT * FROM accounts WHERE user_id = $1 ORDER BY created_at DESC", + ) + .bind(user_id) + .fetch_all(pool) + .await +} diff --git a/crates/funnel-server/src/db/api_keys.rs b/crates/funnel-server/src/db/api_keys.rs new file mode 100644 index 0000000..1ab42eb --- /dev/null +++ b/crates/funnel-server/src/db/api_keys.rs @@ -0,0 +1,132 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +use funnel_core::auth::token as auth; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct ApiKey { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + pub key_hash: String, + pub key_prefix: String, + pub scopes: serde_json::Value, + pub created_at: DateTime, + pub revoked_at: Option>, + pub expires_at: Option>, +} + +impl ApiKey { + pub fn has_scope(&self, scope: &str) -> bool { + self.scopes + .as_array() + .is_some_and(|arr| arr.iter().any(|s| s.as_str() == Some(scope))) + } +} + +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct ApiKeyView { + pub id: Uuid, + pub name: String, + pub key_prefix: String, + #[schema(value_type = Vec)] + pub scopes: serde_json::Value, + pub created_at: DateTime, + pub revoked_at: Option>, + pub expires_at: Option>, +} + +impl From for ApiKeyView { + fn from(key: ApiKey) -> Self { + Self { + id: key.id, + name: key.name, + key_prefix: key.key_prefix, + scopes: key.scopes, + created_at: key.created_at, + revoked_at: key.revoked_at, + expires_at: key.expires_at, + } + } +} + +pub fn default_scopes() -> serde_json::Value { + serde_json::json!(["management", "tunnels"]) +} + +pub async fn create( + pool: &PgPool, + user_id: Uuid, + name: &str, + scopes: &serde_json::Value, + expires_at: Option>, +) -> Result<(String, ApiKey), sqlx::Error> { + let plaintext = auth::generate_api_key() + .map_err(|e| sqlx::Error::Protocol(format!("failed to generate api key: {e}")))?; + let hash = auth::hash_token(&plaintext); + let prefix = auth::ApiKeyPrefix::from_key(&plaintext); + + let key = sqlx::query_as::<_, ApiKey>( + r" + INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + ", + ) + .bind(user_id) + .bind(name) + .bind(&hash) + .bind(prefix.as_ref()) + .bind(scopes) + .bind(expires_at) + .fetch_one(pool) + .await?; + + Ok((plaintext, key)) +} + +pub async fn validate(pool: &PgPool, plaintext: &str) -> Result, sqlx::Error> { + let hash = auth::hash_token(plaintext); + + sqlx::query_as::<_, ApiKey>( + "SELECT * FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())", + ) + .bind(&hash) + .fetch_optional(pool) + .await +} + +pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, ApiKey>( + "SELECT * FROM api_keys WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC", + ) + .bind(user_id) + .fetch_all(pool) + .await +} + +pub async fn revoke(pool: &PgPool, key_id: Uuid, user_id: Uuid) -> Result { + let result = sqlx::query( + "UPDATE api_keys SET revoked_at = now() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL", + ) + .bind(key_id) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +pub async fn revoke_by_name(pool: &PgPool, user_id: Uuid, name: &str) -> Result { + let result = sqlx::query( + "UPDATE api_keys SET revoked_at = now() WHERE user_id = $1 AND name = $2 AND revoked_at IS NULL", + ) + .bind(user_id) + .bind(name) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} diff --git a/crates/funnel-server/src/db/mod.rs b/crates/funnel-server/src/db/mod.rs new file mode 100644 index 0000000..3b5a87e --- /dev/null +++ b/crates/funnel-server/src/db/mod.rs @@ -0,0 +1,5 @@ +pub mod accounts; +pub mod api_keys; +pub mod teams; +pub mod tunnel_sessions; +pub mod users; diff --git a/crates/funnel-server/src/db/teams.rs b/crates/funnel-server/src/db/teams.rs new file mode 100644 index 0000000..d2639c2 --- /dev/null +++ b/crates/funnel-server/src/db/teams.rs @@ -0,0 +1,188 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct Team { + pub id: Uuid, + pub name: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub const TEAM_ROLE_OWNER: &str = "owner"; +pub const TEAM_ROLE_MEMBER: &str = "member"; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct TeamMembership { + pub id: Uuid, + pub team_id: Uuid, + pub user_id: Uuid, + pub role: String, + pub created_at: DateTime, +} + +pub async fn create(pool: &PgPool, name: &str) -> Result { + sqlx::query_as::<_, Team>( + r" + INSERT INTO teams (name) + VALUES ($1) + RETURNING * + ", + ) + .bind(name) + .fetch_one(pool) + .await +} + +pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await +} + +pub async fn find_by_name(pool: &PgPool, name: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE name = $1") + .bind(name) + .fetch_optional(pool) + .await +} + +pub async fn list_all(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, Team>("SELECT * FROM teams ORDER BY name") + .fetch_all(pool) + .await +} + +pub async fn delete(pool: &PgPool, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM teams WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn add_member( + pool: &PgPool, + team_id: Uuid, + user_id: Uuid, + role: &str, +) -> Result { + sqlx::query_as::<_, TeamMembership>( + r" + INSERT INTO team_memberships (team_id, user_id, role) + VALUES ($1, $2, $3) + RETURNING * + ", + ) + .bind(team_id) + .bind(user_id) + .bind(role) + .fetch_one(pool) + .await +} + +pub async fn update_member_role( + pool: &PgPool, + team_id: Uuid, + user_id: Uuid, + role: &str, +) -> Result { + sqlx::query_as::<_, TeamMembership>( + r" + UPDATE team_memberships SET role = $3 + WHERE team_id = $1 AND user_id = $2 + RETURNING * + ", + ) + .bind(team_id) + .bind(user_id) + .bind(role) + .fetch_one(pool) + .await +} + +pub async fn find_membership( + pool: &PgPool, + team_id: Uuid, + user_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, TeamMembership>( + "SELECT * FROM team_memberships WHERE team_id = $1 AND user_id = $2", + ) + .bind(team_id) + .bind(user_id) + .fetch_optional(pool) + .await +} + +pub async fn count_owners(pool: &PgPool, team_id: Uuid) -> Result { + let (count,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM team_memberships WHERE team_id = $1 AND role = 'owner'", + ) + .bind(team_id) + .fetch_one(pool) + .await?; + Ok(count) +} + +pub async fn remove_member( + pool: &PgPool, + team_id: Uuid, + user_id: Uuid, +) -> Result { + let result = sqlx::query("DELETE FROM team_memberships WHERE team_id = $1 AND user_id = $2") + .bind(team_id) + .bind(user_id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn list_members( + pool: &PgPool, + team_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, TeamMembership>( + "SELECT * FROM team_memberships WHERE team_id = $1 ORDER BY created_at", + ) + .bind(team_id) + .fetch_all(pool) + .await +} + +pub async fn list_teams_for_user(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Team>( + r" + SELECT t.* FROM teams t + INNER JOIN team_memberships tm ON t.id = tm.team_id + WHERE tm.user_id = $1 + ORDER BY t.name + ", + ) + .bind(user_id) + .fetch_all(pool) + .await +} + +pub async fn get_team_ids_for_user(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + let rows: Vec<(Uuid,)> = + sqlx::query_as("SELECT team_id FROM team_memberships WHERE user_id = $1") + .bind(user_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|(id,)| id).collect()) +} + +pub async fn is_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> Result { + let row: (bool,) = sqlx::query_as( + "SELECT EXISTS(SELECT 1 FROM team_memberships WHERE team_id = $1 AND user_id = $2)", + ) + .bind(team_id) + .bind(user_id) + .fetch_one(pool) + .await?; + Ok(row.0) +} diff --git a/crates/funnel-server/src/db/tunnel_sessions.rs b/crates/funnel-server/src/db/tunnel_sessions.rs new file mode 100644 index 0000000..791403f --- /dev/null +++ b/crates/funnel-server/src/db/tunnel_sessions.rs @@ -0,0 +1,159 @@ +use chrono::{DateTime, Utc}; +use ipnetwork::IpNetwork; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct TunnelSession { + pub id: Uuid, + pub user_id: Uuid, + pub tunnel_id: String, + #[schema(value_type = Option)] + pub client_ip: Option, + pub connected_at: DateTime, + pub disconnected_at: Option>, + pub bytes_in: i64, + pub bytes_out: i64, + pub requests: i64, +} + +pub async fn create( + pool: &PgPool, + user_id: Uuid, + tunnel_id: &str, + client_ip: Option, +) -> Result { + sqlx::query_as::<_, TunnelSession>( + r" + INSERT INTO tunnel_sessions (user_id, tunnel_id, client_ip) + VALUES ($1, $2, $3) + RETURNING * + ", + ) + .bind(user_id) + .bind(tunnel_id) + .bind(client_ip) + .fetch_one(pool) + .await +} + +pub async fn disconnect( + pool: &PgPool, + session_id: Uuid, + bytes_in: i64, + bytes_out: i64, + requests: i64, +) -> Result { + let result = sqlx::query( + r" + UPDATE tunnel_sessions + SET disconnected_at = now(), bytes_in = $2, bytes_out = $3, requests = $4 + WHERE id = $1 AND disconnected_at IS NULL + ", + ) + .bind(session_id) + .bind(bytes_in) + .bind(bytes_out) + .bind(requests) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + limit: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, TunnelSession>( + r" + SELECT * FROM tunnel_sessions + WHERE user_id = $1 + ORDER BY connected_at DESC + LIMIT $2 + ", + ) + .bind(user_id) + .bind(limit) + .fetch_all(pool) + .await +} + +#[allow(dead_code)] +pub async fn list_active(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, TunnelSession>( + "SELECT * FROM tunnel_sessions WHERE disconnected_at IS NULL ORDER BY connected_at DESC", + ) + .fetch_all(pool) + .await +} + +pub async fn list_all(pool: &PgPool, limit: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, TunnelSession>( + "SELECT * FROM tunnel_sessions ORDER BY connected_at DESC LIMIT $1", + ) + .bind(limit) + .fetch_all(pool) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::users; + + type TestResult = Result<(), Box>; + + async fn setup_user(pool: &PgPool) -> Result { + users::create( + pool, + users::NewUser { + email: format!("session-test-{}@example.com", Uuid::now_v7()), + name: Some("Session Test".into()), + avatar_url: None, + }, + ) + .await + } + + #[tokio::test] + #[ignore = "requires database"] + async fn create_and_disconnect_session() -> TestResult { + let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; + + let user = setup_user(&pool).await?; + let ip: IpNetwork = "192.168.1.1".parse::()?.into(); + let session = create(&pool, user.id, "test-tunnel", Some(ip)).await?; + + assert_eq!(session.tunnel_id, "test-tunnel"); + assert!(session.disconnected_at.is_none()); + assert_eq!(session.bytes_in, 0); + assert!(session.client_ip.is_some()); + + let disconnected = disconnect(&pool, session.id, 1024, 2048, 10).await?; + assert!(disconnected); + + Ok(()) + } + + #[tokio::test] + #[ignore = "requires database"] + async fn list_active_sessions() -> TestResult { + let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; + + let user = setup_user(&pool).await?; + let s1 = create(&pool, user.id, "active-1", None).await?; + let s2 = create(&pool, user.id, "active-2", None).await?; + + disconnect(&pool, s1.id, 0, 0, 0).await?; + + let active = list_active(&pool).await?; + let active_ids: Vec = active.iter().map(|s| s.id).collect(); + assert!(active_ids.contains(&s2.id)); + assert!(!active_ids.contains(&s1.id)); + + Ok(()) + } +} diff --git a/crates/funnel-server/src/db/users.rs b/crates/funnel-server/src/db/users.rs new file mode 100644 index 0000000..c4e16cd --- /dev/null +++ b/crates/funnel-server/src/db/users.rs @@ -0,0 +1,149 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +pub const ROLE_ADMIN: &str = "admin"; +pub const ROLE_MEMBER: &str = "member"; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct User { + pub id: Uuid, + pub email: String, + pub name: Option, + pub avatar_url: Option, + pub role: String, + #[schema(value_type = Object)] + pub metadata: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deactivated_at: Option>, +} + +impl User { + pub fn is_admin(&self) -> bool { + self.role == ROLE_ADMIN + } + + pub const fn is_active(&self) -> bool { + self.deactivated_at.is_none() + } +} + +pub struct NewUser { + pub email: String, + pub name: Option, + pub avatar_url: Option, +} + +pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await +} + +pub async fn find_by_email(pool: &PgPool, email: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") + .bind(email) + .fetch_optional(pool) + .await +} + +pub async fn create(pool: &PgPool, new_user: NewUser) -> Result { + sqlx::query_as::<_, User>( + r" + INSERT INTO users (email, name, avatar_url) + VALUES ($1, $2, $3) + RETURNING * + ", + ) + .bind(&new_user.email) + .bind(&new_user.name) + .bind(&new_user.avatar_url) + .fetch_one(pool) + .await +} + +pub async fn update_profile( + pool: &PgPool, + id: Uuid, + name: Option<&str>, + avatar_url: Option<&str>, +) -> Result { + sqlx::query_as::<_, User>( + r" + UPDATE users SET name = $2, avatar_url = $3, updated_at = now() + WHERE id = $1 + RETURNING * + ", + ) + .bind(id) + .bind(name) + .bind(avatar_url) + .fetch_one(pool) + .await +} + +pub async fn update_role(pool: &PgPool, id: Uuid, role: &str) -> Result { + sqlx::query_as::<_, User>( + r" + UPDATE users SET role = $2, updated_at = now() + WHERE id = $1 + RETURNING * + ", + ) + .bind(id) + .bind(role) + .fetch_one(pool) + .await +} + +pub async fn deactivate(pool: &PgPool, id: Uuid) -> Result { + sqlx::query_as::<_, User>( + r" + UPDATE users SET deactivated_at = now(), updated_at = now() + WHERE id = $1 + RETURNING * + ", + ) + .bind(id) + .fetch_one(pool) + .await +} + +pub async fn reactivate(pool: &PgPool, id: Uuid) -> Result { + sqlx::query_as::<_, User>( + r" + UPDATE users SET deactivated_at = NULL, updated_at = now() + WHERE id = $1 + RETURNING * + ", + ) + .bind(id) + .fetch_one(pool) + .await +} + +pub async fn list_all(pool: &PgPool, limit: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY created_at DESC LIMIT $1") + .bind(limit) + .fetch_all(pool) + .await +} + +pub async fn count(pool: &PgPool) -> Result { + let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + .fetch_one(pool) + .await?; + Ok(row.0) +} + +pub async fn count_admins(pool: &PgPool) -> Result { + let row: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM users WHERE role = 'admin' AND deactivated_at IS NULL", + ) + .fetch_one(pool) + .await?; + Ok(row.0) +} diff --git a/crates/funnel-server/src/error.rs b/crates/funnel-server/src/error.rs new file mode 100644 index 0000000..567c828 --- /dev/null +++ b/crates/funnel-server/src/error.rs @@ -0,0 +1,81 @@ +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use funnel_core::tunnel::id::TunnelIdError; +use serde::Serialize; + +use crate::store::StoreError; + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ApiErrorBody { + error: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("tunnel not found: {0}")] + TunnelNotFound(String), + + #[error("not found: {0}")] + NotFound(String), + + #[error("invalid tunnel id: {0}")] + InvalidTunnelId(#[from] TunnelIdError), + + #[error("store error: {0}")] + Store(#[from] StoreError), + + #[error("unauthorized")] + Unauthorized, + + #[error("forbidden")] + Forbidden, + + #[error("bad request: {0}")] + BadRequest(String), + + #[error("internal error: {0}")] + Internal(#[from] anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), + Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()), + Self::TunnelNotFound(_) | Self::NotFound(_) => { + (StatusCode::NOT_FOUND, self.to_string()) + } + Self::InvalidTunnelId(_) | Self::BadRequest(_) => { + (StatusCode::BAD_REQUEST, self.to_string()) + } + Self::Store(e) => match e { + StoreError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), + StoreError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()), + StoreError::Database(db_err) => { + tracing::error!(error = %db_err, "database error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + StoreError::Other(msg) => { + tracing::error!(error = %msg, "store error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + }, + Self::Internal(e) => { + tracing::error!(error = %e, "internal error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + }; + + (status, Json(ApiErrorBody { error: message })).into_response() + } +} diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs new file mode 100644 index 0000000..ea92817 --- /dev/null +++ b/crates/funnel-server/src/main.rs @@ -0,0 +1,487 @@ +mod api; +mod app; +mod auth; +mod db; +mod error; +mod metrics; +mod openapi; +mod proxy; +mod quic; +mod store; +mod tls; +mod tunnel; + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; + +use clap::Parser; +use sqlx::postgres::PgPoolOptions; +use tracing_subscriber::EnvFilter; + +use auth::generic::{GenericProvider, GenericProviderConfig}; +use auth::github::{GitHubProvider, OAuthConfig}; +use auth::oauth::{OAuthProvider, OAuthState}; +use store::health::UptimeHealthReporter; +use tunnel::manager::TunnelManager; + +fn build_version() -> &'static str { + Box::leak( + format!( + "{} ({}) protocol v{}", + env!("CARGO_PKG_VERSION"), + env!("FUNNEL_GIT_HASH"), + funnel_core::protocol::PROTOCOL_VERSION, + ) + .into_boxed_str(), + ) +} + +#[derive(Parser)] +#[command( + name = "funnel-server", + about = "Funnel tunnel server", + version = build_version(), +)] +struct Cli { + /// Port to listen on + #[arg(short, long, default_value_t = 8080, env = "FUNNEL_PORT")] + port: u16, + + /// Host to bind to + #[arg(long, default_value = "0.0.0.0", env = "FUNNEL_HOST")] + host: String, + + /// QUIC port for tunnel connections + #[arg(long, default_value_t = 4433, env = "FUNNEL_QUIC_PORT")] + quic_port: u16, + + /// PostgreSQL connection URL + #[arg(long, env = "DATABASE_URL")] + database_url: Option, + + /// Maximum database connections + #[arg(long, default_value_t = 10, env = "FUNNEL_DB_MAX_CONNECTIONS")] + db_max_connections: u32, + + /// Enable TLS/HTTPS + #[arg(long, env = "FUNNEL_ENABLE_TLS")] + enable_tls: bool, + + /// TLS port + #[arg(long, default_value_t = 8443, env = "FUNNEL_TLS_PORT")] + tls_port: u16, + + /// Certificate storage directory + #[arg(long, default_value = "./certs", env = "FUNNEL_CERT_DIR")] + cert_dir: String, + + /// Let's Encrypt email for ACME registration + #[arg(long, env = "LETSENCRYPT_EMAIL")] + letsencrypt_email: Option, + + /// Path to DNS providers config JSON + #[arg(long, env = "DNS_PROVIDERS_CONFIG")] + dns_providers_config: Option, + + /// Use Let's Encrypt staging environment + #[arg(long, env = "FUNNEL_ACME_STAGING")] + acme_staging: bool, + + /// Create a seed API key at startup and print it to stdout + #[arg(long, env = "FUNNEL_SEED_API_KEY")] + seed_api_key: bool, + + /// Path to turso/libsql database file (used when no PostgreSQL URL is set) + #[arg(long, default_value = "./funnel.db", env = "FUNNEL_TURSO_DB_PATH")] + turso_db_path: String, + + /// Dump the OpenAPI spec as JSON to stdout and exit + #[arg(long)] + dump_openapi: bool, + + /// Email of the initial admin user (auto promotes on first login) + #[arg(long, env = "FUNNEL_INITIAL_ADMIN_EMAIL")] + initial_admin_email: Option, + + /// GitHub OAuth client ID + #[arg(long, env = "GITHUB_CLIENT_ID")] + github_client_id: Option, + + /// GitHub OAuth client secret + #[arg(long, env = "GITHUB_CLIENT_SECRET")] + github_client_secret: Option, + + /// Base URL of this server (required when OAuth is configured) + #[arg(long, env = "BASE_URL")] + base_url: Option, + + /// Generic OAuth provider name (e.g. "gitlab", "google") + #[arg(long, env = "OAUTH_PROVIDER_NAME")] + oauth_provider_name: Option, + + /// Generic OAuth client ID + #[arg(long, env = "OAUTH_CLIENT_ID")] + oauth_client_id: Option, + + /// Generic OAuth client secret + #[arg(long, env = "OAUTH_CLIENT_SECRET")] + oauth_client_secret: Option, + + /// Generic OAuth authorize URL + #[arg(long, env = "OAUTH_AUTHORIZE_URL")] + oauth_authorize_url: Option, + + /// Generic OAuth token exchange URL + #[arg(long, env = "OAUTH_TOKEN_URL")] + oauth_token_url: Option, + + /// Generic OAuth user info URL + #[arg(long, env = "OAUTH_USERINFO_URL")] + oauth_userinfo_url: Option, + + /// Generic OAuth scopes (space separated) + #[arg(long, env = "OAUTH_SCOPES", default_value = "openid email profile")] + oauth_scopes: String, + + /// JSON field for user ID in userinfo response + #[arg(long, env = "OAUTH_ID_FIELD", default_value = "sub")] + oauth_id_field: String, + + /// JSON field for email in userinfo response + #[arg(long, env = "OAUTH_EMAIL_FIELD", default_value = "email")] + oauth_email_field: String, + + /// JSON field for display name in userinfo response + #[arg(long, env = "OAUTH_NAME_FIELD", default_value = "name")] + oauth_name_field: String, + + /// JSON field for avatar URL in userinfo response + #[arg(long, env = "OAUTH_AVATAR_FIELD", default_value = "picture")] + oauth_avatar_field: String, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .map_err(|_| anyhow::anyhow!("failed to install default crypto provider"))?; + + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .json() + .init(); + + let cli = Cli::parse(); + + if cli.dump_openapi { + let spec = openapi::spec(); + let json = serde_json::to_string_pretty(&spec) + .map_err(|e| anyhow::anyhow!("failed to serialize openapi spec: {e}"))?; + print!("{json}"); + return Ok(()); + } + + tracing::info!(host = %cli.host, port = cli.port, quic_port = cli.quic_port, "starting funnel server"); + + let pool = if let Some(ref database_url) = cli.database_url { + let pool = PgPoolOptions::new() + .max_connections(cli.db_max_connections) + .connect(database_url) + .await?; + + tracing::info!("connected to database"); + + sqlx::migrate!("../../migrations").run(&pool).await?; + + tracing::info!("database migrations applied"); + + let (db_version,): (i32,) = + sqlx::query_as("SELECT version FROM schema_version ORDER BY applied_at DESC LIMIT 1") + .fetch_one(&pool) + .await?; + + let expected = i32::try_from(funnel_core::protocol::PROTOCOL_VERSION) + .map_err(|_| anyhow::anyhow!("protocol version exceeds i32 range"))?; + if db_version != expected { + anyhow::bail!( + "schema version mismatch: database is v{db_version} but server expects v{expected}" + ); + } + + tracing::info!(schema_version = db_version, "schema version verified"); + Some(pool) + } else { + tracing::info!(path = %cli.turso_db_path, "no database url configured, using turso"); + None + }; + + if cli.enable_tls { + run_with_tls(cli, pool).await + } else { + run_plain(cli, pool).await + } +} + +fn build_oauth_state(cli: &Cli) -> anyhow::Result>> { + let mut providers: HashMap> = HashMap::new(); + + if let (Some(client_id), Some(client_secret)) = + (&cli.github_client_id, &cli.github_client_secret) + { + let provider: Arc = Arc::new(GitHubProvider::new(OAuthConfig { + client_id: client_id.clone(), + client_secret: client_secret.clone(), + })); + providers.insert("github".to_string(), provider); + tracing::info!("github oauth configured"); + } + + if let ( + Some(name), + Some(client_id), + Some(client_secret), + Some(authorize_url), + Some(token_url), + Some(userinfo_url), + ) = ( + &cli.oauth_provider_name, + &cli.oauth_client_id, + &cli.oauth_client_secret, + &cli.oauth_authorize_url, + &cli.oauth_token_url, + &cli.oauth_userinfo_url, + ) { + let provider: Arc = + Arc::new(GenericProvider::new(GenericProviderConfig { + name: name.clone(), + client_id: client_id.clone(), + client_secret: client_secret.clone(), + authorize_url: authorize_url.clone(), + token_url: token_url.clone(), + userinfo_url: userinfo_url.clone(), + scopes: cli.oauth_scopes.clone(), + id_field: cli.oauth_id_field.clone(), + email_field: cli.oauth_email_field.clone(), + name_field: cli.oauth_name_field.clone(), + avatar_field: cli.oauth_avatar_field.clone(), + })); + providers.insert(name.clone(), provider); + tracing::info!(provider = %name, "generic oauth provider configured"); + } + + if providers.is_empty() { + return Ok(None); + } + + let base_url = cli + .base_url + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--base-url is required when OAuth is configured"))? + .trim_end_matches('/') + .to_string(); + + Ok(Some(Arc::new(OAuthState::new(providers, base_url)))) +} + +async fn build_state( + pool: Option, + turso_db_path: &str, + is_tls: bool, + oauth_state: Option>, + initial_admin_email: Option, + quic_port: u16, +) -> anyhow::Result> { + let tunnels = Arc::new(TunnelManager::new()); + + if let Some(pool) = pool { + Ok(Arc::new(app::AppState { + tunnels, + api_keys: Arc::new(store::pg::api_key_store::PgApiKeyStore::new(pool.clone())), + users: Arc::new(store::pg::user_store::PgUserStore::new(pool.clone())), + accounts: Arc::new(store::pg::account_store::PgAccountStore::new(pool.clone())), + sessions: Arc::new(store::pg::session_recorder::PgSessionRecorder::new( + pool.clone(), + )), + teams: Arc::new(store::pg::team_store::PgTeamStore::new(pool)), + health: Arc::new(UptimeHealthReporter::new()), + is_tls, + oauth_state, + initial_admin_email, + quic_port, + })) + } else { + let db = store::turso::open(turso_db_path) + .await + .map_err(|e| anyhow::anyhow!("failed to open turso database: {e}"))?; + + tracing::info!("turso database ready"); + + Ok(Arc::new(app::AppState { + tunnels, + api_keys: Arc::new(store::turso::api_key_store::TursoApiKeyStore::new( + Arc::clone(&db), + )), + users: Arc::new(store::turso::user_store::TursoUserStore::new(Arc::clone( + &db, + ))), + accounts: Arc::new(store::turso::account_store::TursoAccountStore::new( + Arc::clone(&db), + )), + sessions: Arc::new(store::turso::session_recorder::TursoSessionRecorder::new( + Arc::clone(&db), + )), + teams: Arc::new(store::turso::team_store::TursoTeamStore::new(db)), + health: Arc::new(UptimeHealthReporter::new()), + is_tls, + oauth_state, + initial_admin_email, + quic_port, + })) + } +} + +async fn run_plain(cli: Cli, pool: Option) -> anyhow::Result<()> { + let metrics_handle = metrics::setup()?; + let oauth_state = build_oauth_state(&cli)?; + let initial_admin_email = cli.initial_admin_email.clone(); + let state = build_state( + pool, + &cli.turso_db_path, + false, + oauth_state, + initial_admin_email, + cli.quic_port, + ) + .await?; + let router = app::build_router(Arc::clone(&state), metrics_handle); + + if cli.seed_api_key { + create_seed_key(&state).await?; + } + + let quic_handle = quic::config::spawn_listener(&cli.host, cli.quic_port, Arc::clone(&state))?; + + let addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?; + let listener = tokio::net::TcpListener::bind(addr).await?; + + tracing::info!(addr = %addr, "http server listening"); + + let http_server = axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ); + + tokio::select! { + res = http_server => { res?; } + _ = quic_handle => {} + }; + + Ok(()) +} + +async fn create_seed_key(state: &Arc) -> anyhow::Result<()> { + use crate::db::users::NewUser; + + let seed_email = "system@funnel.local"; + + // find or create the seed user + let user = match state.users.find_by_email(seed_email).await { + Ok(Some(u)) => u, + Ok(None) => { + let u = state + .users + .create(NewUser { + email: seed_email.to_string(), + name: Some("System".to_string()), + avatar_url: None, + }) + .await + .map_err(|e| anyhow::anyhow!("failed to create seed user: {e}"))?; + + // promote to admin + state + .users + .update_role(u.id, "admin") + .await + .map_err(|e| anyhow::anyhow!("failed to promote seed user: {e}"))? + } + Err(e) => anyhow::bail!("failed to look up seed user: {e}"), + }; + + let scopes = db::api_keys::default_scopes(); + match state.api_keys.create(user.id, "seed", &scopes, None).await { + Ok((plaintext, _)) => { + tracing::info!(seed_api_key = %plaintext, "seed api key created"); + } + Err(store::StoreError::Conflict(_)) => { + tracing::info!("seed key already exists, skipping"); + } + Err(e) => anyhow::bail!("failed to create seed api key: {e}"), + } + + Ok(()) +} + +async fn run_with_tls(cli: Cli, pool: Option) -> anyhow::Result<()> { + let email = cli + .letsencrypt_email + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--letsencrypt-email required when TLS is enabled"))?; + let config_path = cli + .dns_providers_config + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--dns-providers-config required when TLS is enabled"))?; + + let rustls_config = tls::setup( + Path::new(&cli.cert_dir), + Path::new(config_path), + email, + cli.acme_staging, + ) + .await?; + + let metrics_handle = metrics::setup()?; + let oauth_state = build_oauth_state(&cli)?; + let initial_admin_email = cli.initial_admin_email.clone(); + let state = build_state( + pool, + &cli.turso_db_path, + true, + oauth_state, + initial_admin_email, + cli.quic_port, + ) + .await?; + let router = app::build_router(Arc::clone(&state), metrics_handle); + + if cli.seed_api_key { + create_seed_key(&state).await?; + } + + let quic_handle = quic::config::spawn_listener(&cli.host, cli.quic_port, Arc::clone(&state))?; + + let tls_addr: SocketAddr = format!("{}:{}", cli.host, cli.tls_port).parse()?; + let http_addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?; + + tracing::info!(tls_addr = %tls_addr, http_addr = %http_addr, "server listening with TLS"); + + let https_server = axum_server::bind_rustls(tls_addr, rustls_config) + .serve(router.into_make_service_with_connect_info::()); + + let redirect_router = tls::redirect::router(cli.tls_port); + let http_listener = tokio::net::TcpListener::bind(http_addr).await?; + let http_server = axum::serve(http_listener, redirect_router.into_make_service()); + + tokio::select! { + res = https_server => { res?; } + res = http_server => { res?; } + _ = quic_handle => {} + }; + + Ok(()) +} diff --git a/crates/funnel-server/src/metrics.rs b/crates/funnel-server/src/metrics.rs new file mode 100644 index 0000000..19ec765 --- /dev/null +++ b/crates/funnel-server/src/metrics.rs @@ -0,0 +1,19 @@ +use axum::http::StatusCode; +use axum::response::IntoResponse; +use metrics_exporter_prometheus::PrometheusHandle; + +pub fn setup() -> anyhow::Result { + let builder = metrics_exporter_prometheus::PrometheusBuilder::new(); + Ok(builder.install_recorder()?) +} + +pub async fn handler( + axum::extract::State(handle): axum::extract::State, +) -> impl IntoResponse { + let body = handle.render(); + ( + StatusCode::OK, + [("content-type", "text/plain; version=0.0.4; charset=utf-8")], + body, + ) +} diff --git a/crates/funnel-server/src/openapi.rs b/crates/funnel-server/src/openapi.rs new file mode 100644 index 0000000..4c04981 --- /dev/null +++ b/crates/funnel-server/src/openapi.rs @@ -0,0 +1,99 @@ +use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme}; +use utoipa::{Modify, OpenApi}; + +#[derive(OpenApi)] +#[openapi( + info( + title = "Funnel API", + description = "Funnel tunnel server REST API", + version = env!("CARGO_PKG_VERSION"), + license(name = "MIT"), + ), + servers( + (url = "/api/v1", description = "API v1"), + ), + paths( + crate::api::health::handler, + crate::api::info::handler, + crate::api::tunnels::list, + crate::api::tunnels::get_tunnel, + crate::api::tunnels::delete, + crate::api::keys::list, + crate::api::keys::create, + crate::api::keys::revoke, + crate::api::me::handler, + crate::api::accounts::list, + crate::api::sessions::list, + crate::api::users::list, + crate::api::users::set_role, + crate::api::users::deactivate, + crate::api::users::reactivate, + crate::api::teams::list, + crate::api::teams::create, + crate::api::teams::delete, + crate::api::teams::list_members, + crate::api::teams::add_member, + crate::api::teams::remove_member, + crate::api::teams::set_member_role, + ), + components( + schemas( + crate::api::health::HealthResponse, + funnel_core::api::ServerInfo, + crate::tunnel::manager::TunnelInfo, + crate::tunnel::stats::TunnelStatsSnapshot, + crate::db::api_keys::ApiKeyView, + crate::api::keys::CreateKeyRequest, + crate::api::keys::CreateKeyResponse, + crate::db::users::User, + crate::db::teams::Team, + crate::db::teams::TeamMembership, + crate::db::tunnel_sessions::TunnelSession, + crate::api::accounts::AccountView, + crate::error::ApiErrorBody, + crate::api::users::SetRoleRequest, + crate::api::teams::CreateTeamRequest, + crate::api::teams::AddMemberRequest, + crate::api::teams::SetRoleRequest, + ) + ), + modifiers(&SecurityAddon), + tags( + (name = "Server", description = "Server health and info"), + (name = "Tunnels", description = "Tunnel management"), + (name = "API Keys", description = "API key management"), + (name = "Profile", description = "Current user profile and sessions"), + (name = "Users", description = "User management (admin only)"), + (name = "Teams", description = "Team management"), + ), +)] +struct ApiDoc; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "bearer", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + + // fumadocs-openapi uses x-displayName on tags for human-readable titles, + // falling back to idToTitle() which mangles acronyms like "API" -> "A P I" + if let Some(tags) = &mut openapi.tags { + for tag in tags { + let extensions = tag.extensions.get_or_insert_with(Default::default); + extensions.insert("x-displayName".to_string(), serde_json::json!(tag.name)); + } + } + } +} + +pub fn spec() -> utoipa::openapi::OpenApi { + ApiDoc::openapi() +} + +pub async fn json_handler() -> axum::Json { + axum::Json(spec()) +} diff --git a/crates/funnel-server/src/proxy/headers.rs b/crates/funnel-server/src/proxy/headers.rs new file mode 100644 index 0000000..3411d50 --- /dev/null +++ b/crates/funnel-server/src/proxy/headers.rs @@ -0,0 +1,111 @@ +use std::collections::HashMap; +use std::net::SocketAddr; + +use axum::http::HeaderMap; + +/// copy request headers and add standard proxy headers (X-Forwarded-For, etc). +pub fn prepare_forwarding_headers( + original: &HeaderMap, + host: &str, + remote_addr: SocketAddr, + is_tls: bool, +) -> HashMap> { + let mut headers: HashMap> = HashMap::new(); + + for (name, value) in original { + let key = name.as_str().to_string(); + let val = value.to_str().unwrap_or("").to_string(); + headers.entry(key).or_default().push(val); + } + + let client_ip = remote_addr.ip().to_string(); + + // append to existing X-Forwarded-For or create new + match headers.get("x-forwarded-for") { + Some(existing) if !existing.is_empty() => { + let all = existing.join(", "); + let combined = format!("{all}, {client_ip}"); + headers.insert("x-forwarded-for".to_string(), vec![combined]); + } + _ => { + headers.insert("x-forwarded-for".to_string(), vec![client_ip.clone()]); + } + } + + if !host.is_empty() { + headers.insert("x-forwarded-host".to_string(), vec![host.to_string()]); + } + + let proto = if is_tls { "https" } else { "http" }; + headers.insert("x-forwarded-proto".to_string(), vec![proto.to_string()]); + headers.insert("x-real-ip".to_string(), vec![client_ip]); + + headers +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + use std::net::{IpAddr, Ipv4Addr}; + + fn test_addr() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 54321) + } + + #[test] + fn adds_forwarding_headers() { + let headers = HeaderMap::new(); + let result = + prepare_forwarding_headers(&headers, "my-tunnel.example.com", test_addr(), false); + + assert_eq!(result["x-forwarded-for"], vec!["192.168.1.100"]); + assert_eq!(result["x-forwarded-host"], vec!["my-tunnel.example.com"]); + assert_eq!(result["x-forwarded-proto"], vec!["http"]); + assert_eq!(result["x-real-ip"], vec!["192.168.1.100"]); + } + + #[test] + fn appends_to_existing_forwarded_for() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.1")); + + let result = prepare_forwarding_headers(&headers, "t.example.com", test_addr(), false); + + assert_eq!(result["x-forwarded-for"], vec!["10.0.0.1, 192.168.1.100"]); + } + + #[test] + fn appends_to_multi_value_forwarded_for() { + let mut headers = HeaderMap::new(); + headers.append("x-forwarded-for", HeaderValue::from_static("10.0.0.1")); + headers.append("x-forwarded-for", HeaderValue::from_static("172.16.0.1")); + + let result = prepare_forwarding_headers(&headers, "t.example.com", test_addr(), false); + + assert_eq!( + result["x-forwarded-for"], + vec!["10.0.0.1, 172.16.0.1, 192.168.1.100"] + ); + } + + #[test] + fn sets_https_proto_when_tls() { + let headers = HeaderMap::new(); + let result = prepare_forwarding_headers(&headers, "t.example.com", test_addr(), true); + + assert_eq!(result["x-forwarded-proto"], vec!["https"]); + } + + #[test] + fn copies_original_headers() { + let mut headers = HeaderMap::new(); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers.insert("authorization", HeaderValue::from_static("Bearer token123")); + + let result = prepare_forwarding_headers(&headers, "t.example.com", test_addr(), false); + + assert_eq!(result["content-type"], vec!["application/json"]); + assert_eq!(result["authorization"], vec!["Bearer token123"]); + } +} diff --git a/crates/funnel-server/src/proxy/mod.rs b/crates/funnel-server/src/proxy/mod.rs new file mode 100644 index 0000000..9fd8957 --- /dev/null +++ b/crates/funnel-server/src/proxy/mod.rs @@ -0,0 +1,2 @@ +pub mod headers; +pub mod router; diff --git a/crates/funnel-server/src/proxy/router.rs b/crates/funnel-server/src/proxy/router.rs new file mode 100644 index 0000000..aea48d2 --- /dev/null +++ b/crates/funnel-server/src/proxy/router.rs @@ -0,0 +1,291 @@ +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{Request, Response, StatusCode}; +use hyper_util::rt::TokioIo; +use tokio::io; +use tokio_util::io::ReaderStream; + +use funnel_core::protocol::request::{self as proto, RequestMeta, ResponseMeta}; +use funnel_core::tunnel::id::TunnelId; + +use super::headers::prepare_forwarding_headers; +use crate::app::AppState; +use crate::tunnel::connection::{ActiveTunnel, CountedRecvStream, SendError}; + +fn is_upgrade_request(req: &Request) -> bool { + req.headers() + .get("connection") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("upgrade")) +} + +/// axum fallback handler that routes requests based on subdomain. +/// requests to `{tunnel_id}.{base_domain}` are forwarded through the matching tunnel. +pub async fn handle_tunnel_request( + State(state): State>, + mut request: Request, +) -> Response { + let host = request + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + // use pre-resolved tunnel id from middleware when available + let tunnel_id = request + .extensions() + .get::() + .cloned() + .or_else(|| extract_subdomain(&host).and_then(|s| TunnelId::new(s).ok())); + + let Some(tunnel_id) = tunnel_id else { + return not_found("tunnel not found"); + }; + + let Some(tunnel) = state.tunnels.get(&tunnel_id) else { + return not_found("tunnel not found"); + }; + + if is_upgrade_request(&request) { + return handle_upgrade(&mut request, &host, &state, &tunnel).await; + } + + let method = request.method().to_string(); + let path = request.uri().to_string(); + + let fallback_addr = std::net::SocketAddr::from(([0, 0, 0, 0], 0)); + let remote_addr = request + .extensions() + .get::>() + .map_or(fallback_addr, |ci| ci.0); + + let headers = prepare_forwarding_headers(request.headers(), &host, remote_addr, state.is_tls); + + let meta = RequestMeta { + method, + path, + headers, + upgrade: false, + }; + + let body = request.into_body(); + + match tunnel.send_request(meta, body).await { + Ok((resp_meta, recv_stream)) => build_response(&resp_meta, recv_stream), + Err(SendError::Timeout) => error_response(StatusCode::GATEWAY_TIMEOUT, "request timed out"), + Err(SendError::ReadBody(e)) => { + tracing::debug!(error = %e, "failed to read request body"); + error_response(StatusCode::BAD_REQUEST, "failed to read request body") + } + Err(e) => { + tracing::debug!(error = %e, "tunnel request failed"); + error_response(StatusCode::BAD_GATEWAY, "tunnel error") + } + } +} + +async fn handle_upgrade( + request: &mut Request, + host: &str, + state: &AppState, + tunnel: &ActiveTunnel, +) -> Response { + let method = request.method().to_string(); + let path = request.uri().to_string(); + + let fallback_addr = std::net::SocketAddr::from(([0, 0, 0, 0], 0)); + let remote_addr = request + .extensions() + .get::>() + .map_or(fallback_addr, |ci| ci.0); + + let headers = prepare_forwarding_headers(request.headers(), host, remote_addr, state.is_tls); + + let meta = RequestMeta { + method, + path, + headers, + upgrade: true, + }; + + // capture the upgrade future before we consume anything + let on_upgrade = hyper::upgrade::on(request); + + let (resp_meta, quic_send, quic_recv) = match tunnel.send_upgrade_request(meta).await { + Ok(v) => v, + Err(SendError::Timeout) => { + return error_response(StatusCode::GATEWAY_TIMEOUT, "request timed out"); + } + Err(e) => { + tracing::debug!(error = %e, "upgrade tunnel request failed"); + return error_response(StatusCode::BAD_GATEWAY, "tunnel error"); + } + }; + + let status = resp_meta.http_status().unwrap_or(StatusCode::BAD_GATEWAY); + + if status != StatusCode::SWITCHING_PROTOCOLS { + // backend rejected the upgrade, return the response as is + let mut builder = Response::builder().status(status); + if let Some(h) = builder.headers_mut() { + *h = proto::to_header_map(&resp_meta.headers); + } + return builder.body(Body::empty()).unwrap_or_else(|_| { + error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error") + }); + } + + // build the 101 response for the browser + let mut builder = Response::builder().status(StatusCode::SWITCHING_PROTOCOLS); + if let Some(h) = builder.headers_mut() { + *h = proto::to_header_map(&resp_meta.headers); + } + + // spawn a task to pipe bytes between the browser upgrade and the quic streams + tokio::spawn(async move { + let upgraded = match on_upgrade.await { + Ok(u) => u, + Err(e) => { + tracing::debug!(error = %e, "upgrade handshake failed"); + return; + } + }; + + let upgraded_io = TokioIo::new(upgraded); + let (mut browser_read, mut browser_write) = io::split(upgraded_io); + let (mut quic_send, mut quic_recv) = (quic_send, quic_recv); + + let client_to_quic = io::copy(&mut browser_read, &mut quic_send); + let quic_to_client = io::copy(&mut quic_recv, &mut browser_write); + + tokio::select! { + r = client_to_quic => { + if let Err(e) = r { + tracing::debug!(error = %e, "browser to quic copy ended"); + } + } + r = quic_to_client => { + if let Err(e) = r { + tracing::debug!(error = %e, "quic to browser copy ended"); + } + } + } + }); + + builder + .body(Body::empty()) + .unwrap_or_else(|_| error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")) +} + +pub fn extract_subdomain(host: &str) -> Option<&str> { + let host = host.split(':').next().unwrap_or(host); + + let dot = host.find('.')?; + if dot == 0 { + return None; + } + + let subdomain = &host[..dot]; + let rest = &host[dot + 1..]; + + if rest.is_empty() { + None + } else { + Some(subdomain) + } +} + +fn build_response(meta: &ResponseMeta, recv: CountedRecvStream) -> Response { + let status = meta.http_status().unwrap_or(StatusCode::BAD_GATEWAY); + let mut builder = Response::builder().status(status); + + if let Some(headers) = builder.headers_mut() { + *headers = proto::to_header_map(&meta.headers); + } + + let body_stream = ReaderStream::new(recv); + let body = Body::from_stream(body_stream); + + builder + .body(body) + .unwrap_or_else(|_| error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")) +} + +fn not_found(msg: &str) -> Response { + error_response(StatusCode::NOT_FOUND, msg) +} + +fn error_response(status: StatusCode, msg: &str) -> Response { + Response::builder() + .status(status) + .header("content-type", "text/plain") + .body(Body::from(msg.to_string())) + .unwrap_or_else(|_| Response::new(Body::from(msg.to_string()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_subdomain_basic() { + assert_eq!( + extract_subdomain("my-tunnel.example.com"), + Some("my-tunnel") + ); + assert_eq!(extract_subdomain("abc.example.com"), Some("abc")); + } + + #[test] + fn extract_subdomain_with_port() { + assert_eq!( + extract_subdomain("my-tunnel.example.com:8080"), + Some("my-tunnel") + ); + } + + #[test] + fn extract_subdomain_single_label() { + assert_eq!(extract_subdomain("abc.localhost"), Some("abc")); + assert_eq!(extract_subdomain("abc.localhost:8080"), Some("abc")); + } + + #[test] + fn extract_subdomain_no_subdomain() { + assert_eq!(extract_subdomain("localhost"), None); + assert_eq!(extract_subdomain("localhost:8080"), None); + } + + #[test] + fn extract_subdomain_empty_or_dot_prefix() { + assert_eq!(extract_subdomain(""), None); + assert_eq!(extract_subdomain(".example.com"), None); + } + + #[test] + fn extract_subdomain_deep_nesting() { + assert_eq!(extract_subdomain("a.b.example.com"), Some("a")); + } + + type TestResult = Result<(), Box>; + + #[test] + fn detects_upgrade_request() -> TestResult { + let req = Request::builder() + .header("connection", "Upgrade") + .header("upgrade", "websocket") + .body(Body::empty())?; + assert!(is_upgrade_request(&req)); + Ok(()) + } + + #[test] + fn normal_request_is_not_upgrade() -> TestResult { + let req = Request::builder().body(Body::empty())?; + assert!(!is_upgrade_request(&req)); + Ok(()) + } +} diff --git a/crates/funnel-server/src/quic/config.rs b/crates/funnel-server/src/quic/config.rs new file mode 100644 index 0000000..6ae0ea1 --- /dev/null +++ b/crates/funnel-server/src/quic/config.rs @@ -0,0 +1,78 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use funnel_core::protocol::QUIC_ALPN; + +use crate::app::AppState; + +const KEEP_ALIVE: Duration = Duration::from_secs(15); +const IDLE_TIMEOUT: Duration = Duration::from_mins(1); + +pub fn spawn_listener( + host: &str, + port: u16, + state: Arc, +) -> anyhow::Result> { + let server_config = build_self_signed_config()?; + let endpoint = build_endpoint(server_config, host, port)?; + + Ok(tokio::spawn(async move { + if let Err(e) = super::listener::run(endpoint, state).await { + tracing::error!(error = %e, "quic listener failed"); + } + })) +} + +fn build_self_signed_config() -> anyhow::Result { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])?; + let cert_der = cert.cert.der().clone(); + let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()); + + let mut server_crypto = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![cert_der], + rustls::pki_types::PrivateKeyDer::Pkcs8(key_der), + )?; + + server_crypto.alpn_protocols = vec![QUIC_ALPN.to_vec()]; + + let mut server_config = quinn::ServerConfig::with_crypto(Arc::new( + quinn::crypto::rustls::QuicServerConfig::try_from(server_crypto)?, + )); + + let mut transport = quinn::TransportConfig::default(); + transport.keep_alive_interval(Some(KEEP_ALIVE)); + transport.max_idle_timeout(Some(quinn::IdleTimeout::try_from(IDLE_TIMEOUT)?)); + + server_config.transport_config(Arc::new(transport)); + + Ok(server_config) +} + +/// build a quic endpoint, using a dual stack ipv6 socket when the host is a +/// wildcard address so that both ipv4 and ipv6 clients can connect. +fn build_endpoint( + config: quinn::ServerConfig, + host: &str, + port: u16, +) -> anyhow::Result { + let addr: SocketAddr = format!("{host}:{port}").parse()?; + + // when binding to 0.0.0.0, also bind the ipv6 wildcard so dual stack works + if addr == SocketAddr::from(([0, 0, 0, 0], port)) { + let v6_addr: SocketAddr = format!("[::]:{port}").parse()?; + let socket = std::net::UdpSocket::bind(v6_addr)?; + let runtime = quinn::default_runtime() + .ok_or_else(|| anyhow::anyhow!("no async runtime available for quic endpoint"))?; + Ok(quinn::Endpoint::new( + quinn::EndpointConfig::default(), + Some(config), + socket, + runtime, + )?) + } else { + Ok(quinn::Endpoint::server(config, addr)?) + } +} diff --git a/crates/funnel-server/src/quic/handler.rs b/crates/funnel-server/src/quic/handler.rs new file mode 100644 index 0000000..7a0b48c --- /dev/null +++ b/crates/funnel-server/src/quic/handler.rs @@ -0,0 +1,194 @@ +use std::sync::Arc; + +use crate::app::AppState; +use crate::tunnel::connection::ActiveTunnel; +use funnel_core::protocol::PROTOCOL_VERSION; +use funnel_core::protocol::frame; +use funnel_core::protocol::handshake::{Handshake, HandshakeResponse}; + +#[derive(Debug, thiserror::Error)] +pub enum ConnectionError { + #[error("connection error: {0}")] + Connection(#[from] quinn::ConnectionError), + + #[error("frame error: {0}")] + Frame(#[from] frame::FrameError), + + #[error("tunnel id conflict: {0}")] + Conflict(String), + + #[error("auth error: {0}")] + Auth(String), +} + +pub async fn handle_connection( + conn: quinn::Connection, + state: Arc, +) -> Result<(), ConnectionError> { + let (mut send, mut recv) = conn + .accept_bi() + .await + .map_err(ConnectionError::Connection)?; + + let handshake: Handshake = frame::read_meta(&mut recv).await?; + let tunnel_id = handshake.tunnel_id; + + match handshake.version { + Some(v) if v != PROTOCOL_VERSION => { + let resp = HandshakeResponse::rejected(format!( + "incompatible client version: got v{v}, server requires v{PROTOCOL_VERSION}" + )); + let _ = frame::write_meta(&mut send, &resp).await; + return Err(ConnectionError::Auth(format!( + "incompatible client version: v{v}" + ))); + } + None => { + let resp = HandshakeResponse::rejected("client too old, version field required"); + let _ = frame::write_meta(&mut send, &resp).await; + return Err(ConnectionError::Auth("client did not send version".into())); + } + _ => {} + } + + // validate auth token + let token = handshake + .token + .as_deref() + .ok_or_else(|| ConnectionError::Auth("missing token".into()))?; + + let api_key = state + .api_keys + .validate(token) + .await + .map_err(|e| ConnectionError::Auth(format!("validation failed: {e}")))? + .ok_or_else(|| ConnectionError::Auth("invalid token".into()))?; + + if !api_key.has_scope("tunnels") { + let resp = HandshakeResponse::rejected("token missing tunnels scope"); + let _ = frame::write_meta(&mut send, &resp).await; + return Err(ConnectionError::Auth("missing tunnels scope".into())); + } + + let user_id = api_key.user_id; + + // check user is active + let user = state + .users + .find_by_id(user_id) + .await + .map_err(|e| ConnectionError::Auth(format!("user lookup failed: {e}")))? + .ok_or_else(|| ConnectionError::Auth("user not found".into()))?; + + if !user.is_active() { + let resp = HandshakeResponse::rejected("user account is deactivated"); + let _ = frame::write_meta(&mut send, &resp).await; + return Err(ConnectionError::Auth("deactivated user".into())); + } + + // resolve team if specified + let team_id = if let Some(ref team_name) = handshake.team { + let team = state + .teams + .find_by_name(team_name) + .await + .map_err(|e| ConnectionError::Auth(format!("team lookup failed: {e}")))? + .ok_or_else(|| ConnectionError::Auth(format!("team not found: {team_name}")))?; + + let is_member = state + .teams + .is_member(team.id, user_id) + .await + .map_err(|e| ConnectionError::Auth(format!("membership check failed: {e}")))?; + + if !is_member { + let resp = HandshakeResponse::rejected(format!("not a member of team: {team_name}")); + let _ = frame::write_meta(&mut send, &resp).await; + return Err(ConnectionError::Auth(format!( + "not a member of team: {team_name}" + ))); + } + + Some(team.id) + } else { + None + }; + + let tunnel = Arc::new(ActiveTunnel::new( + tunnel_id.clone(), + conn.clone(), + user_id, + team_id, + )); + + if state + .tunnels + .insert(tunnel_id.clone(), Arc::clone(&tunnel)) + .is_err() + { + let resp = HandshakeResponse::rejected(format!("tunnel id already in use: {tunnel_id}")); + let _ = frame::write_meta(&mut send, &resp).await; + return Err(ConnectionError::Conflict(tunnel_id.to_string())); + } + + frame::write_meta(&mut send, &HandshakeResponse::ok()).await?; + + tracing::info!( + tunnel_id = %tunnel_id, + user_id = %user_id, + team_id = ?team_id, + "tunnel connected via quic" + ); + + metrics::gauge!("funnel_tunnels_active").increment(1.0); + metrics::counter!("funnel_tunnels_total").increment(1); + + let client_ip = conn.remote_address(); + let ip_network: Option = Some(client_ip.ip().into()); + + let session = state + .sessions + .record_connect(user_id, tunnel_id.as_ref(), ip_network) + .await + .ok(); + + // wait until the connection closes (control stream EOF or transport error) + let mut buf = [0u8; 1]; + tokio::select! { + _ = recv.read(&mut buf) => {}, + _ = conn.closed() => {}, + } + + let stats = tunnel.stats(); + state.tunnels.remove(&tunnel_id); + + if let Some(session) = session { + let _ = state + .sessions + .record_disconnect( + session.id, + i64::try_from(stats.bytes_in).unwrap_or(i64::MAX), + i64::try_from(stats.bytes_out).unwrap_or(i64::MAX), + i64::try_from(stats.requests).unwrap_or(i64::MAX), + ) + .await; + } + + let session_secs = tunnel.connected_at().elapsed().as_secs_f64(); + + metrics::gauge!("funnel_tunnels_active").decrement(1.0); + metrics::histogram!("funnel_tunnel_session_duration_seconds").record(session_secs); + + tracing::info!( + tunnel_id = %tunnel_id, + user_id = %user_id, + bytes_in = stats.bytes_in, + bytes_out = stats.bytes_out, + requests = stats.requests, + session_secs = session_secs, + remaining_tunnels = state.tunnels.count(), + "tunnel disconnected" + ); + + Ok(()) +} diff --git a/crates/funnel-server/src/quic/listener.rs b/crates/funnel-server/src/quic/listener.rs new file mode 100644 index 0000000..7caf56e --- /dev/null +++ b/crates/funnel-server/src/quic/listener.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use crate::app::AppState; + +use super::handler; + +pub async fn run(endpoint: quinn::Endpoint, state: Arc) -> anyhow::Result<()> { + tracing::info!( + addr = %endpoint.local_addr()?, + "quic endpoint listening" + ); + + while let Some(incoming) = endpoint.accept().await { + let state = Arc::clone(&state); + tokio::spawn(async move { + match incoming.await { + Ok(conn) => { + if let Err(e) = handler::handle_connection(conn, state).await { + tracing::debug!(error = %e, "connection ended"); + } + } + Err(e) => { + tracing::debug!(error = %e, "failed to accept connection"); + } + } + }); + } + + Ok(()) +} diff --git a/crates/funnel-server/src/quic/mod.rs b/crates/funnel-server/src/quic/mod.rs new file mode 100644 index 0000000..bd7bb29 --- /dev/null +++ b/crates/funnel-server/src/quic/mod.rs @@ -0,0 +1,3 @@ +pub mod config; +pub mod handler; +pub mod listener; diff --git a/crates/funnel-server/src/store/account_store.rs b/crates/funnel-server/src/store/account_store.rs new file mode 100644 index 0000000..05abf0d --- /dev/null +++ b/crates/funnel-server/src/store/account_store.rs @@ -0,0 +1,14 @@ +use uuid::Uuid; + +use super::{BoxFuture, StoreError}; +use crate::db::accounts::{Account, NewAccount}; + +pub trait AccountStore: Send + Sync { + fn find_by_provider( + &self, + provider: &str, + provider_account_id: &str, + ) -> BoxFuture<'_, Result, StoreError>>; + fn create(&self, new_account: NewAccount) -> BoxFuture<'_, Result>; + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>>; +} diff --git a/crates/funnel-server/src/store/api_key_store.rs b/crates/funnel-server/src/store/api_key_store.rs new file mode 100644 index 0000000..c3b8eba --- /dev/null +++ b/crates/funnel-server/src/store/api_key_store.rs @@ -0,0 +1,19 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use super::{BoxFuture, StoreError}; +use crate::db::api_keys::{ApiKey, ApiKeyView}; + +pub trait ApiKeyStore: Send + Sync { + fn create( + &self, + user_id: Uuid, + name: &str, + scopes: &serde_json::Value, + expires_at: Option>, + ) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>>; + fn validate(&self, plaintext: &str) -> BoxFuture<'_, Result, StoreError>>; + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>>; + fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result>; + fn revoke_by_name(&self, user_id: Uuid, name: &str) -> BoxFuture<'_, Result>; +} diff --git a/crates/funnel-server/src/store/health.rs b/crates/funnel-server/src/store/health.rs new file mode 100644 index 0000000..a044c7f --- /dev/null +++ b/crates/funnel-server/src/store/health.rs @@ -0,0 +1,28 @@ +use tokio::time::Instant; + +pub trait HealthReporter: Send + Sync { + fn status(&self) -> &'static str; + fn uptime_secs(&self) -> u64; +} + +pub struct UptimeHealthReporter { + start_time: Instant, +} + +impl UptimeHealthReporter { + pub fn new() -> Self { + Self { + start_time: Instant::now(), + } + } +} + +impl HealthReporter for UptimeHealthReporter { + fn status(&self) -> &'static str { + "healthy" + } + + fn uptime_secs(&self) -> u64 { + self.start_time.elapsed().as_secs() + } +} diff --git a/crates/funnel-server/src/store/mod.rs b/crates/funnel-server/src/store/mod.rs new file mode 100644 index 0000000..bbcf0d8 --- /dev/null +++ b/crates/funnel-server/src/store/mod.rs @@ -0,0 +1,54 @@ +pub mod account_store; +pub mod api_key_store; +pub mod health; +pub mod pg; +pub mod session_recorder; +pub mod team_store; +pub mod tunnel_registry; +pub mod turso; +pub mod user_store; + +use std::fmt; +use std::future::Future; +use std::pin::Pin; + +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +#[derive(Debug)] +pub enum StoreError { + Database(sqlx::Error), + NotFound, + Conflict(String), + Other(String), +} + +impl fmt::Display for StoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Database(e) => write!(f, "database error: {e}"), + Self::NotFound => write!(f, "not found"), + Self::Conflict(msg) => write!(f, "conflict: {msg}"), + Self::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for StoreError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Database(e) => Some(e), + _ => None, + } + } +} + +impl From for StoreError { + fn from(e: sqlx::Error) -> Self { + if let sqlx::Error::Database(ref db_err) = e + && db_err.is_unique_violation() + { + return Self::Conflict(db_err.message().to_string()); + } + Self::Database(e) + } +} diff --git a/crates/funnel-server/src/store/pg/account_store.rs b/crates/funnel-server/src/store/pg/account_store.rs new file mode 100644 index 0000000..dd8267a --- /dev/null +++ b/crates/funnel-server/src/store/pg/account_store.rs @@ -0,0 +1,38 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db::accounts::{self, Account, NewAccount}; +use crate::store::account_store::AccountStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct PgAccountStore { + pool: PgPool, +} + +impl PgAccountStore { + pub const fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl AccountStore for PgAccountStore { + fn find_by_provider( + &self, + provider: &str, + provider_account_id: &str, + ) -> BoxFuture<'_, Result, StoreError>> { + let provider = provider.to_string(); + let provider_account_id = provider_account_id.to_string(); + Box::pin(async move { + Ok(accounts::find_by_provider(&self.pool, &provider, &provider_account_id).await?) + }) + } + + fn create(&self, new_account: NewAccount) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(accounts::create(&self.pool, new_account).await?) }) + } + + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(accounts::list_for_user(&self.pool, user_id).await?) }) + } +} diff --git a/crates/funnel-server/src/store/pg/api_key_store.rs b/crates/funnel-server/src/store/pg/api_key_store.rs new file mode 100644 index 0000000..7694246 --- /dev/null +++ b/crates/funnel-server/src/store/pg/api_key_store.rs @@ -0,0 +1,56 @@ +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db::api_keys::{self, ApiKey, ApiKeyView}; +use crate::store::api_key_store::ApiKeyStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct PgApiKeyStore { + pool: PgPool, +} + +impl PgApiKeyStore { + pub const fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl ApiKeyStore for PgApiKeyStore { + fn create( + &self, + user_id: Uuid, + name: &str, + scopes: &serde_json::Value, + expires_at: Option>, + ) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>> { + let name = name.to_string(); + let scopes = scopes.clone(); + Box::pin(async move { + let (plaintext, key) = + api_keys::create(&self.pool, user_id, &name, &scopes, expires_at).await?; + Ok((plaintext, key.into())) + }) + } + + fn validate(&self, plaintext: &str) -> BoxFuture<'_, Result, StoreError>> { + let plaintext = plaintext.to_string(); + Box::pin(async move { Ok(api_keys::validate(&self.pool, &plaintext).await?) }) + } + + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let keys = api_keys::list_for_user(&self.pool, user_id).await?; + Ok(keys.into_iter().map(Into::into).collect()) + }) + } + + fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(api_keys::revoke(&self.pool, key_id, user_id).await?) }) + } + + fn revoke_by_name(&self, user_id: Uuid, name: &str) -> BoxFuture<'_, Result> { + let name = name.to_string(); + Box::pin(async move { Ok(api_keys::revoke_by_name(&self.pool, user_id, &name).await?) }) + } +} diff --git a/crates/funnel-server/src/store/pg/mod.rs b/crates/funnel-server/src/store/pg/mod.rs new file mode 100644 index 0000000..fa1817c --- /dev/null +++ b/crates/funnel-server/src/store/pg/mod.rs @@ -0,0 +1,5 @@ +pub mod account_store; +pub mod api_key_store; +pub mod session_recorder; +pub mod team_store; +pub mod user_store; diff --git a/crates/funnel-server/src/store/pg/session_recorder.rs b/crates/funnel-server/src/store/pg/session_recorder.rs new file mode 100644 index 0000000..597a0e3 --- /dev/null +++ b/crates/funnel-server/src/store/pg/session_recorder.rs @@ -0,0 +1,64 @@ +use ipnetwork::IpNetwork; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db::tunnel_sessions::{self, TunnelSession}; +use crate::store::session_recorder::SessionRecorder; +use crate::store::{BoxFuture, StoreError}; + +pub struct PgSessionRecorder { + pool: PgPool, +} + +impl PgSessionRecorder { + pub const fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl SessionRecorder for PgSessionRecorder { + fn record_connect( + &self, + user_id: Uuid, + tunnel_id: &str, + client_ip: Option, + ) -> BoxFuture<'_, Result> { + let tunnel_id = tunnel_id.to_string(); + Box::pin(async move { + Ok(tunnel_sessions::create(&self.pool, user_id, &tunnel_id, client_ip).await?) + }) + } + + fn record_disconnect( + &self, + session_id: Uuid, + bytes_in: i64, + bytes_out: i64, + requests: i64, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + Ok( + tunnel_sessions::disconnect(&self.pool, session_id, bytes_in, bytes_out, requests) + .await?, + ) + }) + } + + fn list_active(&self) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(tunnel_sessions::list_active(&self.pool).await?) }) + } + + fn list_for_user( + &self, + user_id: Uuid, + limit: i64, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin( + async move { Ok(tunnel_sessions::list_for_user(&self.pool, user_id, limit).await?) }, + ) + } + + fn list_all(&self, limit: i64) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(tunnel_sessions::list_all(&self.pool, limit).await?) }) + } +} diff --git a/crates/funnel-server/src/store/pg/team_store.rs b/crates/funnel-server/src/store/pg/team_store.rs new file mode 100644 index 0000000..d492d8c --- /dev/null +++ b/crates/funnel-server/src/store/pg/team_store.rs @@ -0,0 +1,101 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db::teams::{self, Team, TeamMembership}; +use crate::store::team_store::TeamStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct PgTeamStore { + pool: PgPool, +} + +impl PgTeamStore { + pub const fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl TeamStore for PgTeamStore { + fn create(&self, name: &str) -> BoxFuture<'_, Result> { + let name = name.to_string(); + Box::pin(async move { Ok(teams::create(&self.pool, &name).await?) }) + } + + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(teams::find_by_id(&self.pool, id).await?) }) + } + + fn find_by_name(&self, name: &str) -> BoxFuture<'_, Result, StoreError>> { + let name = name.to_string(); + Box::pin(async move { Ok(teams::find_by_name(&self.pool, &name).await?) }) + } + + fn list_all(&self) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(teams::list_all(&self.pool).await?) }) + } + + fn delete(&self, id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(teams::delete(&self.pool, id).await?) }) + } + + fn add_member( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> BoxFuture<'_, Result> { + let role = role.to_string(); + Box::pin(async move { Ok(teams::add_member(&self.pool, team_id, user_id, &role).await?) }) + } + + fn remove_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(teams::remove_member(&self.pool, team_id, user_id).await?) }) + } + + fn list_members( + &self, + team_id: Uuid, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(teams::list_members(&self.pool, team_id).await?) }) + } + + fn list_teams_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(teams::list_teams_for_user(&self.pool, user_id).await?) }) + } + + fn get_team_ids_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(teams::get_team_ids_for_user(&self.pool, user_id).await?) }) + } + + fn is_member(&self, team_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(teams::is_member(&self.pool, team_id, user_id).await?) }) + } + + fn update_member_role( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> BoxFuture<'_, Result> { + let role = role.to_string(); + Box::pin(async move { + Ok(teams::update_member_role(&self.pool, team_id, user_id, &role).await?) + }) + } + + fn find_membership( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(teams::find_membership(&self.pool, team_id, user_id).await?) }) + } + + fn count_owners(&self, team_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(teams::count_owners(&self.pool, team_id).await?) }) + } +} diff --git a/crates/funnel-server/src/store/pg/user_store.rs b/crates/funnel-server/src/store/pg/user_store.rs new file mode 100644 index 0000000..d0c499c --- /dev/null +++ b/crates/funnel-server/src/store/pg/user_store.rs @@ -0,0 +1,72 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db::users::{self, NewUser, User}; +use crate::store::user_store::UserStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct PgUserStore { + pool: PgPool, +} + +impl PgUserStore { + pub const fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl UserStore for PgUserStore { + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(users::find_by_id(&self.pool, id).await?) }) + } + + fn find_by_email(&self, email: &str) -> BoxFuture<'_, Result, StoreError>> { + let email = email.to_string(); + Box::pin(async move { Ok(users::find_by_email(&self.pool, &email).await?) }) + } + + fn create(&self, new_user: NewUser) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(users::create(&self.pool, new_user).await?) }) + } + + fn update_profile( + &self, + id: Uuid, + name: Option<&str>, + avatar_url: Option<&str>, + ) -> BoxFuture<'_, Result> { + let name = name.map(ToString::to_string); + let avatar_url = avatar_url.map(ToString::to_string); + Box::pin(async move { + Ok( + users::update_profile(&self.pool, id, name.as_deref(), avatar_url.as_deref()) + .await?, + ) + }) + } + + fn update_role(&self, id: Uuid, role: &str) -> BoxFuture<'_, Result> { + let role = role.to_string(); + Box::pin(async move { Ok(users::update_role(&self.pool, id, &role).await?) }) + } + + fn deactivate(&self, id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(users::deactivate(&self.pool, id).await?) }) + } + + fn reactivate(&self, id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(users::reactivate(&self.pool, id).await?) }) + } + + fn list_all(&self, limit: i64) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(users::list_all(&self.pool, limit).await?) }) + } + + fn count(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(users::count(&self.pool).await?) }) + } + + fn count_admins(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(users::count_admins(&self.pool).await?) }) + } +} diff --git a/crates/funnel-server/src/store/session_recorder.rs b/crates/funnel-server/src/store/session_recorder.rs new file mode 100644 index 0000000..44fe210 --- /dev/null +++ b/crates/funnel-server/src/store/session_recorder.rs @@ -0,0 +1,33 @@ +use ipnetwork::IpNetwork; +use uuid::Uuid; + +use super::{BoxFuture, StoreError}; +use crate::db::tunnel_sessions::TunnelSession; + +pub trait SessionRecorder: Send + Sync { + fn record_connect( + &self, + user_id: Uuid, + tunnel_id: &str, + client_ip: Option, + ) -> BoxFuture<'_, Result>; + + fn record_disconnect( + &self, + session_id: Uuid, + bytes_in: i64, + bytes_out: i64, + requests: i64, + ) -> BoxFuture<'_, Result>; + + #[allow(dead_code)] + fn list_active(&self) -> BoxFuture<'_, Result, StoreError>>; + + fn list_for_user( + &self, + user_id: Uuid, + limit: i64, + ) -> BoxFuture<'_, Result, StoreError>>; + + fn list_all(&self, limit: i64) -> BoxFuture<'_, Result, StoreError>>; +} diff --git a/crates/funnel-server/src/store/team_store.rs b/crates/funnel-server/src/store/team_store.rs new file mode 100644 index 0000000..34319d2 --- /dev/null +++ b/crates/funnel-server/src/store/team_store.rs @@ -0,0 +1,40 @@ +use uuid::Uuid; + +use super::{BoxFuture, StoreError}; +use crate::db::teams::{Team, TeamMembership}; + +pub trait TeamStore: Send + Sync { + fn create(&self, name: &str) -> BoxFuture<'_, Result>; + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>>; + fn find_by_name(&self, name: &str) -> BoxFuture<'_, Result, StoreError>>; + fn list_all(&self) -> BoxFuture<'_, Result, StoreError>>; + fn delete(&self, id: Uuid) -> BoxFuture<'_, Result>; + fn add_member( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> BoxFuture<'_, Result>; + fn remove_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> BoxFuture<'_, Result>; + fn list_members(&self, team_id: Uuid) + -> BoxFuture<'_, Result, StoreError>>; + fn list_teams_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>>; + fn get_team_ids_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>>; + fn is_member(&self, team_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result>; + fn update_member_role( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> BoxFuture<'_, Result>; + fn find_membership( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> BoxFuture<'_, Result, StoreError>>; + fn count_owners(&self, team_id: Uuid) -> BoxFuture<'_, Result>; +} diff --git a/crates/funnel-server/src/store/tunnel_registry.rs b/crates/funnel-server/src/store/tunnel_registry.rs new file mode 100644 index 0000000..fefbfce --- /dev/null +++ b/crates/funnel-server/src/store/tunnel_registry.rs @@ -0,0 +1,14 @@ +use std::sync::Arc; + +use funnel_core::tunnel::id::TunnelId; + +use crate::tunnel::connection::ActiveTunnel; +use crate::tunnel::manager::TunnelInfo; + +pub trait TunnelRegistry: Send + Sync { + fn insert(&self, id: TunnelId, tunnel: Arc) -> Result<(), Arc>; + fn remove(&self, id: &TunnelId) -> Option>; + fn get(&self, id: &TunnelId) -> Option>; + fn list(&self) -> Vec; + fn count(&self) -> usize; +} diff --git a/crates/funnel-server/src/store/turso/account_store.rs b/crates/funnel-server/src/store/turso/account_store.rs new file mode 100644 index 0000000..3961109 --- /dev/null +++ b/crates/funnel-server/src/store/turso/account_store.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; + +use chrono::Utc; +use turso::Database; +use uuid::Uuid; + +use super::{format_dt, map_err, parse_dt, parse_uuid}; +use crate::db::accounts::{Account, NewAccount}; +use crate::store::account_store::AccountStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct TursoAccountStore { + db: Arc, +} + +impl TursoAccountStore { + pub const fn new(db: Arc) -> Self { + Self { db } + } +} + +fn row_to_account(row: &turso::Row) -> Result { + Ok(Account { + id: parse_uuid(&row.get::(0).map_err(|e| map_err(&e))?)?, + user_id: parse_uuid(&row.get::(1).map_err(|e| map_err(&e))?)?, + provider: row.get::(2).map_err(|e| map_err(&e))?, + provider_account_id: row.get::(3).map_err(|e| map_err(&e))?, + metadata: serde_json::from_str(&row.get::(4).map_err(|e| map_err(&e))?) + .map_err(|e| StoreError::Other(format!("invalid json: {e}")))?, + created_at: parse_dt(&row.get::(5).map_err(|e| map_err(&e))?)?, + updated_at: parse_dt(&row.get::(6).map_err(|e| map_err(&e))?)?, + }) +} + +impl AccountStore for TursoAccountStore { + fn find_by_provider( + &self, + provider: &str, + provider_account_id: &str, + ) -> BoxFuture<'_, Result, StoreError>> { + let provider = provider.to_string(); + let provider_account_id = provider_account_id.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, user_id, provider, provider_account_id, metadata, created_at, updated_at FROM accounts WHERE provider = ? AND provider_account_id = ?", + turso::params![provider, provider_account_id], + ) + .await + .map_err(|e| map_err(&e))?; + match rows.next().await.map_err(|e| map_err(&e))? { + Some(row) => Ok(Some(row_to_account(&row)?)), + None => Ok(None), + } + }) + } + + fn create(&self, new_account: NewAccount) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let id = Uuid::now_v7(); + let now = Utc::now(); + let now_str = format_dt(now); + let metadata_str = serde_json::to_string(&new_account.metadata) + .map_err(|e| StoreError::Other(format!("invalid json: {e}")))?; + + conn.execute( + "INSERT INTO accounts (id, user_id, provider, provider_account_id, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + turso::params![ + id.to_string(), + new_account.user_id.to_string(), + new_account.provider.clone(), + new_account.provider_account_id.clone(), + metadata_str, + now_str.clone(), + now_str + ], + ) + .await + .map_err(|e| map_err(&e))?; + + Ok(Account { + id, + user_id: new_account.user_id, + provider: new_account.provider, + provider_account_id: new_account.provider_account_id, + metadata: new_account.metadata, + created_at: now, + updated_at: now, + }) + }) + } + + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, user_id, provider, provider_account_id, metadata, created_at, updated_at FROM accounts WHERE user_id = ? ORDER BY created_at DESC", + turso::params![user_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + let mut accounts = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| map_err(&e))? { + accounts.push(row_to_account(&row)?); + } + Ok(accounts) + }) + } +} diff --git a/crates/funnel-server/src/store/turso/api_key_store.rs b/crates/funnel-server/src/store/turso/api_key_store.rs new file mode 100644 index 0000000..a79bc4c --- /dev/null +++ b/crates/funnel-server/src/store/turso/api_key_store.rs @@ -0,0 +1,337 @@ +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use turso::Database; +use uuid::Uuid; + +use super::{format_dt, map_err, parse_dt, parse_optional_dt, parse_uuid}; +use crate::db::api_keys::{ApiKey, ApiKeyView}; +use crate::store::api_key_store::ApiKeyStore; +use crate::store::{BoxFuture, StoreError}; +use funnel_core::auth::token as auth; + +pub struct TursoApiKeyStore { + db: Arc, +} + +impl TursoApiKeyStore { + pub const fn new(db: Arc) -> Self { + Self { db } + } +} + +fn row_to_api_key(row: &turso::Row) -> Result { + Ok(ApiKey { + id: parse_uuid(&row.get::(0).map_err(|e| map_err(&e))?)?, + user_id: parse_uuid(&row.get::(1).map_err(|e| map_err(&e))?)?, + name: row.get::(2).map_err(|e| map_err(&e))?, + key_hash: row.get::(3).map_err(|e| map_err(&e))?, + key_prefix: row.get::(4).map_err(|e| map_err(&e))?, + scopes: serde_json::from_str(&row.get::(5).map_err(|e| map_err(&e))?) + .map_err(|e| StoreError::Other(format!("invalid json: {e}")))?, + created_at: parse_dt(&row.get::(6).map_err(|e| map_err(&e))?)?, + revoked_at: parse_optional_dt(row.get::>(7).map_err(|e| map_err(&e))?)?, + expires_at: parse_optional_dt(row.get::>(8).map_err(|e| map_err(&e))?)?, + }) +} + +impl ApiKeyStore for TursoApiKeyStore { + fn create( + &self, + user_id: Uuid, + name: &str, + scopes: &serde_json::Value, + expires_at: Option>, + ) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>> { + let name = name.to_string(); + let scopes = scopes.clone(); + Box::pin(async move { + let plaintext = auth::generate_api_key() + .map_err(|e| StoreError::Other(format!("failed to generate api key: {e}")))?; + let hash = auth::hash_token(&plaintext); + let prefix = auth::ApiKeyPrefix::from_key(&plaintext); + + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let id = Uuid::now_v7(); + let now = Utc::now(); + let scopes_str = serde_json::to_string(&scopes) + .map_err(|e| StoreError::Other(format!("invalid json: {e}")))?; + let expires_str = expires_at.map(format_dt); + + conn.execute( + "INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, scopes, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + turso::params![ + id.to_string(), + user_id.to_string(), + name.clone(), + hash, + prefix.as_ref().to_string(), + scopes_str, + format_dt(now), + expires_str.unwrap_or_default() + ], + ) + .await + .map_err(|e| map_err(&e))?; + + let view = ApiKeyView { + id, + name, + key_prefix: prefix.as_ref().to_string(), + scopes, + created_at: now, + revoked_at: None, + expires_at, + }; + + Ok((plaintext, view)) + }) + } + + fn validate(&self, plaintext: &str) -> BoxFuture<'_, Result, StoreError>> { + let hash = auth::hash_token(plaintext); + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let now = format_dt(Utc::now()); + let mut rows = conn + .query( + "SELECT id, user_id, name, key_hash, key_prefix, scopes, created_at, revoked_at, expires_at FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at = '' OR expires_at > ?)", + turso::params![hash, now], + ) + .await + .map_err(|e| map_err(&e))?; + match rows.next().await.map_err(|e| map_err(&e))? { + Some(row) => Ok(Some(row_to_api_key(&row)?)), + None => Ok(None), + } + }) + } + + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, user_id, name, key_hash, key_prefix, scopes, created_at, revoked_at, expires_at FROM api_keys WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC", + turso::params![user_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + let mut keys = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| map_err(&e))? { + let key = row_to_api_key(&row)?; + keys.push(ApiKeyView::from(key)); + } + Ok(keys) + }) + } + + fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let now = format_dt(Utc::now()); + let rows_affected = conn + .execute( + "UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL", + turso::params![now, key_id.to_string(), user_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + Ok(rows_affected > 0) + }) + } + + fn revoke_by_name(&self, user_id: Uuid, name: &str) -> BoxFuture<'_, Result> { + let name = name.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let now = format_dt(Utc::now()); + let rows_affected = conn + .execute( + "UPDATE api_keys SET revoked_at = ? WHERE user_id = ? AND name = ? AND revoked_at IS NULL", + turso::params![now, user_id.to_string(), name], + ) + .await + .map_err(|e| map_err(&e))?; + Ok(rows_affected > 0) + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::db::api_keys::default_scopes; + use crate::db::users::NewUser; + use crate::store::turso::open; + use crate::store::turso::user_store::TursoUserStore; + use crate::store::user_store::UserStore; + + async fn setup() -> (TursoApiKeyStore, Uuid) { + let db = open(":memory:") + .await + .unwrap_or_else(|e| panic!("open: {e}")); + let user_store = TursoUserStore::new(Arc::clone(&db)); + let user = user_store + .create(NewUser { + email: format!("key-test-{}@test.com", Uuid::now_v7()), + name: None, + avatar_url: None, + }) + .await + .unwrap_or_else(|e| panic!("create user: {e}")); + (TursoApiKeyStore::new(db), user.id) + } + + #[tokio::test] + async fn create_and_validate() { + let (store, uid) = setup().await; + + let (plaintext, view) = store + .create(uid, "test-key", &default_scopes(), None) + .await + .unwrap(); + assert_eq!(view.name, "test-key"); + + let found = store.validate(&plaintext).await.unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().id, view.id); + } + + #[tokio::test] + async fn validate_wrong_key_returns_none() { + let (store, _uid) = setup().await; + let found = store.validate("fnl_boguskey1234567890").await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn duplicate_name_returns_conflict() { + let (store, uid) = setup().await; + + store + .create(uid, "dup", &default_scopes(), None) + .await + .unwrap(); + let result = store.create(uid, "dup", &default_scopes(), None).await; + assert!(matches!(result, Err(StoreError::Conflict(_)))); + } + + #[tokio::test] + async fn list_for_user_only_returns_matching() { + let db = open(":memory:") + .await + .unwrap_or_else(|e| panic!("open: {e}")); + let user_store = TursoUserStore::new(Arc::clone(&db)); + let u1 = user_store + .create(NewUser { + email: format!("u1-{}@t.com", Uuid::now_v7()), + name: None, + avatar_url: None, + }) + .await + .unwrap(); + let u2 = user_store + .create(NewUser { + email: format!("u2-{}@t.com", Uuid::now_v7()), + name: None, + avatar_url: None, + }) + .await + .unwrap(); + let store = TursoApiKeyStore::new(db); + + store + .create(u1.id, "a", &default_scopes(), None) + .await + .unwrap(); + store + .create(u1.id, "b", &default_scopes(), None) + .await + .unwrap(); + store + .create(u2.id, "c", &default_scopes(), None) + .await + .unwrap(); + + let list = store.list_for_user(u1.id).await.unwrap(); + assert_eq!(list.len(), 2); + assert!(list.iter().all(|k| k.name == "a" || k.name == "b")); + } + + #[tokio::test] + async fn revoke_makes_key_invalid() { + let (store, uid) = setup().await; + + let (plaintext, view) = store + .create(uid, "to-revoke", &default_scopes(), None) + .await + .unwrap(); + let revoked = store.revoke(view.id, uid).await.unwrap(); + assert!(revoked); + + let found = store.validate(&plaintext).await.unwrap(); + assert!(found.is_none()); + + let list = store.list_for_user(uid).await.unwrap(); + assert!(list.is_empty()); + } + + #[tokio::test] + async fn revoke_wrong_user_returns_false() { + let (store, uid) = setup().await; + let other = Uuid::now_v7(); + + let (_plaintext, view) = store + .create(uid, "key", &default_scopes(), None) + .await + .unwrap(); + let revoked = store.revoke(view.id, other).await.unwrap(); + assert!(!revoked); + } + + #[tokio::test] + async fn can_reuse_name_after_revoke() { + let (store, uid) = setup().await; + + let (_plaintext, view) = store + .create(uid, "reuse", &default_scopes(), None) + .await + .unwrap(); + store.revoke(view.id, uid).await.unwrap(); + + let result = store.create(uid, "reuse", &default_scopes(), None).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn scopes_are_preserved() { + let (store, uid) = setup().await; + let scopes = serde_json::json!(["tunnels"]); + + let (plaintext, view) = store + .create(uid, "tunnel-only", &scopes, None) + .await + .unwrap(); + assert_eq!(view.scopes, scopes); + + let key = store.validate(&plaintext).await.unwrap().unwrap(); + assert!(key.has_scope("tunnels")); + assert!(!key.has_scope("management")); + } + + #[tokio::test] + async fn expired_key_is_rejected() { + let (store, uid) = setup().await; + let past = Utc::now() - chrono::Duration::hours(1); + + let (plaintext, _) = store + .create(uid, "expired", &default_scopes(), Some(past)) + .await + .unwrap(); + + let found = store.validate(&plaintext).await.unwrap(); + assert!(found.is_none()); + } +} diff --git a/crates/funnel-server/src/store/turso/mod.rs b/crates/funnel-server/src/store/turso/mod.rs new file mode 100644 index 0000000..ad4daae --- /dev/null +++ b/crates/funnel-server/src/store/turso/mod.rs @@ -0,0 +1,55 @@ +pub mod account_store; +pub mod api_key_store; +pub mod session_recorder; +pub mod team_store; +pub mod user_store; + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use turso::Database; +use uuid::Uuid; + +use super::StoreError; + +const SCHEMA: &str = include_str!("../../../../../migrations/001_turso.sql"); + +pub async fn open(path: &str) -> Result, StoreError> { + let db = turso::Builder::new_local(path) + .build() + .await + .map_err(|e| map_err(&e))?; + let conn = db.connect().map_err(|e| map_err(&e))?; + conn.execute_batch(SCHEMA).await.map_err(|e| map_err(&e))?; + Ok(Arc::new(db)) +} + +pub fn map_err(e: &turso::Error) -> StoreError { + let msg = e.to_string(); + if msg.contains("UNIQUE constraint failed") { + StoreError::Conflict(msg) + } else { + StoreError::Other(msg) + } +} + +pub fn parse_uuid(s: &str) -> Result { + s.parse::() + .map_err(|e| StoreError::Other(format!("invalid uuid: {e}"))) +} + +pub fn format_dt(dt: DateTime) -> String { + dt.to_rfc3339() +} + +pub fn parse_dt(s: &str) -> Result, StoreError> { + s.parse::>() + .map_err(|e| StoreError::Other(format!("invalid datetime: {e}"))) +} + +pub fn parse_optional_dt(s: Option) -> Result>, StoreError> { + match s { + Some(s) if !s.is_empty() => Ok(Some(parse_dt(&s)?)), + _ => Ok(None), + } +} diff --git a/crates/funnel-server/src/store/turso/session_recorder.rs b/crates/funnel-server/src/store/turso/session_recorder.rs new file mode 100644 index 0000000..05dc998 --- /dev/null +++ b/crates/funnel-server/src/store/turso/session_recorder.rs @@ -0,0 +1,306 @@ +use std::sync::Arc; + +use chrono::Utc; +use ipnetwork::IpNetwork; +use turso::Database; +use uuid::Uuid; + +use super::{format_dt, map_err, parse_dt, parse_optional_dt, parse_uuid}; +use crate::db::tunnel_sessions::TunnelSession; +use crate::store::session_recorder::SessionRecorder; +use crate::store::{BoxFuture, StoreError}; + +pub struct TursoSessionRecorder { + db: Arc, +} + +impl TursoSessionRecorder { + pub const fn new(db: Arc) -> Self { + Self { db } + } +} + +fn row_to_session(row: &turso::Row) -> Result { + let ip_str: Option = row.get::>(3).map_err(|e| map_err(&e))?; + let client_ip = match ip_str { + Some(s) if !s.is_empty() => Some( + s.parse::() + .map_err(|e| StoreError::Other(format!("invalid ip: {e}")))?, + ), + _ => None, + }; + Ok(TunnelSession { + id: parse_uuid(&row.get::(0).map_err(|e| map_err(&e))?)?, + user_id: parse_uuid(&row.get::(1).map_err(|e| map_err(&e))?)?, + tunnel_id: row.get::(2).map_err(|e| map_err(&e))?, + client_ip, + connected_at: parse_dt(&row.get::(4).map_err(|e| map_err(&e))?)?, + disconnected_at: parse_optional_dt(row.get::>(5).map_err(|e| map_err(&e))?)?, + bytes_in: row.get::(6).map_err(|e| map_err(&e))?, + bytes_out: row.get::(7).map_err(|e| map_err(&e))?, + requests: row.get::(8).map_err(|e| map_err(&e))?, + }) +} + +impl SessionRecorder for TursoSessionRecorder { + fn record_connect( + &self, + user_id: Uuid, + tunnel_id: &str, + client_ip: Option, + ) -> BoxFuture<'_, Result> { + let tunnel_id = tunnel_id.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let id = Uuid::now_v7(); + let now = Utc::now(); + let ip_str = client_ip.map(|ip| ip.to_string()).unwrap_or_default(); + + conn.execute( + "INSERT INTO tunnel_sessions (id, user_id, tunnel_id, client_ip, connected_at) VALUES (?, ?, ?, ?, ?)", + turso::params![ + id.to_string(), + user_id.to_string(), + tunnel_id.clone(), + ip_str, + format_dt(now) + ], + ) + .await + .map_err(|e| map_err(&e))?; + + Ok(TunnelSession { + id, + user_id, + tunnel_id, + client_ip, + connected_at: now, + disconnected_at: None, + bytes_in: 0, + bytes_out: 0, + requests: 0, + }) + }) + } + + fn record_disconnect( + &self, + session_id: Uuid, + bytes_in: i64, + bytes_out: i64, + requests: i64, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let now = format_dt(Utc::now()); + let rows_affected = conn + .execute( + "UPDATE tunnel_sessions SET disconnected_at = ?, bytes_in = ?, bytes_out = ?, requests = ? WHERE id = ? AND disconnected_at IS NULL", + turso::params![now, bytes_in, bytes_out, requests, session_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + Ok(rows_affected > 0) + }) + } + + fn list_active(&self) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, user_id, tunnel_id, client_ip, connected_at, disconnected_at, bytes_in, bytes_out, requests FROM tunnel_sessions WHERE disconnected_at IS NULL ORDER BY connected_at DESC", + (), + ) + .await + .map_err(|e| map_err(&e))?; + let mut sessions = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| map_err(&e))? { + sessions.push(row_to_session(&row)?); + } + Ok(sessions) + }) + } + + fn list_for_user( + &self, + user_id: Uuid, + limit: i64, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, user_id, tunnel_id, client_ip, connected_at, disconnected_at, bytes_in, bytes_out, requests FROM tunnel_sessions WHERE user_id = ? ORDER BY connected_at DESC LIMIT ?", + turso::params![user_id.to_string(), limit], + ) + .await + .map_err(|e| map_err(&e))?; + let mut sessions = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| map_err(&e))? { + sessions.push(row_to_session(&row)?); + } + Ok(sessions) + }) + } + + fn list_all(&self, limit: i64) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, user_id, tunnel_id, client_ip, connected_at, disconnected_at, bytes_in, bytes_out, requests FROM tunnel_sessions ORDER BY connected_at DESC LIMIT ?", + turso::params![limit], + ) + .await + .map_err(|e| map_err(&e))?; + let mut sessions = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| map_err(&e))? { + sessions.push(row_to_session(&row)?); + } + Ok(sessions) + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::db::users::NewUser; + use crate::store::turso::open; + use crate::store::turso::user_store::TursoUserStore; + use crate::store::user_store::UserStore; + + async fn setup() -> (TursoSessionRecorder, Uuid) { + let db = open(":memory:") + .await + .unwrap_or_else(|e| panic!("open: {e}")); + let user_store = TursoUserStore::new(Arc::clone(&db)); + let user = user_store + .create(NewUser { + email: format!("sess-{}@test.com", Uuid::now_v7()), + name: None, + avatar_url: None, + }) + .await + .unwrap_or_else(|e| panic!("create user: {e}")); + (TursoSessionRecorder::new(db), user.id) + } + + #[tokio::test] + async fn record_connect_and_list_active() { + let (store, uid) = setup().await; + + let session = store.record_connect(uid, "tunnel-1", None).await.unwrap(); + assert_eq!(session.tunnel_id, "tunnel-1"); + assert_eq!(session.user_id, uid); + assert!(session.disconnected_at.is_none()); + + let active = store.list_active().await.unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].id, session.id); + } + + #[tokio::test] + async fn record_disconnect_removes_from_active() { + let (store, uid) = setup().await; + + let session = store.record_connect(uid, "tunnel-2", None).await.unwrap(); + let disconnected = store + .record_disconnect(session.id, 100, 200, 5) + .await + .unwrap(); + assert!(disconnected); + + let active = store.list_active().await.unwrap(); + assert!(active.is_empty()); + } + + #[tokio::test] + async fn record_disconnect_updates_stats() { + let (store, uid) = setup().await; + + let session = store.record_connect(uid, "tunnel-3", None).await.unwrap(); + store + .record_disconnect(session.id, 1024, 2048, 10) + .await + .unwrap(); + + let all = store.list_for_user(uid, 100).await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].bytes_in, 1024); + assert_eq!(all[0].bytes_out, 2048); + assert_eq!(all[0].requests, 10); + assert!(all[0].disconnected_at.is_some()); + } + + #[tokio::test] + async fn disconnect_nonexistent_returns_false() { + let (store, _uid) = setup().await; + let result = store + .record_disconnect(Uuid::now_v7(), 0, 0, 0) + .await + .unwrap(); + assert!(!result); + } + + #[tokio::test] + async fn disconnect_already_disconnected_returns_false() { + let (store, uid) = setup().await; + + let session = store.record_connect(uid, "tunnel-4", None).await.unwrap(); + store.record_disconnect(session.id, 0, 0, 0).await.unwrap(); + + let again = store.record_disconnect(session.id, 0, 0, 0).await.unwrap(); + assert!(!again); + } + + #[tokio::test] + async fn list_for_user_respects_limit() { + let (store, uid) = setup().await; + + for i in 0..5 { + store + .record_connect(uid, &format!("tunnel-{i}"), None) + .await + .unwrap(); + } + + let limited = store.list_for_user(uid, 3).await.unwrap(); + assert_eq!(limited.len(), 3); + } + + #[tokio::test] + async fn list_for_user_filters_by_user() { + let db = open(":memory:") + .await + .unwrap_or_else(|e| panic!("open: {e}")); + let user_store = TursoUserStore::new(Arc::clone(&db)); + let u1 = user_store + .create(NewUser { + email: format!("u1-{}@t.com", Uuid::now_v7()), + name: None, + avatar_url: None, + }) + .await + .unwrap(); + let u2 = user_store + .create(NewUser { + email: format!("u2-{}@t.com", Uuid::now_v7()), + name: None, + avatar_url: None, + }) + .await + .unwrap(); + let store = TursoSessionRecorder::new(db); + + store.record_connect(u1.id, "a", None).await.unwrap(); + store.record_connect(u2.id, "b", None).await.unwrap(); + store.record_connect(u1.id, "c", None).await.unwrap(); + + let list = store.list_for_user(u1.id, 100).await.unwrap(); + assert_eq!(list.len(), 2); + assert!(list.iter().all(|s| s.user_id == u1.id)); + } +} diff --git a/crates/funnel-server/src/store/turso/team_store.rs b/crates/funnel-server/src/store/turso/team_store.rs new file mode 100644 index 0000000..8a35619 --- /dev/null +++ b/crates/funnel-server/src/store/turso/team_store.rs @@ -0,0 +1,322 @@ +use std::sync::Arc; + +use chrono::Utc; +use turso::Database; +use uuid::Uuid; + +use super::{format_dt, map_err, parse_dt, parse_uuid}; +use crate::db::teams::{Team, TeamMembership}; +use crate::store::team_store::TeamStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct TursoTeamStore { + db: Arc, +} + +impl TursoTeamStore { + pub const fn new(db: Arc) -> Self { + Self { db } + } +} + +fn row_to_team(row: &turso::Row) -> Result { + Ok(Team { + id: parse_uuid(&row.get::(0).map_err(|e| map_err(&e))?)?, + name: row.get::(1).map_err(|e| map_err(&e))?, + created_at: parse_dt(&row.get::(2).map_err(|e| map_err(&e))?)?, + updated_at: parse_dt(&row.get::(3).map_err(|e| map_err(&e))?)?, + }) +} + +fn row_to_membership(row: &turso::Row) -> Result { + Ok(TeamMembership { + id: parse_uuid(&row.get::(0).map_err(|e| map_err(&e))?)?, + team_id: parse_uuid(&row.get::(1).map_err(|e| map_err(&e))?)?, + user_id: parse_uuid(&row.get::(2).map_err(|e| map_err(&e))?)?, + role: row.get::(3).map_err(|e| map_err(&e))?, + created_at: parse_dt(&row.get::(4).map_err(|e| map_err(&e))?)?, + }) +} + +impl TeamStore for TursoTeamStore { + fn create(&self, name: &str) -> BoxFuture<'_, Result> { + let name = name.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let id = Uuid::now_v7(); + let now = Utc::now(); + let now_str = format_dt(now); + conn.execute( + "INSERT INTO teams (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)", + turso::params![id.to_string(), name.clone(), now_str.clone(), now_str], + ) + .await + .map_err(|e| map_err(&e))?; + Ok(Team { + id, + name, + created_at: now, + updated_at: now, + }) + }) + } + + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, name, created_at, updated_at FROM teams WHERE id = ?", + turso::params![id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + match rows.next().await.map_err(|e| map_err(&e))? { + Some(row) => Ok(Some(row_to_team(&row)?)), + None => Ok(None), + } + }) + } + + fn find_by_name(&self, name: &str) -> BoxFuture<'_, Result, StoreError>> { + let name = name.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, name, created_at, updated_at FROM teams WHERE name = ?", + turso::params![name], + ) + .await + .map_err(|e| map_err(&e))?; + match rows.next().await.map_err(|e| map_err(&e))? { + Some(row) => Ok(Some(row_to_team(&row)?)), + None => Ok(None), + } + }) + } + + fn list_all(&self) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, name, created_at, updated_at FROM teams ORDER BY name", + (), + ) + .await + .map_err(|e| map_err(&e))?; + let mut teams = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| map_err(&e))? { + teams.push(row_to_team(&row)?); + } + Ok(teams) + }) + } + + fn delete(&self, id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let rows_affected = conn + .execute( + "DELETE FROM teams WHERE id = ?", + turso::params![id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + Ok(rows_affected > 0) + }) + } + + fn add_member( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> BoxFuture<'_, Result> { + let role = role.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let id = Uuid::now_v7(); + let now = Utc::now(); + conn.execute( + "INSERT INTO team_memberships (id, team_id, user_id, role, created_at) VALUES (?, ?, ?, ?, ?)", + turso::params![ + id.to_string(), + team_id.to_string(), + user_id.to_string(), + role.clone(), + format_dt(now) + ], + ) + .await + .map_err(|e| map_err(&e))?; + Ok(TeamMembership { + id, + team_id, + user_id, + role, + created_at: now, + }) + }) + } + + fn remove_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let rows_affected = conn + .execute( + "DELETE FROM team_memberships WHERE team_id = ? AND user_id = ?", + turso::params![team_id.to_string(), user_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + Ok(rows_affected > 0) + }) + } + + fn list_members( + &self, + team_id: Uuid, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, team_id, user_id, role, created_at FROM team_memberships WHERE team_id = ? ORDER BY created_at", + turso::params![team_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + let mut members = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| map_err(&e))? { + members.push(row_to_membership(&row)?); + } + Ok(members) + }) + } + + fn list_teams_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT t.id, t.name, t.created_at, t.updated_at FROM teams t INNER JOIN team_memberships tm ON t.id = tm.team_id WHERE tm.user_id = ? ORDER BY t.name", + turso::params![user_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + let mut teams = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| map_err(&e))? { + teams.push(row_to_team(&row)?); + } + Ok(teams) + }) + } + + fn get_team_ids_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT team_id FROM team_memberships WHERE user_id = ?", + turso::params![user_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + let mut ids = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| map_err(&e))? { + ids.push(parse_uuid(&row.get::(0).map_err(|e| map_err(&e))?)?); + } + Ok(ids) + }) + } + + fn is_member(&self, team_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT 1 FROM team_memberships WHERE team_id = ? AND user_id = ?", + turso::params![team_id.to_string(), user_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + Ok(rows.next().await.map_err(|e| map_err(&e))?.is_some()) + }) + } + + fn update_member_role( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> BoxFuture<'_, Result> { + let role = role.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + conn.execute( + "UPDATE team_memberships SET role = ? WHERE team_id = ? AND user_id = ?", + turso::params![role, team_id.to_string(), user_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + + let mut rows = conn + .query( + "SELECT id, team_id, user_id, role, created_at FROM team_memberships WHERE team_id = ? AND user_id = ?", + turso::params![team_id.to_string(), user_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + match rows.next().await.map_err(|e| map_err(&e))? { + Some(row) => Ok(row_to_membership(&row)?), + None => Err(StoreError::NotFound), + } + }) + } + + fn find_membership( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, team_id, user_id, role, created_at FROM team_memberships WHERE team_id = ? AND user_id = ?", + turso::params![team_id.to_string(), user_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + match rows.next().await.map_err(|e| map_err(&e))? { + Some(row) => Ok(Some(row_to_membership(&row)?)), + None => Ok(None), + } + }) + } + + fn count_owners(&self, team_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT COUNT(*) FROM team_memberships WHERE team_id = ? AND role = 'owner'", + turso::params![team_id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + let row = rows + .next() + .await + .map_err(|e| map_err(&e))? + .ok_or(StoreError::Other("no count row".into()))?; + row.get::(0).map_err(|e| map_err(&e)) + }) + } +} diff --git a/crates/funnel-server/src/store/turso/user_store.rs b/crates/funnel-server/src/store/turso/user_store.rs new file mode 100644 index 0000000..b458d82 --- /dev/null +++ b/crates/funnel-server/src/store/turso/user_store.rs @@ -0,0 +1,304 @@ +use std::sync::Arc; + +use chrono::Utc; +use turso::Database; +use uuid::Uuid; + +use super::{format_dt, map_err, parse_dt, parse_optional_dt, parse_uuid}; +use crate::db::users::{NewUser, ROLE_MEMBER, User}; +use crate::store::user_store::UserStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct TursoUserStore { + db: Arc, +} + +impl TursoUserStore { + pub const fn new(db: Arc) -> Self { + Self { db } + } +} + +fn row_to_user(row: &turso::Row) -> Result { + Ok(User { + id: parse_uuid(&row.get::(0).map_err(|e| map_err(&e))?)?, + email: row.get::(1).map_err(|e| map_err(&e))?, + name: row.get::>(2).map_err(|e| map_err(&e))?, + avatar_url: row.get::>(3).map_err(|e| map_err(&e))?, + role: row.get::(4).map_err(|e| map_err(&e))?, + metadata: serde_json::from_str(&row.get::(5).map_err(|e| map_err(&e))?) + .map_err(|e| StoreError::Other(format!("invalid json: {e}")))?, + created_at: parse_dt(&row.get::(6).map_err(|e| map_err(&e))?)?, + updated_at: parse_dt(&row.get::(7).map_err(|e| map_err(&e))?)?, + deactivated_at: parse_optional_dt(row.get::>(8).map_err(|e| map_err(&e))?)?, + }) +} + +impl UserStore for TursoUserStore { + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, email, name, avatar_url, role, metadata, created_at, updated_at, deactivated_at FROM users WHERE id = ?", + turso::params![id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + match rows.next().await.map_err(|e| map_err(&e))? { + Some(row) => Ok(Some(row_to_user(&row)?)), + None => Ok(None), + } + }) + } + + fn find_by_email(&self, email: &str) -> BoxFuture<'_, Result, StoreError>> { + let email = email.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, email, name, avatar_url, role, metadata, created_at, updated_at, deactivated_at FROM users WHERE email = ?", + turso::params![email], + ) + .await + .map_err(|e| map_err(&e))?; + match rows.next().await.map_err(|e| map_err(&e))? { + Some(row) => Ok(Some(row_to_user(&row)?)), + None => Ok(None), + } + }) + } + + fn create(&self, new_user: NewUser) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let id = Uuid::now_v7(); + let now = Utc::now(); + let now_str = format_dt(now); + let metadata = serde_json::json!({}).to_string(); + conn.execute( + "INSERT INTO users (id, email, name, avatar_url, role, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + turso::params![ + id.to_string(), + new_user.email.clone(), + new_user.name.clone().unwrap_or_default(), + new_user.avatar_url.clone().unwrap_or_default(), + ROLE_MEMBER, + metadata, + now_str.clone(), + now_str + ], + ) + .await + .map_err(|e| map_err(&e))?; + Ok(User { + id, + email: new_user.email, + name: new_user.name, + avatar_url: new_user.avatar_url, + role: ROLE_MEMBER.into(), + metadata: serde_json::json!({}), + created_at: now, + updated_at: now, + deactivated_at: None, + }) + }) + } + + fn update_profile( + &self, + id: Uuid, + name: Option<&str>, + avatar_url: Option<&str>, + ) -> BoxFuture<'_, Result> { + let name = name.map(ToString::to_string); + let avatar_url = avatar_url.map(ToString::to_string); + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let now = format_dt(Utc::now()); + conn.execute( + "UPDATE users SET name = ?, avatar_url = ?, updated_at = ? WHERE id = ?", + turso::params![ + name.clone().unwrap_or_default(), + avatar_url.clone().unwrap_or_default(), + now, + id.to_string() + ], + ) + .await + .map_err(|e| map_err(&e))?; + let user = self.find_by_id(id).await?.ok_or(StoreError::NotFound)?; + Ok(user) + }) + } + + fn update_role(&self, id: Uuid, role: &str) -> BoxFuture<'_, Result> { + let role = role.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let now = format_dt(Utc::now()); + conn.execute( + "UPDATE users SET role = ?, updated_at = ? WHERE id = ?", + turso::params![role, now, id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + let user = self.find_by_id(id).await?.ok_or(StoreError::NotFound)?; + Ok(user) + }) + } + + fn deactivate(&self, id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let now = format_dt(Utc::now()); + conn.execute( + "UPDATE users SET deactivated_at = ?, updated_at = ? WHERE id = ?", + turso::params![now.clone(), now, id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + let user = self.find_by_id(id).await?.ok_or(StoreError::NotFound)?; + Ok(user) + }) + } + + fn reactivate(&self, id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let now = format_dt(Utc::now()); + conn.execute( + "UPDATE users SET deactivated_at = NULL, updated_at = ? WHERE id = ?", + turso::params![now, id.to_string()], + ) + .await + .map_err(|e| map_err(&e))?; + let user = self.find_by_id(id).await?.ok_or(StoreError::NotFound)?; + Ok(user) + }) + } + + fn list_all(&self, limit: i64) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT id, email, name, avatar_url, role, metadata, created_at, updated_at, deactivated_at FROM users ORDER BY created_at DESC LIMIT ?", + turso::params![limit], + ) + .await + .map_err(|e| map_err(&e))?; + let mut users = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| map_err(&e))? { + users.push(row_to_user(&row)?); + } + Ok(users) + }) + } + + fn count(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query("SELECT COUNT(*) FROM users", ()) + .await + .map_err(|e| map_err(&e))?; + let row = rows + .next() + .await + .map_err(|e| map_err(&e))? + .ok_or(StoreError::Other("no count row".into()))?; + row.get::(0).map_err(|e| map_err(&e)) + }) + } + + fn count_admins(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(|e| map_err(&e))?; + let mut rows = conn + .query( + "SELECT COUNT(*) FROM users WHERE role = 'admin' AND deactivated_at IS NULL", + (), + ) + .await + .map_err(|e| map_err(&e))?; + let row = rows + .next() + .await + .map_err(|e| map_err(&e))? + .ok_or(StoreError::Other("no count row".into()))?; + row.get::(0).map_err(|e| map_err(&e)) + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::store::turso::open; + + async fn store() -> TursoUserStore { + let db = open(":memory:") + .await + .unwrap_or_else(|e| panic!("open: {e}")); + TursoUserStore::new(db) + } + + fn make_new_user(email: &str) -> NewUser { + NewUser { + email: email.to_string(), + name: Some("Test User".to_string()), + avatar_url: None, + } + } + + #[tokio::test] + async fn create_and_find_by_id() { + let store = store().await; + let user = store.create(make_new_user("a@test.com")).await.unwrap(); + + let found = store.find_by_id(user.id).await.unwrap(); + assert_eq!(found.unwrap().email, "a@test.com"); + } + + #[tokio::test] + async fn find_by_id_missing_returns_none() { + let store = store().await; + let found = store.find_by_id(Uuid::now_v7()).await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn find_by_email() { + let store = store().await; + store.create(make_new_user("b@test.com")).await.unwrap(); + + let found = store.find_by_email("b@test.com").await.unwrap(); + assert!(found.is_some()); + + let missing = store.find_by_email("nope@test.com").await.unwrap(); + assert!(missing.is_none()); + } + + #[tokio::test] + async fn update_profile_changes_fields() { + let store = store().await; + let user = store.create(make_new_user("c@test.com")).await.unwrap(); + + let updated = store + .update_profile(user.id, Some("New Name"), Some("https://img.test/1.png")) + .await + .unwrap(); + assert_eq!(updated.name, Some("New Name".into())); + assert_eq!(updated.avatar_url, Some("https://img.test/1.png".into())); + } + + #[tokio::test] + async fn new_user_gets_default_role() { + let store = store().await; + let user = store.create(make_new_user("d@test.com")).await.unwrap(); + assert_eq!(user.role, "member"); + } +} diff --git a/crates/funnel-server/src/store/user_store.rs b/crates/funnel-server/src/store/user_store.rs new file mode 100644 index 0000000..ff087e8 --- /dev/null +++ b/crates/funnel-server/src/store/user_store.rs @@ -0,0 +1,22 @@ +use uuid::Uuid; + +use super::{BoxFuture, StoreError}; +use crate::db::users::{NewUser, User}; + +pub trait UserStore: Send + Sync { + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>>; + fn find_by_email(&self, email: &str) -> BoxFuture<'_, Result, StoreError>>; + fn create(&self, new_user: NewUser) -> BoxFuture<'_, Result>; + fn update_profile( + &self, + id: Uuid, + name: Option<&str>, + avatar_url: Option<&str>, + ) -> BoxFuture<'_, Result>; + fn update_role(&self, id: Uuid, role: &str) -> BoxFuture<'_, Result>; + fn deactivate(&self, id: Uuid) -> BoxFuture<'_, Result>; + fn reactivate(&self, id: Uuid) -> BoxFuture<'_, Result>; + fn list_all(&self, limit: i64) -> BoxFuture<'_, Result, StoreError>>; + fn count(&self) -> BoxFuture<'_, Result>; + fn count_admins(&self) -> BoxFuture<'_, Result>; +} diff --git a/crates/funnel-server/src/tls/acme.rs b/crates/funnel-server/src/tls/acme.rs new file mode 100644 index 0000000..db9830a --- /dev/null +++ b/crates/funnel-server/src/tls/acme.rs @@ -0,0 +1,188 @@ +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use anyhow::{Context, Result}; +use instant_acme::{ + Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, NewAccount, + NewOrder, RetryPolicy, +}; +use rustls::sign::CertifiedKey; +use x509_parser::prelude::*; + +use super::provider::DnsChallenger; + +const POLL_TIMEOUT: Duration = Duration::from_mins(5); +const DNS_PROPAGATION_DELAY: Duration = Duration::from_secs(10); + +pub async fn load_or_create_account( + cert_dir: &Path, + email: &str, + staging: bool, +) -> Result { + let account_json_path = cert_dir.join("account.json"); + + if account_json_path.exists() { + let json = tokio::fs::read_to_string(&account_json_path).await?; + let credentials: AccountCredentials = serde_json::from_str(&json)?; + let account = Account::builder() + .context("failed to create account builder")? + .from_credentials(credentials) + .await + .context("failed to load ACME account from credentials")?; + tracing::info!("loaded existing ACME account"); + return Ok(account); + } + + let directory_url = if staging { + instant_acme::LetsEncrypt::Staging.url() + } else { + instant_acme::LetsEncrypt::Production.url() + }; + + let (account, credentials) = Account::builder() + .context("failed to create account builder")? + .create( + &NewAccount { + contact: &[&format!("mailto:{email}")], + terms_of_service_agreed: true, + only_return_existing: false, + }, + directory_url.to_string(), + None, + ) + .await + .context("failed to create ACME account")?; + + let json = serde_json::to_string_pretty(&credentials)?; + tokio::fs::write(&account_json_path, &json).await?; + + tracing::info!("created new ACME account"); + Ok(account) +} + +pub async fn order_certificate( + account: &Account, + domain: &str, + provider: &Arc, +) -> Result<(String, String)> { + let identifiers = vec![Identifier::Dns(domain.to_string())]; + let mut order = account + .new_order(&NewOrder::new(&identifiers)) + .await + .context("failed to create ACME order")?; + + struct PendingCleanup { + record_name: String, + dns_value: String, + } + let mut cleanups = Vec::new(); + + { + let mut authz_stream = order.authorizations(); + while let Some(result) = authz_stream.next().await { + let mut authz = result.context("failed to get authorization")?; + + if matches!(authz.status, AuthorizationStatus::Valid) { + continue; + } + + let challenge_domain = match authz.identifier().identifier { + Identifier::Dns(d) => d.clone(), + _ => continue, + }; + + let mut challenge = authz + .challenge(ChallengeType::Dns01) + .context("no DNS-01 challenge found")?; + + let dns_value = challenge.key_authorization().dns_value(); + + let record_name = format!("_acme-challenge.{challenge_domain}"); + provider + .present(&record_name, &dns_value) + .await + .context("failed to present DNS challenge")?; + + tokio::time::sleep(DNS_PROPAGATION_DELAY).await; + + challenge + .set_ready() + .await + .context("failed to set challenge ready")?; + + cleanups.push(PendingCleanup { + record_name, + dns_value, + }); + } + } + + let retries = RetryPolicy::new().timeout(POLL_TIMEOUT); + + order + .poll_ready(&retries) + .await + .context("failed waiting for order to become ready")?; + + let private_key_pem = order + .finalize() + .await + .context("failed to finalize ACME order")?; + + let cert_pem = order + .poll_certificate(&retries) + .await + .context("failed to get certificate")?; + + // clean up DNS records (best effort) + for cleanup in &cleanups { + if let Err(e) = provider + .cleanup(&cleanup.record_name, &cleanup.dns_value) + .await + { + tracing::warn!( + record = %cleanup.record_name, + error = %e, + "failed to clean up DNS record" + ); + } + } + + Ok((cert_pem, private_key_pem)) +} + +pub fn load_certified_key(cert_pem: &str, key_pem: &str) -> Result { + use rustls_pki_types::pem::PemObject; + use rustls_pki_types::{CertificateDer, PrivateKeyDer}; + + let certs: Vec> = CertificateDer::pem_slice_iter(cert_pem.as_bytes()) + .collect::, _>>() + .map_err(|e| anyhow::anyhow!(e)) + .context("failed to parse certificate PEM")?; + + let key = PrivateKeyDer::from_pem_slice(key_pem.as_bytes()) + .map_err(|e| anyhow::anyhow!(e)) + .context("failed to parse private key PEM")?; + + let signing_key = rustls::crypto::ring::sign::any_supported_type(&key) + .map_err(|e| anyhow::anyhow!(e)) + .context("unsupported private key type")?; + + Ok(CertifiedKey::new(certs, signing_key)) +} + +pub fn parse_cert_expiry(cert_pem: &str) -> Result { + use rustls_pki_types::CertificateDer; + use rustls_pki_types::pem::PemObject; + + let certs: Vec> = CertificateDer::pem_slice_iter(cert_pem.as_bytes()) + .collect::, _>>() + .map_err(|e| anyhow::anyhow!(e))?; + let cert = certs.first().context("no certificate found")?; + let (_, parsed) = X509Certificate::from_der(cert.as_ref()) + .map_err(|e| anyhow::anyhow!("failed to parse x509 certificate: {e}"))?; + + let not_after = parsed.validity().not_after; + Ok(not_after.to_datetime().into()) +} diff --git a/crates/funnel-server/src/tls/cache.rs b/crates/funnel-server/src/tls/cache.rs new file mode 100644 index 0000000..3bb26dd --- /dev/null +++ b/crates/funnel-server/src/tls/cache.rs @@ -0,0 +1,61 @@ +use std::num::NonZeroUsize; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, SystemTime}; + +use lru::LruCache; +use rustls::sign::CertifiedKey; + +const CAPACITY: NonZeroUsize = match NonZeroUsize::new(512) { + Some(v) => v, + None => panic!("cache size must be non-zero"), +}; + +pub struct CachedCert { + pub certified_key: Arc, + pub not_after: SystemTime, +} + +pub struct CertCache { + inner: Mutex>, +} + +impl CertCache { + pub fn new() -> Self { + Self { + inner: Mutex::new(LruCache::new(CAPACITY)), + } + } + + pub fn get(&self, domain: &str) -> Option<(Arc, SystemTime)> { + let cache = self.inner.lock().unwrap_or_else(PoisonError::into_inner); + let cached = cache.peek(domain)?; + let result = (Arc::clone(&cached.certified_key), cached.not_after); + drop(cache); + Some(result) + } + + pub fn put(&self, domain: String, key: CertifiedKey, not_after: SystemTime) { + let mut cache = self.inner.lock().unwrap_or_else(PoisonError::into_inner); + cache.put( + domain, + CachedCert { + certified_key: Arc::new(key), + not_after, + }, + ); + } + + pub fn domains_needing_renewal(&self, window: Duration) -> Vec { + let cache = self.inner.lock().unwrap_or_else(PoisonError::into_inner); + cache + .iter() + .filter(|(_, cert)| { + cert.not_after + .duration_since(SystemTime::now()) + .unwrap_or(Duration::ZERO) + < window + }) + .map(|(domain, _)| domain.clone()) + .collect() + } +} diff --git a/crates/funnel-server/src/tls/config.rs b/crates/funnel-server/src/tls/config.rs new file mode 100644 index 0000000..b0f6574 --- /dev/null +++ b/crates/funnel-server/src/tls/config.rs @@ -0,0 +1,35 @@ +use std::collections::HashMap; +use std::path::Path; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct ProviderConfig { + pub providers: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProviderType { + Cloudflare, + Route53, + Exec, +} + +#[derive(Debug, Deserialize)] +pub struct DnsProviderEntry { + #[allow(dead_code)] + pub name: String, + #[serde(rename = "type")] + pub provider_type: ProviderType, + pub domains: Vec, + pub config: HashMap, +} + +impl ProviderConfig { + pub async fn load(path: &Path) -> anyhow::Result { + let contents = tokio::fs::read_to_string(path).await?; + let config: Self = serde_json::from_str(&contents)?; + Ok(config) + } +} diff --git a/crates/funnel-server/src/tls/manager.rs b/crates/funnel-server/src/tls/manager.rs new file mode 100644 index 0000000..ef149f1 --- /dev/null +++ b/crates/funnel-server/src/tls/manager.rs @@ -0,0 +1,169 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use anyhow::{Context, Result}; +use instant_acme::Account; +use rustls::server::ResolvesServerCert; +use rustls::sign::CertifiedKey; + +use super::acme; +use super::cache::CertCache; +use super::provider::ProviderMux; + +const RENEWAL_CHECK_INTERVAL: Duration = Duration::from_hours(1); +const RENEWAL_WINDOW: Duration = Duration::from_hours(720); + +pub struct CertificateManager { + cache: CertCache, + obtain_lock: tokio::sync::Mutex<()>, + cert_dir: PathBuf, + provider_mux: Arc, + account: Account, +} + +impl std::fmt::Debug for CertificateManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CertificateManager") + .field("cert_dir", &self.cert_dir) + .finish_non_exhaustive() + } +} + +impl CertificateManager { + pub async fn new( + cert_dir: PathBuf, + provider_mux: ProviderMux, + email: &str, + staging: bool, + ) -> Result { + tokio::fs::create_dir_all(&cert_dir) + .await + .context("failed to create certificate directory")?; + + let account = acme::load_or_create_account(&cert_dir, email, staging).await?; + + Ok(Self { + cache: CertCache::new(), + obtain_lock: tokio::sync::Mutex::new(()), + cert_dir, + provider_mux: Arc::new(provider_mux), + account, + }) + } + + pub async fn preload_certificates(&self) -> Result<()> { + for domain in self.provider_mux.domains() { + match self.load_from_disk(&domain).await { + Ok(Some((certified_key, not_after))) => { + tracing::info!(domain = %domain, "loaded certificate from disk"); + self.cache.put(domain, certified_key, not_after); + } + Ok(None) => { + tracing::info!(domain = %domain, "no cached certificate, obtaining from ACME"); + if let Err(e) = self.obtain_certificate(&domain).await { + tracing::error!(domain = %domain, error = %e, "failed to obtain certificate"); + } + } + Err(e) => { + tracing::warn!(domain = %domain, error = %e, "failed to load cert from disk"); + if let Err(e) = self.obtain_certificate(&domain).await { + tracing::error!(domain = %domain, error = %e, "failed to obtain certificate"); + } + } + } + } + Ok(()) + } + + pub async fn obtain_certificate(&self, domain: &str) -> Result<()> { + let _guard = self.obtain_lock.lock().await; + + // double check cache after acquiring lock + if let Some((_, not_after)) = self.cache.get(domain) + && not_after > SystemTime::now() + RENEWAL_WINDOW + { + return Ok(()); + } + + let provider = self + .provider_mux + .find(domain) + .context("no DNS provider configured for domain")?; + + tracing::info!(domain = %domain, "ordering certificate from ACME"); + + let (cert_pem, key_pem) = acme::order_certificate(&self.account, domain, &provider).await?; + + self.save_to_disk(domain, &cert_pem, &key_pem).await?; + + let certified_key = acme::load_certified_key(&cert_pem, &key_pem)?; + let not_after = acme::parse_cert_expiry(&cert_pem)?; + + self.cache.put(domain.to_string(), certified_key, not_after); + + tracing::info!(domain = %domain, "certificate obtained and cached"); + + Ok(()) + } + + async fn load_from_disk(&self, domain: &str) -> Result> { + let cert_path = self.cert_dir.join(format!("{domain}.crt")); + let key_path = self.cert_dir.join(format!("{domain}.key")); + + if !cert_path.exists() || !key_path.exists() { + return Ok(None); + } + + let cert_pem = tokio::fs::read_to_string(&cert_path).await?; + let key_pem = tokio::fs::read_to_string(&key_path).await?; + + let certified_key = acme::load_certified_key(&cert_pem, &key_pem)?; + let not_after = acme::parse_cert_expiry(&cert_pem)?; + + Ok(Some((certified_key, not_after))) + } + + async fn save_to_disk(&self, domain: &str, cert_pem: &str, key_pem: &str) -> Result<()> { + let cert_path = self.cert_dir.join(format!("{domain}.crt")); + let key_path = self.cert_dir.join(format!("{domain}.key")); + tokio::fs::write(&cert_path, cert_pem).await?; + tokio::fs::write(&key_path, key_pem).await?; + Ok(()) + } + + pub fn spawn_renewal_task(self: &Arc) { + let manager = Arc::clone(self); + tokio::spawn(async move { + let mut interval = tokio::time::interval(RENEWAL_CHECK_INTERVAL); + loop { + interval.tick().await; + manager.check_renewals().await; + } + }); + } + + async fn check_renewals(&self) { + let domains = self.cache.domains_needing_renewal(RENEWAL_WINDOW); + + for domain in domains { + tracing::info!(domain = %domain, "renewing certificate"); + if let Err(e) = self.obtain_certificate(&domain).await { + tracing::error!(domain = %domain, error = %e, "failed to renew certificate"); + } + } + } +} + +impl ResolvesServerCert for CertificateManager { + fn resolve(&self, client_hello: rustls::server::ClientHello<'_>) -> Option> { + let sni = client_hello.server_name()?; + let (key, not_after) = self.cache.get(sni)?; + + if not_after > SystemTime::now() { + Some(key) + } else { + None + } + } +} diff --git a/crates/funnel-server/src/tls/mod.rs b/crates/funnel-server/src/tls/mod.rs new file mode 100644 index 0000000..a0f78af --- /dev/null +++ b/crates/funnel-server/src/tls/mod.rs @@ -0,0 +1,38 @@ +mod acme; +mod cache; +pub mod config; +pub mod manager; +pub mod provider; +pub mod redirect; + +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Context, Result}; + +pub async fn setup( + cert_dir: &Path, + providers_config_path: &Path, + email: &str, + acme_staging: bool, +) -> Result { + let provider_config = config::ProviderConfig::load(providers_config_path).await?; + let provider_mux = provider::ProviderMux::from_config(&provider_config).await?; + + let cert_manager = Arc::new( + manager::CertificateManager::new(cert_dir.to_path_buf(), provider_mux, email, acme_staging) + .await + .context("failed to create certificate manager")?, + ); + + cert_manager.preload_certificates().await?; + cert_manager.spawn_renewal_task(); + + let server_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(cert_manager as Arc); + + Ok(axum_server::tls_rustls::RustlsConfig::from_config( + Arc::new(server_config), + )) +} diff --git a/crates/funnel-server/src/tls/provider/cloudflare.rs b/crates/funnel-server/src/tls/provider/cloudflare.rs new file mode 100644 index 0000000..e2f5633 --- /dev/null +++ b/crates/funnel-server/src/tls/provider/cloudflare.rs @@ -0,0 +1,118 @@ +use anyhow::Result; + +use super::{BoxFuture, DnsChallenger}; + +const API_BASE: &str = "https://api.cloudflare.com/client/v4"; +const TXT_TTL: u32 = 120; + +pub struct CloudflareProvider { + client: reqwest::Client, + api_token: String, +} + +impl CloudflareProvider { + pub fn new(api_token: String) -> Self { + Self { + client: reqwest::Client::new(), + api_token, + } + } + + async fn find_zone_id(&self, domain: &str) -> Result { + let parts: Vec<&str> = domain.split('.').collect(); + for i in 0..parts.len().saturating_sub(1) { + let zone_name = parts[i..].join("."); + let resp: serde_json::Value = self + .client + .get(format!("{API_BASE}/zones")) + .bearer_auth(&self.api_token) + .query(&[("name", &zone_name)]) + .send() + .await? + .json() + .await?; + + let zone_id = resp["result"] + .as_array() + .and_then(|zones| zones.first()) + .and_then(|zone| zone["id"].as_str()); + + if let Some(id) = zone_id { + return Ok(id.to_string()); + } + } + anyhow::bail!("no cloudflare zone found for domain: {domain}") + } + + async fn create_txt_record(&self, zone_id: &str, name: &str, value: &str) -> Result<()> { + let resp = self + .client + .post(format!("{API_BASE}/zones/{zone_id}/dns_records")) + .bearer_auth(&self.api_token) + .json(&serde_json::json!({ + "type": "TXT", + "name": name, + "content": value, + "ttl": TXT_TTL + })) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await?; + anyhow::bail!("failed to create TXT record: {body}"); + } + Ok(()) + } + + async fn delete_txt_records(&self, zone_id: &str, name: &str, value: &str) -> Result<()> { + let resp: serde_json::Value = self + .client + .get(format!("{API_BASE}/zones/{zone_id}/dns_records")) + .bearer_auth(&self.api_token) + .query(&[("type", "TXT"), ("name", name)]) + .send() + .await? + .json() + .await?; + + let matching_records = resp["result"] + .as_array() + .into_iter() + .flatten() + .filter(|r| r["content"].as_str() == Some(value)) + .filter_map(|r| r["id"].as_str()); + + for record_id in matching_records { + self.client + .delete(format!( + "{API_BASE}/zones/{zone_id}/dns_records/{record_id}" + )) + .bearer_auth(&self.api_token) + .send() + .await?; + } + Ok(()) + } +} + +impl DnsChallenger for CloudflareProvider { + fn present(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>> { + let record_name = record_name.to_string(); + let value = value.to_string(); + Box::pin(async move { + let zone_id = self.find_zone_id(&record_name).await?; + self.create_txt_record(&zone_id, &record_name, &value).await + }) + } + + fn cleanup(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>> { + let record_name = record_name.to_string(); + let value = value.to_string(); + Box::pin(async move { + let zone_id = self.find_zone_id(&record_name).await?; + self.delete_txt_records(&zone_id, &record_name, &value) + .await + }) + } +} diff --git a/crates/funnel-server/src/tls/provider/exec.rs b/crates/funnel-server/src/tls/provider/exec.rs new file mode 100644 index 0000000..42a5067 --- /dev/null +++ b/crates/funnel-server/src/tls/provider/exec.rs @@ -0,0 +1,41 @@ +use anyhow::{Context, Result}; + +use super::{BoxFuture, DnsChallenger}; + +pub struct ExecProvider { + command: String, +} + +impl ExecProvider { + pub const fn new(command: String) -> Self { + Self { command } + } + + async fn run(&self, action: &str, record_name: &str, value: &str) -> Result<()> { + let output = tokio::process::Command::new(&self.command) + .args([action, record_name, value]) + .output() + .await + .with_context(|| format!("failed to execute command: {}", self.command))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("command exited with {}: {}", output.status, stderr.trim()); + } + Ok(()) + } +} + +impl DnsChallenger for ExecProvider { + fn present(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>> { + let record_name = record_name.to_string(); + let value = value.to_string(); + Box::pin(async move { self.run("present", &record_name, &value).await }) + } + + fn cleanup(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>> { + let record_name = record_name.to_string(); + let value = value.to_string(); + Box::pin(async move { self.run("cleanup", &record_name, &value).await }) + } +} diff --git a/crates/funnel-server/src/tls/provider/mod.rs b/crates/funnel-server/src/tls/provider/mod.rs new file mode 100644 index 0000000..7271039 --- /dev/null +++ b/crates/funnel-server/src/tls/provider/mod.rs @@ -0,0 +1,87 @@ +mod cloudflare; +mod exec; +mod route53; + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use anyhow::{Context, Result}; + +use self::cloudflare::CloudflareProvider; +use self::exec::ExecProvider; +use self::route53::Route53Provider; +use super::config::{ProviderConfig, ProviderType}; + +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +pub trait DnsChallenger: Send + Sync { + fn present(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>>; + fn cleanup(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>>; +} + +/// maps domains to their DNS challenge providers, walking the domain hierarchy for lookups +pub struct ProviderMux { + providers: HashMap>, +} + +impl ProviderMux { + pub fn new() -> Self { + Self { + providers: HashMap::new(), + } + } + + pub async fn from_config(config: &ProviderConfig) -> Result { + let mut mux = Self::new(); + + for entry in &config.providers { + let provider: Arc = match entry.provider_type { + ProviderType::Cloudflare => { + let api_token = entry + .config + .get("api_token") + .context("cloudflare provider requires 'api_token' in config")?; + Arc::new(CloudflareProvider::new(api_token.clone())) + } + ProviderType::Route53 => { + let region = entry.config.get("region").cloned(); + Arc::new(Route53Provider::new(region).await) + } + ProviderType::Exec => { + let command = entry + .config + .get("command") + .context("exec provider requires 'command' in config")?; + Arc::new(ExecProvider::new(command.clone())) + } + }; + + for domain in &entry.domains { + mux.add(domain.clone(), Arc::clone(&provider)); + } + } + + Ok(mux) + } + + pub fn add(&mut self, domain: String, provider: Arc) { + self.providers.insert(domain, provider); + } + + pub fn find(&self, domain: &str) -> Option> { + let parts: Vec<&str> = domain.split('.').collect(); + for i in 0..parts.len() { + let candidate = parts[i..].join("."); + if let Some(provider) = self.providers.get(&candidate) { + return Some(Arc::clone(provider)); + } + } + None + } + + pub fn domains(&self) -> Vec { + self.providers.keys().cloned().collect() + } +} diff --git a/crates/funnel-server/src/tls/provider/route53.rs b/crates/funnel-server/src/tls/provider/route53.rs new file mode 100644 index 0000000..0122bf6 --- /dev/null +++ b/crates/funnel-server/src/tls/provider/route53.rs @@ -0,0 +1,112 @@ +use anyhow::{Context, Result}; +use aws_sdk_route53::types::{ + Change, ChangeAction, ChangeBatch, ResourceRecord, ResourceRecordSet, RrType, +}; + +use super::{BoxFuture, DnsChallenger}; + +const TXT_TTL: i64 = 60; + +pub struct Route53Provider { + client: aws_sdk_route53::Client, +} + +impl Route53Provider { + pub async fn new(region: Option) -> Self { + let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest()); + if let Some(region) = region { + loader = loader.region(aws_config::Region::new(region)); + } + let config = loader.load().await; + Self { + client: aws_sdk_route53::Client::new(&config), + } + } + + async fn find_hosted_zone(&self, domain: &str) -> Result { + let parts: Vec<&str> = domain.split('.').collect(); + for i in 0..parts.len().saturating_sub(1) { + let zone_name = parts[i..].join("."); + let resp = self + .client + .list_hosted_zones_by_name() + .dns_name(&zone_name) + .max_items(1) + .send() + .await + .context("failed to list hosted zones")?; + + if let Some(zone) = resp.hosted_zones().first() { + let normalized_name = zone.name().trim_end_matches('.'); + if normalized_name == zone_name { + let zone_id = zone.id().trim_start_matches("/hostedzone/"); + return Ok(zone_id.to_string()); + } + } + } + anyhow::bail!("no route53 hosted zone found for domain: {domain}") + } + + async fn change_record( + &self, + action: ChangeAction, + record_name: &str, + value: &str, + ) -> Result<()> { + let zone_id = self.find_hosted_zone(record_name).await?; + + let record = ResourceRecord::builder() + .value(format!("\"{value}\"")) + .build() + .context("failed to build resource record")?; + + let record_set = ResourceRecordSet::builder() + .name(record_name) + .r#type(RrType::Txt) + .ttl(TXT_TTL) + .resource_records(record) + .build() + .context("failed to build resource record set")?; + + let change = Change::builder() + .action(action) + .resource_record_set(record_set) + .build() + .context("failed to build change")?; + + let batch = ChangeBatch::builder() + .changes(change) + .build() + .context("failed to build change batch")?; + + self.client + .change_resource_record_sets() + .hosted_zone_id(&zone_id) + .change_batch(batch) + .send() + .await + .context("failed to change route53 record")?; + + Ok(()) + } +} + +impl DnsChallenger for Route53Provider { + fn present(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>> { + let record_name = record_name.to_string(); + let value = value.to_string(); + Box::pin(async move { + self.change_record(ChangeAction::Upsert, &record_name, &value) + .await + }) + } + + fn cleanup(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>> { + let record_name = record_name.to_string(); + let value = value.to_string(); + Box::pin(async move { + self.change_record(ChangeAction::Delete, &record_name, &value) + .await + }) + } +} diff --git a/crates/funnel-server/src/tls/redirect.rs b/crates/funnel-server/src/tls/redirect.rs new file mode 100644 index 0000000..5f8ac7e --- /dev/null +++ b/crates/funnel-server/src/tls/redirect.rs @@ -0,0 +1,25 @@ +use axum::Router; +use axum::extract::Request; +use axum::response::Redirect; + +pub fn router(tls_port: u16) -> Router { + Router::new().fallback(move |request: Request| async move { + let host = request + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost"); + let host_without_port = host.split(':').next().unwrap_or(host); + let path = request + .uri() + .path_and_query() + .map_or("/", axum::http::uri::PathAndQuery::as_str); + + let location = if tls_port == 443 { + format!("https://{host_without_port}{path}") + } else { + format!("https://{host_without_port}:{tls_port}{path}") + }; + Redirect::permanent(&location) + }) +} diff --git a/crates/funnel-server/src/tunnel/connection.rs b/crates/funnel-server/src/tunnel/connection.rs new file mode 100644 index 0000000..0ec275e --- /dev/null +++ b/crates/funnel-server/src/tunnel/connection.rs @@ -0,0 +1,265 @@ +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; + +use http_body_util::BodyExt; +use tokio::io::{AsyncRead, ReadBuf}; + +use funnel_core::protocol::frame::{self as frame, FrameError}; +use funnel_core::protocol::request::{RequestMeta, ResponseMeta}; +use funnel_core::tunnel::id::TunnelId; + +use super::stats::TunnelStats; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +pub struct ActiveTunnel { + id: TunnelId, + conn: quinn::Connection, + stats: Arc, + connected_at: tokio::time::Instant, + owner_id: uuid::Uuid, + team_id: Option, +} + +impl ActiveTunnel { + pub fn new( + id: TunnelId, + conn: quinn::Connection, + owner_id: uuid::Uuid, + team_id: Option, + ) -> Self { + Self { + id, + conn, + stats: Arc::new(TunnelStats::new()), + connected_at: tokio::time::Instant::now(), + owner_id, + team_id, + } + } + + pub const fn id(&self) -> &TunnelId { + &self.id + } + + pub fn stats(&self) -> super::stats::TunnelStatsSnapshot { + self.stats.snapshot() + } + + pub const fn connected_at(&self) -> tokio::time::Instant { + self.connected_at + } + + pub const fn owner_id(&self) -> uuid::Uuid { + self.owner_id + } + + pub const fn team_id(&self) -> Option { + self.team_id + } + + /// open a quic bidirectional stream, send the request, and return the + /// response metadata along with a counted recv stream for body streaming. + pub async fn send_request( + &self, + meta: RequestMeta, + body: axum::body::Body, + ) -> Result<(ResponseMeta, CountedRecvStream), SendError> { + self.stats.inc_requests(); + + let start = std::time::Instant::now(); + + let result = tokio::time::timeout(REQUEST_TIMEOUT, async { + let (mut send, mut recv) = self.conn.open_bi().await.map_err(SendError::OpenStream)?; + + frame::write_meta(&mut send, &meta) + .await + .map_err(SendError::SendMeta)?; + + let mut body = body; + let mut bytes_sent: u64 = 0; + while let Some(frame) = body.frame().await { + let frame = frame.map_err(SendError::ReadBody)?; + if let Ok(data) = frame.into_data() { + bytes_sent += data.len() as u64; + send.write_all(&data).await.map_err(SendError::WriteBody)?; + } + } + + send.finish().map_err(SendError::FinishStream)?; + + self.stats.add_bytes_out(bytes_sent); + metrics::counter!("funnel_bytes_out_total").increment(bytes_sent); + metrics::histogram!("funnel_request_body_bytes").record(bytes_sent as f64); + + let resp_meta: ResponseMeta = frame::read_meta(&mut recv) + .await + .map_err(SendError::ReadResponse)?; + + let counted_recv = CountedRecvStream::new(recv, Arc::clone(&self.stats)); + Ok::<_, SendError>((resp_meta, counted_recv)) + }) + .await; + + let duration = start.elapsed().as_secs_f64(); + metrics::histogram!("funnel_request_duration_seconds").record(duration); + + match result { + Ok(Ok(response)) => { + metrics::counter!("funnel_requests_total", "outcome" => "success").increment(1); + Ok(response) + } + Ok(Err(e)) => { + metrics::counter!("funnel_requests_total", "outcome" => e.outcome_label()) + .increment(1); + Err(e) + } + Err(_) => { + metrics::counter!("funnel_requests_total", "outcome" => "timeout").increment(1); + Err(SendError::Timeout) + } + } + } + + /// open a quic bidirectional stream, send the request metadata, then + /// return the response metadata and raw streams for bidirectional piping. + /// unlike send_request, this does not send a body or call finish(). + pub async fn send_upgrade_request( + &self, + meta: RequestMeta, + ) -> Result<(ResponseMeta, quinn::SendStream, quinn::RecvStream), SendError> { + self.stats.inc_requests(); + + let result = tokio::time::timeout(REQUEST_TIMEOUT, async { + let (mut send, mut recv) = self.conn.open_bi().await.map_err(SendError::OpenStream)?; + + frame::write_meta(&mut send, &meta) + .await + .map_err(SendError::SendMeta)?; + + let resp_meta: ResponseMeta = frame::read_meta(&mut recv) + .await + .map_err(SendError::ReadResponse)?; + + Ok::<_, SendError>((resp_meta, send, recv)) + }) + .await; + + match result { + Ok(Ok(response)) => { + metrics::counter!("funnel_requests_total", "outcome" => "success").increment(1); + Ok(response) + } + Ok(Err(e)) => { + metrics::counter!("funnel_requests_total", "outcome" => e.outcome_label()) + .increment(1); + Err(e) + } + Err(_) => { + metrics::counter!("funnel_requests_total", "outcome" => "timeout").increment(1); + Err(SendError::Timeout) + } + } + } + + pub fn close(&self) { + self.conn + .close(quinn::VarInt::from_u32(0), b"tunnel closed"); + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SendError { + #[error("failed to open tunnel stream")] + OpenStream(#[source] quinn::ConnectionError), + + #[error("failed to send request metadata")] + SendMeta(#[source] FrameError), + + #[error("failed to read request body")] + ReadBody(#[source] axum::Error), + + #[error("failed to write body to tunnel")] + WriteBody(#[source] quinn::WriteError), + + #[error("failed to finish send stream")] + FinishStream(#[source] quinn::ClosedStream), + + #[error("failed to read response metadata")] + ReadResponse(#[source] FrameError), + + #[error("request timed out")] + Timeout, +} + +impl SendError { + pub const fn outcome_label(&self) -> &'static str { + match self { + Self::OpenStream(_) => "stream_open_failed", + Self::SendMeta(_) => "send_meta_failed", + Self::ReadBody(_) => "read_body_failed", + Self::WriteBody(_) => "write_body_failed", + Self::FinishStream(_) => "finish_stream_failed", + Self::ReadResponse(_) => "read_response_failed", + Self::Timeout => "timeout", + } + } +} + +/// wraps a quinn::RecvStream and tracks bytes read for metrics and per tunnel stats. +/// on drop, records funnel_bytes_in_total and funnel_response_body_bytes. +pub struct CountedRecvStream { + inner: quinn::RecvStream, + bytes_read: u64, + stats: Arc, +} + +impl CountedRecvStream { + const fn new(inner: quinn::RecvStream, stats: Arc) -> Self { + Self { + inner, + bytes_read: 0, + stats, + } + } +} + +impl AsyncRead for CountedRecvStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + let before = buf.filled().len(); + let result = Pin::new(&mut this.inner).poll_read(cx, buf); + let after = buf.filled().len(); + this.bytes_read += (after - before) as u64; + result + } +} + +impl Drop for CountedRecvStream { + fn drop(&mut self) { + self.stats.add_bytes_in(self.bytes_read); + metrics::counter!("funnel_bytes_in_total").increment(self.bytes_read); + metrics::histogram!("funnel_response_body_bytes").record(self.bytes_read as f64); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn send_error_display() { + assert_eq!(SendError::Timeout.to_string(), "request timed out"); + } + + #[test] + fn send_error_outcome_labels() { + assert_eq!(SendError::Timeout.outcome_label(), "timeout"); + } +} diff --git a/crates/funnel-server/src/tunnel/manager.rs b/crates/funnel-server/src/tunnel/manager.rs new file mode 100644 index 0000000..3952615 --- /dev/null +++ b/crates/funnel-server/src/tunnel/manager.rs @@ -0,0 +1,124 @@ +use std::sync::Arc; + +use dashmap::DashMap; +use dashmap::mapref::entry::Entry; +use serde::Serialize; + +use funnel_core::tunnel::id::TunnelId; + +use uuid::Uuid; + +use super::connection::ActiveTunnel; +use super::stats::TunnelStatsSnapshot; +use crate::store::tunnel_registry::TunnelRegistry; + +pub struct TunnelManager { + tunnels: DashMap>, +} + +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct TunnelInfo { + pub id: String, + pub uptime_secs: f64, + pub stats: TunnelStatsSnapshot, + pub owner_id: Uuid, + pub team_id: Option, +} + +impl TunnelManager { + pub fn new() -> Self { + Self { + tunnels: DashMap::new(), + } + } + + pub fn insert(&self, id: TunnelId, tunnel: Arc) -> Result<(), Arc> { + match self.tunnels.entry(id) { + Entry::Occupied(_) => Err(tunnel), + Entry::Vacant(e) => { + e.insert(tunnel); + Ok(()) + } + } + } + + pub fn remove(&self, id: &TunnelId) -> Option> { + self.tunnels.remove(id).map(|(_, t)| t) + } + + pub fn get(&self, id: &TunnelId) -> Option> { + self.tunnels.get(id).map(|r| Arc::clone(r.value())) + } + + pub fn list(&self) -> Vec { + self.tunnels + .iter() + .map(|entry| { + let tunnel = entry.value(); + TunnelInfo { + id: entry.key().to_string(), + uptime_secs: tunnel.connected_at().elapsed().as_secs_f64(), + stats: tunnel.stats(), + owner_id: tunnel.owner_id(), + team_id: tunnel.team_id(), + } + }) + .collect() + } + + pub fn count(&self) -> usize { + self.tunnels.len() + } +} + +impl TunnelRegistry for TunnelManager { + fn insert(&self, id: TunnelId, tunnel: Arc) -> Result<(), Arc> { + self.insert(id, tunnel) + } + + fn remove(&self, id: &TunnelId) -> Option> { + self.remove(id) + } + + fn get(&self, id: &TunnelId) -> Option> { + self.get(id) + } + + fn list(&self) -> Vec { + self.list() + } + + fn count(&self) -> usize { + self.count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_manager_is_empty() { + let mgr = TunnelManager::new(); + assert_eq!(mgr.count(), 0); + assert!(mgr.list().is_empty()); + } + + type TestResult = Result<(), Box>; + + #[test] + fn get_returns_none_for_missing() -> TestResult { + let mgr = TunnelManager::new(); + let id = TunnelId::new("test-abc")?; + assert!(mgr.get(&id).is_none()); + Ok(()) + } + + #[test] + fn remove_returns_none_for_missing() -> TestResult { + let mgr = TunnelManager::new(); + let id = TunnelId::new("test-abc")?; + assert!(mgr.remove(&id).is_none()); + Ok(()) + } +} diff --git a/crates/funnel-server/src/tunnel/mod.rs b/crates/funnel-server/src/tunnel/mod.rs new file mode 100644 index 0000000..ef787ad --- /dev/null +++ b/crates/funnel-server/src/tunnel/mod.rs @@ -0,0 +1,3 @@ +pub mod connection; +pub mod manager; +pub mod stats; diff --git a/crates/funnel-server/src/tunnel/stats.rs b/crates/funnel-server/src/tunnel/stats.rs new file mode 100644 index 0000000..230a42d --- /dev/null +++ b/crates/funnel-server/src/tunnel/stats.rs @@ -0,0 +1,75 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::Serialize; + +pub struct TunnelStats { + bytes_in: AtomicU64, + bytes_out: AtomicU64, + requests: AtomicU64, +} + +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct TunnelStatsSnapshot { + pub bytes_in: u64, + pub bytes_out: u64, + pub requests: u64, +} + +impl TunnelStats { + pub const fn new() -> Self { + Self { + bytes_in: AtomicU64::new(0), + bytes_out: AtomicU64::new(0), + requests: AtomicU64::new(0), + } + } + + pub fn add_bytes_in(&self, n: u64) { + self.bytes_in.fetch_add(n, Ordering::Relaxed); + } + + pub fn add_bytes_out(&self, n: u64) { + self.bytes_out.fetch_add(n, Ordering::Relaxed); + } + + pub fn inc_requests(&self) { + self.requests.fetch_add(1, Ordering::Relaxed); + } + + pub fn snapshot(&self) -> TunnelStatsSnapshot { + TunnelStatsSnapshot { + bytes_in: self.bytes_in.load(Ordering::Relaxed), + bytes_out: self.bytes_out.load(Ordering::Relaxed), + requests: self.requests.load(Ordering::Relaxed), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracks_bytes_and_requests() { + let stats = TunnelStats::new(); + stats.add_bytes_in(100); + stats.add_bytes_in(200); + stats.add_bytes_out(50); + stats.inc_requests(); + stats.inc_requests(); + + let snap = stats.snapshot(); + assert_eq!(snap.bytes_in, 300); + assert_eq!(snap.bytes_out, 50); + assert_eq!(snap.requests, 2); + } + + #[test] + fn snapshot_starts_at_zero() { + let stats = TunnelStats::new(); + let snap = stats.snapshot(); + assert_eq!(snap.bytes_in, 0); + assert_eq!(snap.bytes_out, 0); + assert_eq!(snap.requests, 0); + } +} diff --git a/crates/funnel-web/Cargo.toml b/crates/funnel-web/Cargo.toml new file mode 100644 index 0000000..a26e5af --- /dev/null +++ b/crates/funnel-web/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "funnel-web" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +funnel-core.workspace = true + +leptos = { version = "0.7", features = ["csr"] } +serde.workspace = true diff --git a/crates/funnel-web/src/lib.rs b/crates/funnel-web/src/lib.rs new file mode 100644 index 0000000..433d7dd --- /dev/null +++ b/crates/funnel-web/src/lib.rs @@ -0,0 +1 @@ +// leptos frontend diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..a52350a --- /dev/null +++ b/deny.toml @@ -0,0 +1,49 @@ +[graph] +targets = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", +] + +[advisories] +ignore = [ + # number_prefix unmaintained โ€” transitive via indicatif, no alternative + "RUSTSEC-2025-0119", + # paste unmaintained โ€” transitive via leptos ecosystem + "RUSTSEC-2024-0436", + # rustls-webpki 0.101.x vulnerabilities โ€” pinned by aws-sdk via rustls 0.21, + # no fix available for this major version line + "RUSTSEC-2026-0098", + "RUSTSEC-2026-0099", + "RUSTSEC-2026-0104", +] + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "OpenSSL", + "Zlib", + "MPL-2.0", + "CDLA-Permissive-2.0", + "BSL-1.0", +] + +[licenses.private] +ignore = true + +[bans] +multiple-versions = "warn" +wildcards = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/dns-provider-template.op.json b/dns-provider-template.op.json deleted file mode 100644 index 6e0b82b..0000000 --- a/dns-provider-template.op.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/karol-broda/funnel/refs/heads/master/dns-providers.schema.json", - "providers": [ - { - "name": "aws", - "provider": "route53", - "domains": ["op://Funnel/DNS Credentials/domains/0", "op://Funnel/DNS Credentials/domains/1"], - "config": { - "AWS_ACCESS_KEY_ID": "op://Funnel/DNS credentials/aws access key", - "AWS_SECRET_ACCESS_KEY": "op://Funnel/DNS credentials/aws secret access key", - "AWS_REGION": "us-east-1", - "AWS_HOSTED_ZONE_ID": "op://Funnel/DNS Credentials/hosted zone id" - } - } - ] -} \ No newline at end of file diff --git a/dns-providers.schema.json b/dns-providers.schema.json deleted file mode 100644 index 7191f3a..0000000 --- a/dns-providers.schema.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "funnel tunnel server dns provider configuration", - "description": "configuration file for dns providers used for let's encrypt dns-01 challenges.", - "type": "object", - "properties": { - "providers": { - "description": "an array of dns provider configurations.", - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "description": "a unique, friendly name for this provider configuration (e.g., 'aws-production').", - "type": "string" - }, - "provider": { - "description": "the name of the dns provider (e.g., 'route53', 'cloudflare'). must match a provider supported by the lego library.", - "type": "string" - }, - "domains": { - "description": "a list of domains (including wildcards) managed by this provider.", - "type": "array", - "items": { - "type": "string", - "pattern": "^(\\*\\.)?([a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$" - }, - "minitems": 1 - }, - "config": { - "description": "a key-value map of credentials and settings for this provider.", - "type": "object", - "additionalproperties": { - "type": "string" - } - } - }, - "required": ["name", "provider", "domains", "config"] - } - } - }, - "required": ["providers"] -} \ No newline at end of file diff --git a/docker/server/Dockerfile b/docker/server/Dockerfile deleted file mode 100644 index c0916e1..0000000 --- a/docker/server/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -ARG ALPINE_VERSION=latest - -FROM alpine:${ALPINE_VERSION} - -ARG APP_USER=funnel -ARG APP_GROUP=funnel -ARG APP_HOME=/var/lib/funnel - -ENV FUNNEL_HOST="0.0.0.0" -ENV FUNNEL_PORT="8080" -ENV FUNNEL_TLS_PORT="8443" -ENV APP_NAME=funnel-server - -RUN addgroup -S "${APP_GROUP}" && adduser -S "${APP_USER}" -G "${APP_GROUP}" -RUN mkdir -p "${APP_HOME}/certs" && chown -R "${APP_USER}:${APP_GROUP}" "${APP_HOME}" - -# goreleaser places the binary (named 'server' from the builds.binary config) -# and the entrypoint.sh (from the dockers.extra_files config) -# in the root of the build context. -COPY server /usr/local/bin/funnel-server -COPY docker/server/entrypoint.sh /usr/local/bin/entrypoint.sh - -RUN chmod +x /usr/local/bin/entrypoint.sh - -USER ${APP_USER} - -EXPOSE ${FUNNEL_PORT} -EXPOSE ${FUNNEL_TLS_PORT} - -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] - -CMD ["/usr/local/bin/funnel-server", "--host", "0.0.0.0", "--port", "8080"] \ No newline at end of file diff --git a/docker/server/entrypoint.sh b/docker/server/entrypoint.sh deleted file mode 100644 index 7d8bbe5..0000000 --- a/docker/server/entrypoint.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -# this script is a wrapper around the main application binary. -# it allows us to use environment variables to configure the application's command-line flags. - -set -e - -# if the first argument is a flag, assume the user wants to run the server. -# in this case, we prepend the server command to the arguments. -if [ "${1#-}" != "$1" ]; then - set -- /usr/local/bin/funnel-server "$@" -fi - -# execute the command. -# this will be "funnel-server --host ..." if the user runs the container without arguments, -# or a command like "sh" if the user wants an interactive shell. -exec "$@" \ No newline at end of file diff --git a/docs/.eslintrc.json b/docs/.eslintrc.json deleted file mode 100644 index e8c9316..0000000 --- a/docs/.eslintrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "next/core-web-vitals", - "rules": { - "react/no-unescaped-entities": "off" - } -} \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore index 0c9ff6c..c856e18 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,30 +1,5 @@ -# deps -/node_modules - -# generated content -.contentlayer -.content-collections -.source - -# test & build -/coverage -/.next/ -/out/ -/build +.next/ +.source/ +node_modules/ *.tsbuildinfo - -# misc -.DS_Store -*.pem -/.pnp -.pnp.js -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# others -.env*.local -.vercel next-env.d.ts - -.env \ No newline at end of file diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 8169b22..0000000 --- a/docs/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# funnel Documentation - -this is the documentation site for funnel, built with Next.js and fumadocs diff --git a/docs/bun.lock b/docs/bun.lock index b7b23bd..bb2b149 100644 --- a/docs/bun.lock +++ b/docs/bun.lock @@ -1,261 +1,206 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { - "name": "docs", + "name": "funnel-docs", "dependencies": { - "@catppuccin/palette": "1.7.1", - "@phosphor-icons/react": "2.1.10", - "ajv": "^8.17.1", - "fumadocs-core": "15.6.3", - "fumadocs-mdx": "11.6.10", + "@tailwindcss/postcss": "4.2.4", + "@takumi-rs/core": "1.1.2", + "@takumi-rs/core-linux-x64-gnu": "1.1.2", + "@takumi-rs/image-response": "1.1.2", + "@tsparticles/path-perlin-noise": "^3.9.1", + "@tsparticles/react": "^3.0.0", + "@tsparticles/slim": "^3.9.1", + "fumadocs-core": "16.8.5", + "fumadocs-mdx": "14.3.2", "fumadocs-openapi": "9.1.10", - "fumadocs-twoslash": "3.1.4", - "fumadocs-ui": "15.6.3", - "gsap": "3.13.0", - "katex": "0.16.22", - "mermaid": "11.8.1", - "next": "15.3.7", - "ogl": "1.0.11", - "react": "19.1.3", - "react-dom": "19.1.3", - "rehype-katex": "7.0.1", - "remark-math": "6.0.0", - "shiki": "3.9.2", - "twoslash": "0.3.2", + "fumadocs-ui": "16.8.5", + "motion": "^12.38.0", + "next": "16.2.4", + "postcss": "8.5.12", + "react": "19.2.5", + "react-dom": "19.2.5", + "tailwind-variants": "^3.2.2", + "tailwindcss": "4.2.4", }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.11", - "@types/bun": "1.2.19", - "@types/mdx": "^2.0.13", - "@types/node": "24.0.7", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "eslint": "^8", - "eslint-config-next": "15.3.7", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.11", - "typescript": "^5.8.3", + "@types/mdx": "2.0.13", + "@types/node": "20.19.19", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3", }, }, }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], - - "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - - "@antfu/utils": ["@antfu/utils@8.1.1", "", {}, "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ=="], - "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="], - "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], - - "@catppuccin/palette": ["@catppuccin/palette@1.7.1", "", {}, "sha512-aRc1tbzrevOTV7nFTT9SRdF26w/MIwT4Jwt4fDMc9itRZUDXCuEDBLyz4TQMlqO9ZP8mf5Hu4Jr6D03NLFc6Gw=="], - - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], - - "@chevrotain/gast": ["@chevrotain/gast@11.0.3", "", { "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="], - - "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.0.3", "", {}, "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="], - - "@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="], - - "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], - - "@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="], - - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], - "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], - "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@floating-ui/dom": ["@floating-ui/dom@1.7.3", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag=="], + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.5", "", { "dependencies": { "@floating-ui/dom": "^1.7.3" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q=="], - - "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - - "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.1", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg=="], + "@fumadocs/tailwind": ["@fumadocs/tailwind@0.0.5", "", { "peerDependencies": { "@tailwindcss/oxide": "^4.0.0", "tailwindcss": "^4.0.0" }, "optionalPeers": ["@tailwindcss/oxide", "tailwindcss"] }, "sha512-ENKPWUDRmriccsrUDE4bDBq3FNr/ms3BP2rWlsAEMV1yP23pcCaan+ceGfeBUsAQjw7sj9Q3R4Kl3g/TCStPzQ=="], "@fumari/json-schema-to-typescript": ["@fumari/json-schema-to-typescript@1.1.3", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.9.3", "js-yaml": "^4.1.0", "prettier": "^3.5.3" } }, "sha512-KnaZAo5W769nOaxhPqEMTdjHdngugxmPpNS+Yr2U90iVxgmNAWwhSr8Nx3l+CUehJKNFzJi2C7clQXOfuPJegA=="], - "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="], - - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - "@iconify/utils": ["@iconify/utils@2.3.0", "", { "dependencies": { "@antfu/install-pkg": "^1.0.0", "@antfu/utils": "^8.1.0", "@iconify/types": "^2.0.0", "debug": "^4.4.0", "globals": "^15.14.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "mlly": "^1.7.4" } }, "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.0" }, "os": "darwin", "cpu": "x64" }, "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.0" }, "os": "linux", "cpu": "arm" }, "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.0" }, "os": "linux", "cpu": "arm64" }, "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.0" }, "os": "linux", "cpu": "ppc64" }, "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.0" }, "os": "linux", "cpu": "s390x" }, "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.0" }, "os": "linux", "cpu": "x64" }, "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" }, "os": "linux", "cpu": "arm64" }, "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.0" }, "os": "linux", "cpu": "x64" }, "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.3", "", { "dependencies": { "@emnapi/runtime": "^1.4.4" }, "cpu": "none" }, "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], - "@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="], - - "@mermaid-js/parser": ["@mermaid-js/parser@0.6.2", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ=="], - - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], - "@next/env": ["@next/env@15.3.7", "", {}, "sha512-1fzTVgpRoCwd0zo0/Xs5dt0vM4Ir7/uoeC3FjmuluNALQlllIPQ1zCOL6GMV8QTwK92e9htZTwqaRedzudD7Bg=="], + "@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="], - "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.3.7", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-DmX7Jd08Y88/vhEjufBNgG+NwHCAqIZ/MNNUkx3wDCToRnICwqPT6f4Sjek9WSujq6rw21BJJFMu4i1cKfMm5w=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw=="], + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], - - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], - - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - - "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], - - "@orama/orama": ["@orama/orama@3.1.11", "", {}, "sha512-Szki0cgFiXE5F9RLx2lUyEtJllnuCSQ4B8RLDwIjXkVit6qZjoDAxH+xhJs29MjKLDz0tbPLdKFa6QrQ/qoGGA=="], - - "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], - "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], - "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collapsible": "1.1.11", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], - "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], @@ -263,39 +208,39 @@ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g=="], + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="], - "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw=="], + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], - "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], - "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="], + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], - "@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="], + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], - "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], @@ -317,304 +262,216 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], - - "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.12.0", "", {}, "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw=="], - "@scalar/openapi-parser": ["@scalar/openapi-parser@0.18.3", "", { "dependencies": { "@scalar/openapi-types": "0.3.7", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "2.8.0" } }, "sha512-j0OM3Y3JTVx50m7r5ogoADe2pT/ewhAnO7knjkcm7b+vjgKRpgjUMhXsYraBemFkqd2dNa1QfTX/mDVv7wEr9A=="], "@scalar/openapi-types": ["@scalar/openapi-types@0.3.7", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-QHSvHBVDze3+dUwAhIGq6l1iOev4jdoqdBK7QpfeN1Q4h+6qpVEw3EEqBiH0AXUSh/iWwObBv4uMgfIx0aNZ5g=="], - "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], + "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-kUTRVKPsB/28H5Ko6qEsyudBiWEDLst+Sfi+hwr59E0GLHV0h8RfgbQU7fdN5Lt9A8R1ulRiZyTvAizkROjwDA=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Vn/w5oyQ6TUgTVDIC/BrpXwIlfK6V6kGWDVVz2eRkF2v13YoENUvaNwxMsQU/t6oCuZKzqp9vqtEtEzKl9VegA=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], - "@shikijs/langs": ["@shikijs/langs@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2" } }, "sha512-X1Q6wRRQXY7HqAuX3I8WjMscjeGjqXCg/Sve7J2GWFORXkSrXud23UECqTBIdCSNKJioFtmUGJQNKtlMMZMn0w=="], + "@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], - "@shikijs/rehype": ["@shikijs/rehype@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", "shiki": "3.9.2", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" } }, "sha512-obHyTWAUp5cpgpr4v7T9sjEHkLUMvBHvcpYAtdB1yuWU4/IeJ8boDMpnGUvvnxVpDwARlkvBA4Hr+BISo3zwjg=="], + "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], - "@shikijs/themes": ["@shikijs/themes@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2" } }, "sha512-6z5lBPBMRfLyyEsgf6uJDHPa6NAGVzFJqH4EAZ+03+7sedYir2yJBRu2uPZOKmj43GyhVHWHvyduLDAwJQfDjA=="], + "@shikijs/rehype": ["@shikijs/rehype@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", "shiki": "3.23.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" } }, "sha512-GepKJxXHbXFfAkiZZZ+4V7x71Lw3s0ALYmydUxJRdvpKjSx9FOMSaunv6WRLFBXR6qjYerUq1YZQno+2gLEPwA=="], - "@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="], + "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], - "@shikijs/twoslash": ["@shikijs/twoslash@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2", "twoslash": "^0.3.3" }, "peerDependencies": { "typescript": ">=5.5.0" } }, "sha512-LRAhdZwp+4AXyScSgWAyoo1EM6lf2iTQPivl3w9fWSBi3yp4MJZfbed/6dcemd20jW4A9olzB3j1mFAKv/CRFw=="], + "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], - "@shikijs/types": ["@shikijs/types@3.9.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-/M5L0Uc2ljyn2jKvj4Yiah7ow/W+DJSglVafvWAJ/b8AZDeeRAdMu3c2riDzB7N42VD+jSnWxeP9AKtd4TfYVw=="], + "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - - "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - "@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="], - - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.11", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.11", "@tailwindcss/oxide-darwin-arm64": "4.1.11", "@tailwindcss/oxide-darwin-x64": "4.1.11", "@tailwindcss/oxide-freebsd-x64": "4.1.11", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", "@tailwindcss/oxide-linux-x64-musl": "4.1.11", "@tailwindcss/oxide-wasm32-wasi": "4.1.11", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg=="], - - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.11", "", { "os": "android", "cpu": "arm64" }, "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg=="], - - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ=="], - - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw=="], - - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11", "", { "os": "linux", "cpu": "arm" }, "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.11", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="], - "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.11", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "postcss": "^8.4.41", "tailwindcss": "4.1.11" } }, "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="], - "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="], - "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="], - "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="], - "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.4", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "postcss": "^8.5.6", "tailwindcss": "4.2.4" } }, "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg=="], - "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + "@takumi-rs/core": ["@takumi-rs/core@1.1.2", "", { "dependencies": { "@takumi-rs/helpers": "1.1.2" }, "optionalDependencies": { "@takumi-rs/core-darwin-arm64": "1.1.2", "@takumi-rs/core-darwin-x64": "1.1.2", "@takumi-rs/core-linux-arm64-gnu": "1.1.2", "@takumi-rs/core-linux-arm64-musl": "1.1.2", "@takumi-rs/core-linux-x64-gnu": "1.1.2", "@takumi-rs/core-linux-x64-musl": "1.1.2", "@takumi-rs/core-win32-arm64-msvc": "1.1.2", "@takumi-rs/core-win32-x64-msvc": "1.1.2" } }, "sha512-JI0kUis5MCPzOt2sCahBZLVJERpp1t1fXTRBaBbCGploUa/hqRA9HW6Nr+tccDM/C6wy9FDrJnI4SB56W0HfDA=="], - "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + "@takumi-rs/core-darwin-arm64": ["@takumi-rs/core-darwin-arm64@1.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CLPzikYAND2xFm0Lg5HKImCOnl+Sue4F/WIcm1Z2ykL7h2r9rWDBzcFqGhAEFj9onMJH6c60Mph8kDa1jdj2rQ=="], - "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + "@takumi-rs/core-darwin-x64": ["@takumi-rs/core-darwin-x64@1.1.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-eH351x+BJOMdLQgUoVKEIfTQbBjR6Dv7rzTLZUdWwbbwjEe38geCNsxQunHQ96nsB7M+0sGc8aJjOkd+0qoK3g=="], - "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + "@takumi-rs/core-linux-arm64-gnu": ["@takumi-rs/core-linux-arm64-gnu@1.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-2cuSnKWAfQjQwS+7aprz0LRkztPVsGqWGTto0TydVI81z7sNyC4kZWoJMTpkNb3439HB03UBStQVacgUeVOL+w=="], - "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + "@takumi-rs/core-linux-arm64-musl": ["@takumi-rs/core-linux-arm64-musl@1.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7OIdvVy6lbr49LIBe96uvlOg4Awmye5w+DgL4d0rRi1Zv7bmY23ciBCGaM+7jjkZgcThGrlZKz1Ecopx56pPqA=="], - "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + "@takumi-rs/core-linux-x64-gnu": ["@takumi-rs/core-linux-x64-gnu@1.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IXEhQjGSGye+kwawDad00kN2cLpj1rPF5u0K4KsANvWxa/dho32Nnw+RX8cCtPGiuvEKhtENxtdc7pVSx1E84g=="], - "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + "@takumi-rs/core-linux-x64-musl": ["@takumi-rs/core-linux-x64-musl@1.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Q2vuZCwQ/XmHwwbJxSKNNEKdWdhsvBMhTF8pyyGti5s44yx7W6+a0MkDdMRAkStzDhH6HeZlpknpHcUk4R4g8w=="], - "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + "@takumi-rs/core-win32-arm64-msvc": ["@takumi-rs/core-win32-arm64-msvc@1.1.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-CKpslxYCltih/lL+h7xkVzsfIZtDcXp3WGk01fudgx82Ic87b/jjRMX5KE4f/exkgrKxshRF4yCQGGeQ3vIhiw=="], - "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + "@takumi-rs/core-win32-x64-msvc": ["@takumi-rs/core-win32-x64-msvc@1.1.2", "", { "os": "win32", "cpu": "x64" }, "sha512-c+n2vPpDscMuTHZ29eJp8did5tlJ/KKBKicc51D6AdkA5jhr7MLeryzIKe3ob59iQttWYd/1j4+tUwoO6yEusg=="], - "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + "@takumi-rs/helpers": ["@takumi-rs/helpers@1.1.2", "", { "peerDependencies": { "react": "^19.2.5", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-Edvmk7UzPhlbUCYpIqeBBfq9h/lAeWAhtwIhJJ10vxv7qNWjgfIPakzNWeAYhFVj8KT5y47S3RLT3cKVniqI5g=="], - "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + "@takumi-rs/image-response": ["@takumi-rs/image-response@1.1.2", "", { "dependencies": { "takumi-js": "1.1.2" } }, "sha512-kXkGzHdFBAXH/05KEFkgVP2oJScTeWD7nORQzEiBajYthRyU44ZBctZzEBobvWfagDhfeS86sD+OZB1AjSE1KA=="], - "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + "@takumi-rs/wasm": ["@takumi-rs/wasm@1.1.2", "", { "dependencies": { "@takumi-rs/helpers": "1.1.2" } }, "sha512-in2kLgFp6nuXie5q7blvRk8m/3UuvNDtc5SEFUh6Eu2yn20PcElj1VMdXhQhn+IN0h119BUWWdJ3AuEuh8PiDg=="], - "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + "@tsparticles/basic": ["@tsparticles/basic@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1", "@tsparticles/move-base": "3.9.1", "@tsparticles/plugin-hex-color": "3.9.1", "@tsparticles/plugin-hsl-color": "3.9.1", "@tsparticles/plugin-rgb-color": "3.9.1", "@tsparticles/shape-circle": "3.9.1", "@tsparticles/updater-color": "3.9.1", "@tsparticles/updater-opacity": "3.9.1", "@tsparticles/updater-out-modes": "3.9.1", "@tsparticles/updater-size": "3.9.1" } }, "sha512-ijr2dHMx0IQHqhKW3qA8tfwrR2XYbbWYdaJMQuBo2CkwBVIhZ76U+H20Y492j/NXpd1FUnt2aC0l4CEVGVGdeQ=="], - "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + "@tsparticles/engine": ["@tsparticles/engine@3.9.1", "", {}, "sha512-DpdgAhWMZ3Eh2gyxik8FXS6BKZ8vyea+Eu5BC4epsahqTGY9V3JGGJcXC6lRJx6cPMAx1A0FaQAojPF3v6rkmQ=="], - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + "@tsparticles/interaction-external-attract": ["@tsparticles/interaction-external-attract@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-5AJGmhzM9o4AVFV24WH5vSqMBzOXEOzIdGLIr+QJf4fRh9ZK62snsusv/ozKgs2KteRYQx+L7c5V3TqcDy2upg=="], - "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + "@tsparticles/interaction-external-bounce": ["@tsparticles/interaction-external-bounce@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-bv05+h70UIHOTWeTsTI1AeAmX6R3s8nnY74Ea6p6AbQjERzPYIa0XY19nq/hA7+Nrg+EissP5zgoYYeSphr85A=="], - "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + "@tsparticles/interaction-external-bubble": ["@tsparticles/interaction-external-bubble@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-tbd8ox/1GPl+zr+KyHQVV1bW88GE7OM6i4zql801YIlCDrl9wgTDdDFGIy9X7/cwTvTrCePhrfvdkUamXIribQ=="], - "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + "@tsparticles/interaction-external-connect": ["@tsparticles/interaction-external-connect@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-sq8YfUNsIORjXHzzW7/AJQtfi/qDqLnYG2qOSE1WOsog39MD30RzmiOloejOkfNeUdcGUcfsDgpUuL3UhzFUOA=="], - "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + "@tsparticles/interaction-external-grab": ["@tsparticles/interaction-external-grab@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-QwXza+sMMWDaMiFxd8y2tJwUK6c+nNw554+/9+tEZeTTk2fCbB0IJ7p/TH6ZGWDL0vo2muK54Njv2fEey191ow=="], - "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + "@tsparticles/interaction-external-pause": ["@tsparticles/interaction-external-pause@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-Gzv4/FeNir0U/tVM9zQCqV1k+IAgaFjDU3T30M1AeAsNGh/rCITV2wnT7TOGFkbcla27m4Yxa+Fuab8+8pzm+g=="], - "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + "@tsparticles/interaction-external-push": ["@tsparticles/interaction-external-push@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-GvnWF9Qy4YkZdx+WJL2iy9IcgLvzOIu3K7aLYJFsQPaxT8d9TF8WlpoMlWKnJID6H5q4JqQuMRKRyWH8aAKyQw=="], - "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + "@tsparticles/interaction-external-remove": ["@tsparticles/interaction-external-remove@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-yPThm4UDWejDOWW5Qc8KnnS2EfSo5VFcJUQDWc1+Wcj17xe7vdSoiwwOORM0PmNBzdDpSKQrte/gUnoqaUMwOA=="], - "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + "@tsparticles/interaction-external-repulse": ["@tsparticles/interaction-external-repulse@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-/LBppXkrMdvLHlEKWC7IykFhzrz+9nebT2fwSSFXK4plEBxDlIwnkDxd3FbVOAbnBvx4+L8+fbrEx+RvC8diAw=="], - "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + "@tsparticles/interaction-external-slow": ["@tsparticles/interaction-external-slow@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-1ZYIR/udBwA9MdSCfgADsbDXKSFS0FMWuPWz7bm79g3sUxcYkihn+/hDhc6GXvNNR46V1ocJjrj0u6pAynS1KQ=="], - "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + "@tsparticles/interaction-particles-attract": ["@tsparticles/interaction-particles-attract@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-CYYYowJuGwRLUixQcSU/48PTKM8fCUYThe0hXwQ+yRMLAn053VHzL7NNZzKqEIeEyt5oJoy9KcvubjKWbzMBLQ=="], - "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@tsparticles/interaction-particles-collisions": ["@tsparticles/interaction-particles-collisions@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-ggGyjW/3v1yxvYW1IF1EMT15M6w31y5zfNNUPkqd/IXRNPYvm0Z0ayhp+FKmz70M5p0UxxPIQHTvAv9Jqnuj8w=="], - "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + "@tsparticles/interaction-particles-links": ["@tsparticles/interaction-particles-links@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-MsLbMjy1vY5M5/hu/oa5OSRZAUz49H3+9EBMTIOThiX+a+vpl3sxc9AqNd9gMsPbM4WJlub8T6VBZdyvzez1Vg=="], - "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@tsparticles/move-base": ["@tsparticles/move-base@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-X4huBS27d8srpxwOxliWPUt+NtCwY+8q/cx1DvQxyqmTA8VFCGpcHNwtqiN+9JicgzOvSuaORVqUgwlsc7h4pQ=="], - "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@tsparticles/move-parallax": ["@tsparticles/move-parallax@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-whlOR0bVeyh6J/hvxf/QM3DqvNnITMiAQ0kro6saqSDItAVqg4pYxBfEsSOKq7EhjxNvfhhqR+pFMhp06zoCVA=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@tsparticles/path-perlin-noise": ["@tsparticles/path-perlin-noise@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1", "@tsparticles/perlin-noise": "3.9.1" } }, "sha512-Essnb64JVpkXeTvKukNXT338Zbc6fdRKIt+IYc0idvJT1wOISnMekigd+Mgf8bIo0L2sJnFjf54keocgTov5FQ=="], - "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], - - "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], - - "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@tsparticles/perlin-noise": ["@tsparticles/perlin-noise@3.9.1", "", {}, "sha512-JFX32N14UZMucfsGg4jpsaMoTqNOhr+ZF8rtxX+L9Rq5eNnEyz0PDgc00lqABF4C1Ah/XTCKWo+yeyDjhntGqQ=="], - "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + "@tsparticles/plugin-easing-quad": ["@tsparticles/plugin-easing-quad@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-C2UJOca5MTDXKUTBXj30Kiqr5UyID+xrY/LxicVWWZPczQW2bBxbIbfq9ULvzGDwBTxE2rdvIB8YFKmDYO45qw=="], - "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], - - "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], - - "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], - - "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - - "@types/node": ["@types/node@24.0.7", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw=="], - - "@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="], - - "@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="], - - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - - "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@tsparticles/plugin-hex-color": ["@tsparticles/plugin-hex-color@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-vZgZ12AjUicJvk7AX4K2eAmKEQX/D1VEjEPFhyjbgI7A65eX72M465vVKIgNA6QArLZ1DLs7Z787LOE6GOBWsg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/type-utils": "8.39.0", "@typescript-eslint/utils": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw=="], + "@tsparticles/plugin-hsl-color": ["@tsparticles/plugin-hsl-color@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-jJd1iGgRwX6eeNjc1zUXiJivaqC5UE+SC2A3/NtHwwoQrkfxGWmRHOsVyLnOBRcCPgBp/FpdDe6DIDjCMO715w=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.39.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg=="], + "@tsparticles/plugin-rgb-color": ["@tsparticles/plugin-rgb-color@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-SBxk7f1KBfXeTnnklbE2Hx4jBgh6I6HOtxb+Os1gTp0oaghZOkWcCD2dP4QbUu7fVNCMOcApPoMNC8RTFcy9wQ=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.39.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.39.0", "@typescript-eslint/types": "^8.39.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew=="], + "@tsparticles/react": ["@tsparticles/react@3.0.0", "", { "peerDependencies": { "@tsparticles/engine": "^3.0.2", "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-hjGEtTT1cwv6BcjL+GcVgH++KYs52bIuQGW3PWv7z3tMa8g0bd6RI/vWSLj7p//NZ3uTjEIeilYIUPBh7Jfq/Q=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0" } }, "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A=="], + "@tsparticles/shape-circle": ["@tsparticles/shape-circle@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-DqZFLjbuhVn99WJ+A9ajz9YON72RtCcvubzq6qfjFmtwAK7frvQeb6iDTp6Ze9FUipluxVZWVRG4vWTxi2B+/g=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.39.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ=="], + "@tsparticles/shape-emoji": ["@tsparticles/shape-emoji@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-ifvY63usuT+hipgVHb8gelBHSeF6ryPnMxAAEC1RGHhhXfpSRWMtE6ybr+pSsYU52M3G9+TF84v91pSwNrb9ZQ=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/utils": "8.39.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q=="], + "@tsparticles/shape-image": ["@tsparticles/shape-image@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-fCA5eme8VF3oX8yNVUA0l2SLDKuiZObkijb0z3Ky0qj1HUEVlAuEMhhNDNB9E2iELTrWEix9z7BFMePp2CC7AA=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="], + "@tsparticles/shape-line": ["@tsparticles/shape-line@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-wT8NSp0N9HURyV05f371cHKcNTNqr0/cwUu6WhBzbshkYGy1KZUP9CpRIh5FCrBpTev34mEQfOXDycgfG0KiLQ=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.39.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.39.0", "@typescript-eslint/tsconfig-utils": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw=="], + "@tsparticles/shape-polygon": ["@tsparticles/shape-polygon@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-dA77PgZdoLwxnliH6XQM/zF0r4jhT01pw5y7XTeTqws++hg4rTLV9255k6R6eUqKq0FPSW1/WBsBIl7q/MmrqQ=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.39.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ=="], + "@tsparticles/shape-square": ["@tsparticles/shape-square@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-DKGkDnRyZrAm7T2ipqNezJahSWs6xd9O5LQLe5vjrYm1qGwrFxJiQaAdlb00UNrexz1/SA7bEoIg4XKaFa7qhQ=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA=="], + "@tsparticles/shape-star": ["@tsparticles/shape-star@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-kdMJpi8cdeb6vGrZVSxTG0JIjCwIenggqk0EYeKAwtOGZFBgL7eHhF2F6uu1oq8cJAbXPujEoabnLsz6mW8XaA=="], - "@typescript/vfs": ["@typescript/vfs@1.6.1", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-JwoxboBh7Oz1v38tPbkrZ62ZXNHAk9bJ7c9x0eI5zBfBnBYGhURdbnh7Z4smN/MV48Y5OCcZb58n972UtbazsA=="], + "@tsparticles/slim": ["@tsparticles/slim@3.9.1", "", { "dependencies": { "@tsparticles/basic": "3.9.1", "@tsparticles/engine": "3.9.1", "@tsparticles/interaction-external-attract": "3.9.1", "@tsparticles/interaction-external-bounce": "3.9.1", "@tsparticles/interaction-external-bubble": "3.9.1", "@tsparticles/interaction-external-connect": "3.9.1", "@tsparticles/interaction-external-grab": "3.9.1", "@tsparticles/interaction-external-pause": "3.9.1", "@tsparticles/interaction-external-push": "3.9.1", "@tsparticles/interaction-external-remove": "3.9.1", "@tsparticles/interaction-external-repulse": "3.9.1", "@tsparticles/interaction-external-slow": "3.9.1", "@tsparticles/interaction-particles-attract": "3.9.1", "@tsparticles/interaction-particles-collisions": "3.9.1", "@tsparticles/interaction-particles-links": "3.9.1", "@tsparticles/move-parallax": "3.9.1", "@tsparticles/plugin-easing-quad": "3.9.1", "@tsparticles/shape-emoji": "3.9.1", "@tsparticles/shape-image": "3.9.1", "@tsparticles/shape-line": "3.9.1", "@tsparticles/shape-polygon": "3.9.1", "@tsparticles/shape-square": "3.9.1", "@tsparticles/shape-star": "3.9.1", "@tsparticles/updater-life": "3.9.1", "@tsparticles/updater-rotate": "3.9.1", "@tsparticles/updater-stroke-color": "3.9.1" } }, "sha512-CL5cDmADU7sDjRli0So+hY61VMbdroqbArmR9Av+c1Fisa5ytr6QD7Jv62iwU2S6rvgicEe9OyRmSy5GIefwZw=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@tsparticles/updater-color": ["@tsparticles/updater-color@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-XGWdscrgEMA8L5E7exsE0f8/2zHKIqnTrZymcyuFBw2DCB6BIV+5z6qaNStpxrhq3DbIxxhqqcybqeOo7+Alpg=="], - "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], + "@tsparticles/updater-life": ["@tsparticles/updater-life@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-Oi8aF2RIwMMsjssUkCB6t3PRpENHjdZf6cX92WNfAuqXtQphr3OMAkYFJFWkvyPFK22AVy3p/cFt6KE5zXxwAA=="], - "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="], + "@tsparticles/updater-opacity": ["@tsparticles/updater-opacity@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-w778LQuRZJ+IoWzeRdrGykPYSSaTeWfBvLZ2XwYEkh/Ss961InOxZKIpcS6i5Kp/Zfw0fS1ZAuqeHwuj///Osw=="], - "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g=="], + "@tsparticles/updater-out-modes": ["@tsparticles/updater-out-modes@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-cKQEkAwbru+hhKF+GTsfbOvuBbx2DSB25CxOdhtW2wRvDBoCnngNdLw91rs+0Cex4tgEeibkebrIKFDDE6kELg=="], - "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ=="], + "@tsparticles/updater-rotate": ["@tsparticles/updater-rotate@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-9BfKaGfp28JN82MF2qs6Ae/lJr9EColMfMTHqSKljblwbpVDHte4umuwKl3VjbRt87WD9MGtla66NTUYl+WxuQ=="], - "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.11.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw=="], + "@tsparticles/updater-size": ["@tsparticles/updater-size@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-3NSVs0O2ApNKZXfd+y/zNhTXSFeG1Pw4peI8e6z/q5+XLbmue9oiEwoPy/tQLaark3oNj3JU7Q903ZijPyXSzw=="], - "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw=="], + "@tsparticles/updater-stroke-color": ["@tsparticles/updater-stroke-color@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-3x14+C2is9pZYTg9T2TiA/aM1YMq4wLdYaZDcHm3qO30DZu5oeQq0rm/6w+QOGKYY1Z3Htg9rlSUZkhTHn7eDA=="], - "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], - "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w=="], + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], - "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.11.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], - "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], - "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.11.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg=="], + "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], - "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA=="], + "@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="], - "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.11.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw=="], + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.11.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], - - "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], - - "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], - - "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], - - "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], - - "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], - - "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], - - "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], - - "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], - - "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], - "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], - "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], - - "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - - "axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="], - - "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], - "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="], - "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], - - "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], - - "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001733", "", {}, "sha512-e4QKw/O2Kavj2VQTKZWrwzkt3IxOmIlU6ajRb6LP64LHpBo1J67k2Hi4Vu/TgJWsNtynurfS0uK3MaUTCPfu5Q=="], + "caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -623,13 +480,7 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="], - - "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], - - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - - "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], @@ -639,199 +490,37 @@ "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], - "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], - - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], - "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - - "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], - - "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - - "cytoscape": ["cytoscape@3.33.0", "", {}, "sha512-2d2EwwhaxLWC8ahkH1PpQwCyu6EY3xDRdcEJXrLTb4fOUtVc+YWQalHU67rFS1a6ngj1fgv9dQLtJxP/KAFZEw=="], - - "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], - - "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], - - "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], - - "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], - - "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], - - "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - - "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], - - "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], - - "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], - - "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], - - "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], - - "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - - "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], - - "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], - - "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], - - "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], - - "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], - - "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], - - "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], - - "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], - - "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], - - "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], - - "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], - - "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], - - "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], - - "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], - - "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], - - "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], - - "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], - - "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - - "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], - - "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - - "dagre-d3-es": ["dagre-d3-es@7.0.11", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw=="], - - "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], - - "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], - - "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], - - "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], - - "dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], - - "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], - - "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], - - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - - "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - - "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - - "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], - "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], - - "dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - - "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + "enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-iterator-helpers": ["es-iterator-helpers@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.0.3", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.6", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.4", "safe-array-concat": "^1.1.3" } }, "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - - "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], - - "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], - "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], - "esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], - - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "eslint": ["eslint@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.1", "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA=="], - - "eslint-config-next": ["eslint-config-next@15.3.7", "", { "dependencies": { "@next/eslint-plugin-next": "15.3.7", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-/4Bj88Hcnwuw4bfPNgw/3LbwiSNKMjMneN3XH+Syf1kdbIGMG0nBmipecvBtcPre8EfEUp3v9WX1mu3tWNbemw=="], - - "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], - - "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], - - "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], - - "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], - - "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], - - "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], - - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], - - "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], - "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], - - "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], - - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], @@ -843,131 +532,57 @@ "estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="], - "estree-util-value-to-estree": ["estree-util-value-to-estree@3.4.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-Zlp+gxis+gCfK12d3Srl2PdX2ybsEA8ZYy6vQGVQTNNYLEGRQQ56XB64bjemN8kxIKXP1nC9ip4Z+ILy9LGzvQ=="], + "estree-util-value-to-estree": ["estree-util-value-to-estree@3.5.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ=="], "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - - "exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], - - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - - "fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="], - - "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="], - - "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], - - "fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], - - "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "fast-xml-parser": ["fast-xml-parser@5.8.0", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.2.0", "path-expression-matcher": "^1.5.0", "strnum": "^2.3.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg=="], - "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], - - "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - - "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "foreach": ["foreach@2.0.6", "", {}, "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg=="], - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], - "fumadocs-core": ["fumadocs-core@15.6.3", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.6.1", "@orama/orama": "^3.1.9", "@shikijs/rehype": "^3.7.0", "@shikijs/transformers": "^3.7.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "image-size": "^2.0.2", "negotiator": "^1.0.0", "npm-to-yarn": "^3.0.1", "react-remove-scroll": "^2.7.1", "remark": "^15.0.0", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^3.7.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@oramacloud/client": "1.x.x || 2.x.x", "@types/react": "*", "algoliasearch": "5.x.x", "next": "14.x.x || 15.x.x", "react": "18.x.x || 19.x.x", "react-dom": "18.x.x || 19.x.x" }, "optionalPeers": ["@oramacloud/client", "@types/react", "algoliasearch", "next", "react", "react-dom"] }, "sha512-71IPC6Y0ZLPHlavYormnF1r2uX/lNrTFTYCEh6Akll8hWxRNbKG9Hk4xpFJDTkU83c8eLtHk2iow/ccQkcV6Hw=="], + "fumadocs-core": ["fumadocs-core@16.8.5", "", { "dependencies": { "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.0.2", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "^0.26.0 || ^0.27.0 || ^1.0.0", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-4MRqh/KWtR5Q5+LJd2SFv3nLDHtuZw3q8rwApd9nAWkunHVU30U17fUVq6nY+IDoLs7bSLnvDGvoE+Ynelrn3A=="], - "fumadocs-mdx": ["fumadocs-mdx@11.6.10", "", { "dependencies": { "@mdx-js/mdx": "^3.1.0", "@standard-schema/spec": "^1.0.0", "chokidar": "^4.0.3", "esbuild": "^0.25.5", "estree-util-value-to-estree": "^3.4.0", "js-yaml": "^4.1.0", "lru-cache": "^11.1.0", "picocolors": "^1.1.1", "tinyexec": "^1.0.1", "tinyglobby": "^0.2.14", "unist-util-visit": "^5.0.0", "zod": "^3.25.67" }, "peerDependencies": { "@fumadocs/mdx-remote": "^1.2.0", "fumadocs-core": "^14.0.0 || ^15.0.0", "next": "^15.3.0", "vite": "6.x.x" }, "optionalPeers": ["@fumadocs/mdx-remote", "next", "vite"], "bin": { "fumadocs-mdx": "bin.js" } }, "sha512-W13mGPKDviKHq1FdxJqbBmA8vQ0niEISUUREJU8u3q1g5lQgnZ9whZjTnvijnqiGNbBsjb8CmjU20OlmwG6nhA=="], + "fumadocs-mdx": ["fumadocs-mdx@14.3.2", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.28.0", "estree-util-value-to-estree": "^3.5.0", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "picocolors": "^1.1.1", "picomatch": "^4.0.4", "tinyexec": "^1.1.1", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "zod": "^4.3.6" }, "peerDependencies": { "@types/mdast": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "^15.0.0 || ^16.0.0", "mdast-util-directive": "*", "next": "^15.3.0 || ^16.0.0", "react": "^19.2.0", "vite": "6.x.x || 7.x.x || 8.x.x" }, "optionalPeers": ["@types/mdast", "@types/mdx", "@types/react", "mdast-util-directive", "next", "react", "vite"], "bin": { "fumadocs-mdx": "dist/bin.js" } }, "sha512-73SoZkbUuqnD91G/0zBcaQdM1TMnYw5JJzKgkGvQTiZbtLQFuWTt8/uRqnzFMuNIUu/WY9Lo9d1iZ8G+jOVieA=="], "fumadocs-openapi": ["fumadocs-openapi@9.1.10", "", { "dependencies": { "@fumari/json-schema-to-typescript": "^1.1.3", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@scalar/openapi-parser": "0.18.3", "ajv": "^8.17.1", "class-variance-authority": "^0.7.1", "fumadocs-core": "15.6.9", "fumadocs-ui": "15.6.9", "github-slugger": "^2.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.1.0", "next-themes": "^0.4.6", "openapi-sampler": "^1.6.1", "react-hook-form": "^7.62.0", "remark": "^15.0.1", "remark-rehype": "^11.1.2", "shiki": "^3.9.2", "tinyglobby": "^0.2.14", "xml-js": "^1.6.11" }, "peerDependencies": { "@scalar/api-client-react": "*", "@types/react": "*", "react": "18.x.x || 19.x.x", "react-dom": "18.x.x || 19.x.x" }, "optionalPeers": ["@scalar/api-client-react", "@types/react"] }, "sha512-0ybW/yn9XevfpGfSNPzZql0JvtrKvD6wst1iRPpJHglPasWS4C9tCGdR3iyabME21FE/d+UUz7jbvMWG5Ip4kw=="], - "fumadocs-twoslash": ["fumadocs-twoslash@3.1.4", "", { "dependencies": { "@radix-ui/react-popover": "^1.1.14", "@shikijs/twoslash": "^3.6.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-gfm": "^3.1.0", "mdast-util-to-hast": "^13.2.0", "shiki": "^3.6.0", "tailwind-merge": "^3.3.1", "twoslash": "^0.3.1" }, "peerDependencies": { "@types/react": "*", "fumadocs-ui": "^15.0.0", "react": "18.x.x || 19.x.x" }, "optionalPeers": ["@types/react"] }, "sha512-mD3byKodAZ9c7OG6coppMUg/KcaYlM5DznTR4Yh0/adFkaToFYJcK/cJHHc/hHSou9WOXlwdSOrDUdMny8Qugw=="], - - "fumadocs-ui": ["fumadocs-ui@15.6.3", "", { "dependencies": { "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-direction": "^1.1.1", "@radix-ui/react-navigation-menu": "^1.2.13", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-presence": "^1.1.4", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "class-variance-authority": "^0.7.1", "fumadocs-core": "15.6.3", "lodash.merge": "^4.6.2", "next-themes": "^0.4.6", "postcss-selector-parser": "^7.1.0", "react-medium-image-zoom": "^5.2.14", "scroll-into-view-if-needed": "^3.1.0", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@types/react": "*", "next": "14.x.x || 15.x.x", "react": "18.x.x || 19.x.x", "react-dom": "18.x.x || 19.x.x", "tailwindcss": "^3.4.14 || ^4.0.0" }, "optionalPeers": ["@types/react", "next", "tailwindcss"] }, "sha512-FN2wpPacoJ6vHhwVZF+tiAezKKqOOy5hpQxBUer0Bda95I7uFyloGF/ilVMrrCSbzd5bt/bKzXRJQwcJAw9vAQ=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], - - "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "fumadocs-ui": ["fumadocs-ui@16.8.5", "", { "dependencies": { "@fumadocs/tailwind": "0.0.5", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-direction": "^1.1.1", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-presence": "^1.1.5", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "lucide-react": "^1.11.0", "motion": "^12.38.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.0.2", "tailwind-merge": "^3.5.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "16.8.5", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@takumi-rs/image-response", "@types/mdx", "@types/react", "next"] }, "sha512-caJjSfUhNkwoqumOBKfHxE1UjVHxkTsoaUhA96IvCM3G82bU2OKhf1pYtf/GbZ0XVdIlmY8Z47Cqwsze0HlXjg=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], - - "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], - "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], - - "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], - - "gsap": ["gsap@3.13.0", "", {}, "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw=="], - - "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], - - "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], - - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], - - "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - - "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], - - "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], - - "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], - "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], - "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], - "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], - "hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="], + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], - "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + "hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], @@ -975,187 +590,71 @@ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="], - "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], - - "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - - "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], - "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], - - "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], - - "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], - - "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], - - "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], - - "is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="], - - "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], - - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], - - "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], - - "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], - "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - - "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], - - "is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="], - - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], - "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], - - "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], - - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - - "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], - - "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], - "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], - - "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], - - "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], - - "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], - - "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], - - "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], - - "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], - - "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], - - "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], - - "jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], - - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "json-pointer": ["json-pointer@0.6.2", "", { "dependencies": { "foreach": "^2.0.4" } }, "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - - "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], - "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], - - "katex": ["katex@0.16.22", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg=="], - - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - - "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], - - "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], - - "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], - - "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], - - "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], - - "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], - - "leven": ["leven@4.0.0", "", {}, "sha512-puehA3YKku3osqPlNuzGDUHq8WpwXupUg1V6NXdV38G+gr+gkBwFC8g1b/+YcIvp8gnqVIus+eJCH/eGsRmJNw=="], - - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - - "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], + "leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - "local-pkg": ["local-pkg@1.1.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.0.1", "quansync": "^0.2.8" } }, "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], - "lru-cache": ["lru-cache@11.1.0", "", {}, "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A=="], - - "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], - "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], @@ -1169,8 +668,6 @@ "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], - "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], - "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], @@ -1181,16 +678,12 @@ "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], - "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - - "mermaid": ["mermaid@11.8.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.0.4", "@iconify/utils": "^2.1.33", "@mermaid-js/parser": "^0.6.1", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.11", "dayjs": "^1.11.13", "dompurify": "^3.2.5", "katex": "^0.16.9", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^15.0.7", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-VSXJLqP1Sqw5sGr273mhvpPRhXwE6NlmMSqBZQw+yZJoAJkOIPPn/uT3teeCBx60Fkt5zEI3FrH2eVT0jXRDzw=="], - "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1209,8 +702,6 @@ "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], - "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], - "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], @@ -1263,137 +754,63 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], - "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], - - "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], - - "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], + "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "napi-postinstall": ["napi-postinstall@0.3.2", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw=="], - - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "next": ["next@15.3.7", "", { "dependencies": { "@next/env": "15.3.7", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.5", "@next/swc-darwin-x64": "15.3.5", "@next/swc-linux-arm64-gnu": "15.3.5", "@next/swc-linux-arm64-musl": "15.3.5", "@next/swc-linux-x64-gnu": "15.3.5", "@next/swc-linux-x64-musl": "15.3.5", "@next/swc-win32-arm64-msvc": "15.3.5", "@next/swc-win32-x64-msvc": "15.3.5", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-7dzkjUftt7PAtspscMY+n7dx6Gg+/si89AwwwEMiupSd5bozpQNEbkYczzcIVaYloohZUTsGImq96FGrh0rFUw=="], + "next": ["next@16.2.4", "", { "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.4", "@next/swc-darwin-x64": "16.2.4", "@next/swc-linux-arm64-gnu": "16.2.4", "@next/swc-linux-arm64-musl": "16.2.4", "@next/swc-linux-x64-gnu": "16.2.4", "@next/swc-linux-x64-musl": "16.2.4", "@next/swc-win32-arm64-msvc": "16.2.4", "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], "npm-to-yarn": ["npm-to-yarn@3.0.1", "", {}, "sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - - "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], - - "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], - - "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], - - "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], - - "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], - - "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], - - "ogl": ["ogl@1.0.11", "", {}, "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], - - "oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="], - - "openapi-sampler": ["openapi-sampler@1.6.1", "", { "dependencies": { "@types/json-schema": "^7.0.7", "fast-xml-parser": "^4.5.0", "json-pointer": "0.6.2" } }, "sha512-s1cIatOqrrhSj2tmJ4abFYZQK6l5v+V4toO5q1Pa0DyN8mtyqy2I+Qrj5W9vOELEtybIMQs/TBZGVO/DtTFK8w=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], - "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], - - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - - "package-manager-detector": ["package-manager-detector@1.3.0", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="], - - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "openapi-sampler": ["openapi-sampler@1.7.3", "", { "dependencies": { "@types/json-schema": "^7.0.7", "fast-xml-parser": "^5.5.1", "json-pointer": "0.6.2" } }, "sha512-Qgy2+Z7xR3l7kXurtzi1PCtzAINkFKhBADBe/8cidC2fQrLUQTudLiJjQDnqJXoisWAR6zaHhC0hP6Hn5vja+g=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], - - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - - "pkg-types": ["pkg-types@2.2.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ=="], - - "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], - - "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], - "postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="], - - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - - "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], - - "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - - "quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="], - - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - - "react": ["react@19.1.3", "", {}, "sha512-A4YW2fBH0DryMfkcPOfIPd6YO7jP/0KguaRmONj69EcxMbHerGFzxeCqfznKipB8aJIwr/VefHwehTakgfDA1g=="], - - "react-dom": ["react-dom@19.1.3", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.3" } }, "sha512-stZrJtR8ovt0w3Dn8kTWwcx8hGHMhwjYOcv7hyzPPX/Ym+yl+Z39XFa+1meucWq0FgxF5IIE3h93SYP1kWB0tg=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], - "react-hook-form": ["react-hook-form@7.62.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="], + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-hook-form": ["react-hook-form@7.75.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw=="], - "react-medium-image-zoom": ["react-medium-image-zoom@5.3.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-RCIzVlsKqy3BYgGgYbolUfuvx0aSKC7YhX/IJGEp+WJxsqdIVYJHkBdj++FAj6VD7RiWj6VVmdCfa/9vJE9hZg=="], + "react-medium-image-zoom": ["react-medium-image-zoom@5.4.5", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-58QSIRK6X3uw2fSTejJRnH0JuKTZl7ZJYX+sAMaYx4YTEm33gsNdnP5RuQSCnBiAvisQeErqZWAT31bR89WB6g=="], - "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], @@ -1403,17 +820,13 @@ "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], - "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - - "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], - "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], - - "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], @@ -1421,9 +834,7 @@ "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], - "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], - - "remark-mdx": ["remark-mdx@3.1.0", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA=="], + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -1433,63 +844,17 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], - - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - - "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], - - "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], - - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - - "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], - - "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], - - "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], - - "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], - - "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], - - "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], - "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], - "sharp": ["sharp@0.34.3", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.3", "@img/sharp-darwin-x64": "0.34.3", "@img/sharp-libvips-darwin-arm64": "1.2.0", "@img/sharp-libvips-darwin-x64": "1.2.0", "@img/sharp-libvips-linux-arm": "1.2.0", "@img/sharp-libvips-linux-arm64": "1.2.0", "@img/sharp-libvips-linux-ppc64": "1.2.0", "@img/sharp-libvips-linux-s390x": "1.2.0", "@img/sharp-libvips-linux-x64": "1.2.0", "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", "@img/sharp-libvips-linuxmusl-x64": "1.2.0", "@img/sharp-linux-arm": "0.34.3", "@img/sharp-linux-arm64": "0.34.3", "@img/sharp-linux-ppc64": "0.34.3", "@img/sharp-linux-s390x": "0.34.3", "@img/sharp-linux-x64": "0.34.3", "@img/sharp-linuxmusl-arm64": "0.34.3", "@img/sharp-linuxmusl-x64": "0.34.3", "@img/sharp-wasm32": "0.34.3", "@img/sharp-win32-arm64": "0.34.3", "@img/sharp-win32-ia32": "0.34.3", "@img/sharp-win32-x64": "0.34.3" } }, "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "shiki": ["shiki@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/engine-javascript": "3.9.2", "@shikijs/engine-oniguruma": "3.9.2", "@shikijs/langs": "3.9.2", "@shikijs/themes": "3.9.2", "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-t6NKl5e/zGTvw/IyftLcumolgOczhuroqwXngDeMqJ3h3EQiTY/7wmfgPlsmloD8oYfqkEDqxiaH37Pjm1zUhQ=="], - - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - - "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -1497,103 +862,43 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], - - "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - - "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], - - "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], - - "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], - - "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], - - "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], - - "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], - - "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], - - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], - "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], - "style-to-js": ["style-to-js@1.1.17", "", { "dependencies": { "style-to-object": "1.0.9" } }, "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="], - - "style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="], + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], - - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], + "tailwind-variants": ["tailwind-variants@3.2.2", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="], - "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], + "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], - "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + "takumi-js": ["takumi-js@1.1.2", "", { "dependencies": { "@takumi-rs/core": "1.1.2", "@takumi-rs/helpers": "1.1.2", "@takumi-rs/wasm": "1.1.2" } }, "sha512-S8Ff0zBUQJ8VC40r7y1RPBo5QXFXV3U868M+T8B8JGt5Tko8l83DVEr1BYLOxqUenEeev853S708oE0aD50eZA=="], - "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], - "tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], - - "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], - - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], - "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], - - "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], - - "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "twoslash": ["twoslash@0.3.2", "", { "dependencies": { "@typescript/vfs": "^1.6.1", "twoslash-protocol": "0.3.2" }, "peerDependencies": { "typescript": "^5.5.0" } }, "sha512-TB+ja888uMKhbng8HzpTHm+JfxIWbngIHPy4nKEt2N93MFjpqmkqn8ppnPhIKj4kDnrohEsiogMF7T1gMY06rw=="], - - "twoslash-protocol": ["twoslash-protocol@0.3.2", "", {}, "sha512-lWIL1dGcMr7cywSLSn8ufCoeyPab3bIwPE6DmAlQYQSMjJUgzzRvSz/LsQ179eNJafRghYDlIgF2v7pmsjV3Ww=="], - - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - - "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], - - "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], - - "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], - - "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], - - "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], - - "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], - - "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - - "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], - "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], - - "unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="], + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], @@ -1603,13 +908,9 @@ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], - "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], - "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], - - "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], - - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -1617,142 +918,86 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], - - "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], - - "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], - - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], - - "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - - "vscode-uri": ["vscode-uri@3.0.8", "", {}, "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="], - "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], - - "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], - - "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], - - "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], - - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], - "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], "yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@eslint/eslintrc/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], - - "@iconify/utils/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], - - "@scalar/openapi-types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="], - - "@shikijs/twoslash/twoslash": ["twoslash@0.3.3", "", { "dependencies": { "@typescript/vfs": "^1.6.1", "twoslash-protocol": "0.3.3" }, "peerDependencies": { "typescript": "^5.5.0" } }, "sha512-Yen1RBSYh/NUR6tfK6xT7DsXkBYby7lfp078Q7XoJR5dtAeG9jB5PVL1oF2sZwHapLjaqcZNlw7GSB8himZNsQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" }, "bundled": true }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" }, "bundled": true }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="], + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - - "@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "@scalar/openapi-types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="], - "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + "@shikijs/rehype/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "@shikijs/rehype/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], - "eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], - "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "eslint-plugin-import/doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "eslint-plugin-react/doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "fumadocs-openapi/fumadocs-core": ["fumadocs-core@15.6.9", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.6.1", "@orama/orama": "^3.1.11", "@shikijs/rehype": "^3.9.2", "@shikijs/transformers": "^3.9.2", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "image-size": "^2.0.2", "negotiator": "^1.0.0", "npm-to-yarn": "^3.0.1", "react-remove-scroll": "^2.7.1", "remark": "^15.0.0", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^3.9.2", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@mixedbread/sdk": "^0.19.0", "@oramacloud/client": "1.x.x || 2.x.x", "@types/react": "*", "algoliasearch": "5.x.x", "next": "14.x.x || 15.x.x", "react": "18.x.x || 19.x.x", "react-dom": "18.x.x || 19.x.x", "react-router": "7.x.x" }, "optionalPeers": ["@mixedbread/sdk", "@oramacloud/client", "@types/react", "algoliasearch", "next", "react", "react-dom", "react-router"] }, "sha512-GVrJ2C2fch3KfNz3utqvF2CeZ7Uc947lfo7Deq1mKhWlH+HY3RSUM9MMecRl+uTzu9TNWPCUwj9q0XSUBh5VbQ=="], "fumadocs-openapi/fumadocs-ui": ["fumadocs-ui@15.6.9", "", { "dependencies": { "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-direction": "^1.1.1", "@radix-ui/react-navigation-menu": "^1.2.13", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-presence": "^1.1.4", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "class-variance-authority": "^0.7.1", "fumadocs-core": "15.6.9", "lodash.merge": "^4.6.2", "next-themes": "^0.4.6", "postcss-selector-parser": "^7.1.0", "react-medium-image-zoom": "^5.3.0", "scroll-into-view-if-needed": "^3.1.0", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@types/react": "*", "next": "14.x.x || 15.x.x", "react": "18.x.x || 19.x.x", "react-dom": "18.x.x || 19.x.x", "tailwindcss": "^3.4.14 || ^4.0.0" }, "optionalPeers": ["@types/react", "next", "tailwindcss"] }, "sha512-4zQcWqBJx7DuJn3IWAVrRY7o7Xx6DsScq3U8R8Qu2EJOuioJHotyLoVldSaB3wLioxtR7ito6866LZDp3oci6A=="], - "is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - - "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "fumadocs-openapi/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "sharp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@shikijs/rehype/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@shikijs/rehype/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], - "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@shikijs/rehype/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], - "@shikijs/twoslash/twoslash/twoslash-protocol": ["twoslash-protocol@0.3.3", "", {}, "sha512-26NXjXEj+2NgytwOjjhFtNpQI7Zgct6PTyLg6JO7fIbO7MIm+kx89IxvBustgBOSKxoWjNBN2LXjNHHMD7+k0g=="], + "@shikijs/rehype/shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], + "@shikijs/rehype/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], - "@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "fumadocs-openapi/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "fumadocs-openapi/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], - "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + "fumadocs-openapi/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], - "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "fumadocs-openapi/shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], - "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "fumadocs-openapi/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], - "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "fumadocs-openapi/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], } } diff --git a/docs/content/docs/client/cli.mdx b/docs/content/docs/client/cli.mdx new file mode 100644 index 0000000..0d4efec --- /dev/null +++ b/docs/content/docs/client/cli.mdx @@ -0,0 +1,134 @@ +--- +title: CLI Reference +description: All funnel client commands and flags +--- + +## funnel http + +Create an HTTP tunnel to a local service. + +```bash +funnel http
+``` + +`
` is a port number or `host:port`. If only a port is given, `localhost` is assumed. + +| Flag | Description | +| --- | --- | +| `--id`, `-i` | Tunnel ID (subdomain). Generated if omitted | +| `--server`, `-s` | Server URL, overrides context config | +| `--token`, `-t` | Auth token, overrides context config | +| `--quic-port` | QUIC port on the server, overrides auto-discovery | +| `--insecure` | Skip TLS certificate verification | +| `--team` | Associate tunnel with a team | + +```bash +funnel http 3000 +funnel http 3000 --id my-app +funnel http 127.0.0.1:8080 --server https://tunnel.example.com +``` + +## funnel login + +Authenticate via OAuth. Opens a browser for the OAuth flow and stores the received token. + +```bash +funnel login +funnel login --provider gitlab +``` + +| Flag | Default | Description | +| --- | --- | --- | +| `--provider` | `github` | OAuth provider name | + +## funnel logout + +Clear the stored token for the current context. + +```bash +funnel logout +``` + +## funnel whoami + +Show the currently authenticated user. + +```bash +funnel whoami +``` + +## funnel status + +List active tunnels on the server. + +```bash +funnel status +``` + +## funnel keys + +Manage API keys. + +```bash +funnel keys create --name "deploy-key" +funnel keys list +``` + +## funnel sessions + +View tunnel session history. + +```bash +funnel sessions +funnel sessions --all # admin only: show all users +funnel sessions --limit 100 +``` + +## funnel users + +Manage users (admin only). + +```bash +funnel users list +funnel users set-role --user --role admin +``` + +## funnel teams + +Manage teams. + +```bash +funnel teams create --name backend +funnel teams list +funnel teams add-member --team --user +funnel teams remove-member --team --user +funnel teams list-members --team +funnel teams set-member-role --team --user --role owner +``` + +## funnel context + +Manage server contexts. See [contexts](/docs/getting-started/contexts) for details. + +```bash +funnel context create --server +funnel context use +funnel context list +funnel context delete +``` + +## funnel config + +View the current configuration. + +```bash +funnel config show +``` + +## Global flags + +| Flag | Description | +| --- | --- | +| `--context`, `-c` | Use a specific context for this command | +| `--version` | Print version and protocol info | +| `--help` | Print help | diff --git a/docs/content/docs/core-concepts/architecture.mdx b/docs/content/docs/core-concepts/architecture.mdx deleted file mode 100644 index 8718923..0000000 --- a/docs/content/docs/core-concepts/architecture.mdx +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: architecture -icon: GraphIcon -description: a look at how funnel works under the hood. ---- - -import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; -import { Callout } from 'fumadocs-ui/components/callout'; -import { Step, Steps } from 'fumadocs-ui/components/steps'; - -funnel's architecture is designed to be both simple and efficient, relying on a persistent websocket connection to proxy http requests. the system consists of two main components: the **funnel server** and the **funnel client**. - -## system diagram - -this diagram illustrates the flow of a request from an end-user to your local application. - -:3000"] --> B["funnel client"] - B -.websocket.-> C["funnel server
:8080"] - D["browser/curl"] --> E["tunnel-id.localhost:8080"] - E --> C - C -.-> B - B --> A - - style A fill:#b7bdf8,color:#24273a - style B fill:#f5bde6,color:#24273a - style C fill:#8bd5ca,color:#24273a - style D fill:#a6da95,color:#24273a - style E fill:#91d7e3,color:#24273a -`} -/> - -## component breakdown - - - -the client runs on your local machine, alongside the service you want to expose. its primary responsibilities are: -- establishing and maintaining a persistent websocket connection to the funnel server. -- when a request is received from the server, it forwards it to the specified local service (e.g., `localhost:3000`). -- it captures the response from the local service and sends it back to the server over the websocket. -- it automatically reconnects to the server if the connection is lost. - - -the server runs on a publicly accessible machine (e.g., a vps). its responsibilities are: -- listening for websocket connections from funnel clients. -- for each connected client, it registers a unique tunnel id and manages its lifecycle. -- listening for public http traffic on a specific port (e.g., 8080). -- when a request arrives at a tunnel's public url (e.g., `your-id.your-server.com`), it proxies that request to the corresponding client over the correct websocket. -- it then waits for the response from the client and forwards it back to the original requester. - - - -## communication protocol - - - the entire system is built around the websocket protocol. this allows for a persistent, bi-directional communication channel between the client and server, which is more efficient than traditional http polling for this use case. - - - - -**connection**: the client initiates a websocket connection to the server's `/ws` endpoint, providing its desired tunnel id in the query parameters (`/ws?id=my-tunnel`). - - - -**registration**: the server validates the id. if it's available, it creates a new `tunnel` instance and associates it with the websocket connection. - - - -**request proxying**: - - an http request arrives at `my-tunnel.server.com`. - - the server's router extracts the subdomain (`my-tunnel`), finds the active tunnel, and constructs a json message containing the request details (method, path, headers, body). - - this message is sent down the websocket to the client. - - - -**local forwarding**: - - the client receives the message, reconstructs the http request, and forwards it to the local service. - - - -**response proxying**: - - the local service returns a response. - - the client captures this response, serializes it into a json message, and sends it back to the server. - - - -**final response**: - - the server receives the response message and writes its contents (status code, headers, body) back to the original http requester. - - \ No newline at end of file diff --git a/docs/content/docs/core-concepts/meta.json b/docs/content/docs/core-concepts/meta.json deleted file mode 100644 index 0e46b51..0000000 --- a/docs/content/docs/core-concepts/meta.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "core concepts", - "icon": "LightbulbIcon", - "pages": ["architecture"] -} diff --git a/docs/content/docs/getting-started/basic-usage.mdx b/docs/content/docs/getting-started/basic-usage.mdx deleted file mode 100644 index c206529..0000000 --- a/docs/content/docs/getting-started/basic-usage.mdx +++ /dev/null @@ -1,172 +0,0 @@ ---- -title: basic usage -icon: RocketLaunchIcon -description: a step-by-step guide to get your first tunnel running. ---- - -import { Steps } from 'fumadocs-ui/components/steps'; -import { Callout } from 'fumadocs-ui/components/callout'; -import { Card, Cards } from 'fumadocs-ui/components/card'; - - -this guide walks you through creating your first tunnel, from starting a local service to accessing it from anywhere on the internet. - - -## prerequisites - - - - - - - - -for convenience, you can set up configuration files to save server endpoints. this allows you to use `funnel http 3000` instead of specifying the full server URL each time. - - - -for client authentication, currently there is none. -what i recommend is throwing a uuid at a subdomain and hoping it sticks like spaghetti on the wall. well yea funnel uses letsencrypt for the domain, so crt.sh indexes it faster than i can say "oops i committed my api keys again". - -some buzzkills would call this a "security nightmare". some would say it's like using tissues as a bulletproof vest. but hey, at least it's documented now! that's basically the same as fixing it, right? - -well yea it is a little unoptimal - -but honestly at this point security is just performance art. we're all just improvising until someone gets pwned. - - - - -### start a local service - -first, you need a local web service to expose. if you don't have one, you can start a simple python web server. - - -this python server will serve the current directory on port 3000, perfect for testing your tunnel. - - -```bash -# this will serve the current directory on port 3000 -python3 -m http.server 3000 -``` - -you should see output like: -``` -Serving HTTP on 0.0.0.0 port 3000 (http://0.0.0.0:3000/) ... -``` - -### start the funnel server - -next, start the funnel server using docker. this is the remote component that will listen for public traffic. - - -the server acts as a bridge between public internet traffic and your local client. it runs independently and can serve multiple clients. - - -```bash -# starts the server on the default port 8080 -docker run -d --name funnel-server -p 8080:8080 ghcr.io/karol-broda/funnel-server:latest -``` - -verify the server is running: -```bash -docker logs funnel-server -``` - -you should see logs indicating the server is listening on port 8080. - -### (optional) set up configuration - -for convenience, you can create a configuration file to save your server settings. this allows you to use shorter commands in the future. - - -you must create the configuration file manually. the client will not create a default config automatically. - - -create a config file at `~/.config/funnel/config.toml` with this structure: - -```toml -[inlets.default] -server = "http://localhost:8080" -domain = "localhost:8080" -``` - -you can add more inlets for different environments: - -```toml -[inlets.default] -server = "http://localhost:8080" -domain = "localhost:8080" - -[inlets.production] -server = "https://api.tunneling.dev" -domain = "tunneling.dev" -``` - -### connect the funnel client - -now, connect your local service to the server. this command tells the funnel client to connect to your remote server and forward traffic to your local service on port 3000. - - -we're using the custom subdomain `demo` for this example. you can choose any available subdomain name. - - -```bash -# connect to the server, forwarding to localhost:3000 -funnel http 3000 --server http://localhost:8080 --id demo -``` - - -if you set up configuration, you can use the shorter command: -```bash -funnel http 3000 --id demo -``` - - -you should see output similar to: -``` -โœ“ Connected to server -โœ“ Tunnel active: http://demo.localhost:8080 -โœ“ Forwarding to localhost:3000 -``` - -### access your service - - -your local service is now exposed to the world! you can access it from any device with internet connectivity. - - -test your tunnel: -```bash -curl http://demo.localhost:8080 -``` - -you should see the response from your local python web server. the request flow is: -1. **curl** โ†’ **funnel server** โ†’ **websocket** โ†’ **funnel client** โ†’ **localhost:3000** -2. **localhost:3000** โ†’ **funnel client** โ†’ **websocket** โ†’ **funnel server** โ†’ **curl** - - - -## what's next? - - - - - - - - -you've successfully created your first tunnel! for production use, consider deploying your own server with TLS enabled. - \ No newline at end of file diff --git a/docs/content/docs/getting-started/contexts.mdx b/docs/content/docs/getting-started/contexts.mdx new file mode 100644 index 0000000..1bd45bd --- /dev/null +++ b/docs/content/docs/getting-started/contexts.mdx @@ -0,0 +1,78 @@ +--- +title: Contexts +description: Manage connections to multiple funnel servers +--- + +Contexts let you configure and switch between multiple funnel servers. Each context stores a server URL and authentication token. + +## Config file + +Contexts are stored in `~/.config/funnel/config.toml`: + +```toml +current_context = "production" + +[contexts.production] +server = "https://tunnel.example.com" +token = "fnl_abc123..." + +[contexts.staging] +server = "https://staging.tunnel.example.com" +token = "fnl_def456..." +``` + +The file is created with `0600` permissions to protect tokens. + +## Create a context + +```bash +funnel context create production --server https://tunnel.example.com +``` + +The first context you create automatically becomes the active one. + +## Switch contexts + +```bash +funnel context use staging +``` + +All subsequent commands use the staging server and token. + +## List contexts + +```bash +funnel context list +``` + +## Delete a context + +```bash +funnel context delete staging +``` + +If you delete the active context, funnel switches to the next available one. + +## Environment overrides + +Environment variables take precedence over the config file: + +| Variable | Overrides | +| --- | --- | +| `FUNNEL_CONTEXT` | Active context name | +| `FUNNEL_SERVER` | Server URL for the resolved context | +| `FUNNEL_TOKEN` | Token for the resolved context | + +This is useful in CI or scripts where you don't want to modify the config file: + +```bash +FUNNEL_SERVER=https://ci.tunnel.example.com FUNNEL_TOKEN=fnl_ci_key funnel http 3000 +``` + +## Per-command override + +You can also override the context for a single command with `--context`: + +```bash +funnel --context staging status +``` diff --git a/docs/content/docs/getting-started/installation.mdx b/docs/content/docs/getting-started/installation.mdx index 21edf5a..537a303 100644 --- a/docs/content/docs/getting-started/installation.mdx +++ b/docs/content/docs/getting-started/installation.mdx @@ -1,99 +1,51 @@ --- -title: installation -icon: DownloadSimpleIcon -description: how to install the funnel client and server. +title: Installation +description: Install the funnel client and server --- -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; -import { Callout } from 'fumadocs-ui/components/callout'; -import { File, Folder, Files } from 'fumadocs-ui/components/files'; +## Nix flake -get started with funnel by installing the client and running the server using docker. +Add funnel as a flake input: -## client installation +```nix +{ + inputs.funnel.url = "github:karol-broda/funnel"; +} +``` - - -the easiest way to install the `funnel` client: +Then use the package directly: ```bash -curl -LsSf https://raw.githubusercontent.com/karol-broda/funnel/master/scripts/install.sh | sh +nix run github:karol-broda/funnel -- http 3000 ``` -supports linux and macos on both amd64 and arm64 architectures. - - - -download from the [releases page](https://github.com/karol-broda/funnel/releases) and install: +Or install it to your profile: ```bash -# example for linux amd64 -sudo mv funnel /usr/local/bin/funnel -sudo chmod +x /usr/local/bin/funnel +nix profile install github:karol-broda/funnel ``` - - -requires [go](https://go.dev/doc/install) 1.21 or later: +See [NixOS module](/docs/nix/nixos-module) and [Home Manager](/docs/nix/home-manager) for declarative setups. + +## Cargo ```bash -git clone https://github.com/karol-broda/funnel.git -cd funnel -make build-client -sudo mv ./bin/funnel /usr/local/bin/funnel +cargo install funnel-client +cargo install funnel-server ``` - - -## server installation +## Binary download - -the server runs as a docker container. you'll need [docker](https://docs.docker.com/get-docker/) installed. - - -### using docker image +Pre-built binaries are available for Linux and macOS (amd64 and arm64) from [GitHub releases](https://github.com/karol-broda/funnel/releases). ```bash -# pull and verify the server image -docker pull ghcr.io/karol-broda/funnel-server:latest -docker run --rm ghcr.io/karol-broda/funnel-server:latest version +curl -fsSL https://github.com/karol-broda/funnel/releases/latest/download/install.sh | sh ``` -### building from source +The install script verifies checksums before placing binaries in your PATH. + +## Verify ```bash -git clone https://github.com/karol-broda/funnel.git -cd funnel -make build-server-docker +funnel --version ``` - -## project structure - - - - - - - - - - - - - - - - - - - - - - - - -## next steps - -**[basic usage](/docs/getting-started/basic-usage)** - create your first tunnel -**[server deployment](/docs/server/deployment)** - deploy for production -**[cli reference](/docs/reference/client-cli)** - explore all commands \ No newline at end of file diff --git a/docs/content/docs/getting-started/meta.json b/docs/content/docs/getting-started/meta.json deleted file mode 100644 index 2c10ad3..0000000 --- a/docs/content/docs/getting-started/meta.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "getting started", - "icon": "RocketLaunchIcon", - "pages": ["installation", "basic-usage"] -} diff --git a/docs/content/docs/getting-started/quickstart.mdx b/docs/content/docs/getting-started/quickstart.mdx new file mode 100644 index 0000000..efae066 --- /dev/null +++ b/docs/content/docs/getting-started/quickstart.mdx @@ -0,0 +1,64 @@ +--- +title: Quickstart +description: Get a tunnel running in under two minutes +--- + +This guide assumes you have a funnel server running and the client installed. If not, see [installation](/docs/getting-started/installation) and [server deployment](/docs/server/deployment). + +## Connect to a server + +Create a context pointing to your server: + +```bash +funnel context create default --server https://tunnel.example.com +``` + +## Authenticate + +Log in via OAuth (GitHub by default): + +```bash +funnel login +``` + +This opens your browser, completes the OAuth flow, and stores the token in your local config. + +## Start a tunnel + +Expose a local service on port 3000: + +```bash +funnel http 3000 +``` + +``` +funnel + + public url https://abc123.tunnel.example.com + forwarding localhost:3000 + tunnel id abc123 +``` + +The tunnel is live. Requests to the public URL are forwarded to your local service over QUIC. + +## Custom subdomain + +Pick your own tunnel ID: + +```bash +funnel http 3000 --id my-app +``` + +This gives you `https://my-app.tunnel.example.com`. + +## Check active tunnels + +```bash +funnel status +``` + +## Next steps + +- [Contexts](/docs/getting-started/contexts): manage multiple servers +- [CLI reference](/docs/client/cli): all commands and flags +- [Teams](/docs/server/teams): team-scoped tunnels diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index 98a14fd..7b97172 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -1,62 +1,23 @@ --- -title: introduction -icon: HouseIcon -description: expose local services to the internet through websocket connections +title: funnel +description: Self-hosted tunneling over QUIC --- -import { Card, Cards } from 'fumadocs-ui/components/card'; -import { Callout } from 'fumadocs-ui/components/callout'; +Funnel exposes local services through secure tunnels. It runs over QUIC with per-request streams, handles TLS automatically via Let's Encrypt, and ships as a single binary for both server and client. -
- -

๐Ÿ•ณ๏ธ funnel

- -*a tunneling solution built with go* - -**expose local services to the internet through websocket connections** - -
- - -this tunneling solution is intended for development and testing purposes. while functional, it may not include all security features required for production environments. - - -funnel is a lightweight tunneling solution that allows you to expose your local development services to the internet. it works by creating a persistent websocket connection between a client on your machine and a remote server. - -## key features - -- **http tunneling**: expose any local http service with a single command -- **secure connections**: supports tls with let's encrypt for secure tunnels -- **custom subdomains**: use your own memorable tunnel ids -- **automatic reconnection**: client automatically reconnects if connection is lost -- **cross-platform**: single binary for linux, macos, and windows - -## how it works - -the flow is straightforward: - -1. **the funnel client** starts on your local machine and connects to **the funnel server** -2. **the funnel server** registers a unique public url for your tunnel -3. when a request comes in, the server proxies it through the websocket to the client -4. the client forwards the request to your local service -5. the response travels back through the same path - -## getting started +## Quick links - - - + + Install the client and server via Nix, Cargo, or binary download. + + + Get a tunnel running in under two minutes. + + + Run your own funnel server with PostgreSQL or embedded Turso. + + + How QUIC streams, tunnel routing, and request proxying work. + diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 5ff077b..dac3996 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -1,12 +1,26 @@ { - "title": "documentation", - "icon": "BookOpenIcon", + "title": "Documentation", + "root": true, "pages": [ "index", - "getting-started", - "core-concepts", - "reference", - "server", + "---Getting Started---", + "getting-started/installation", + "getting-started/quickstart", + "getting-started/contexts", + "---Server---", + "server/deployment", + "server/tls", + "server/authentication", + "server/teams", + "---Client---", + "client/cli", + "---NixOS---", + "nix/flake", + "nix/nixos-module", + "nix/home-manager", + "---Reference---", + "reference/architecture", + "reference/server-api", "roadmap" ] } diff --git a/docs/content/docs/nix/flake.mdx b/docs/content/docs/nix/flake.mdx new file mode 100644 index 0000000..dfd2d41 --- /dev/null +++ b/docs/content/docs/nix/flake.mdx @@ -0,0 +1,58 @@ +--- +title: Flake +description: Using funnel as a Nix flake input +--- + +Funnel provides a Nix flake with packages, NixOS modules, and Home Manager modules. + +## Add as input + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + funnel.url = "github:karol-broda/funnel"; + }; +} +``` + +## Packages + +The flake exports packages for the client and server: + +```nix +{ + # use directly + environment.systemPackages = [ + inputs.funnel.packages.${system}.default # client + inputs.funnel.packages.${system}.server + ]; +} +``` + +## Overlay + +Apply the overlay to get funnel packages in your pkgs set: + +```nix +{ + nixpkgs.overlays = [ inputs.funnel.overlays.default ]; +} +``` + +Then reference as `pkgs.funnel` and `pkgs.funnel-server`. + +## Run without installing + +```bash +nix run github:karol-broda/funnel -- http 3000 +nix run github:karol-broda/funnel#server -- --seed-api-key +``` + +## OCI containers + +The flake also builds OCI container images: + +```nix +inputs.funnel.packages.${system}.oci-server +``` diff --git a/docs/content/docs/nix/home-manager.mdx b/docs/content/docs/nix/home-manager.mdx new file mode 100644 index 0000000..c8bda27 --- /dev/null +++ b/docs/content/docs/nix/home-manager.mdx @@ -0,0 +1,74 @@ +--- +title: Home Manager +description: Declarative client configuration with Home Manager +--- + +The funnel Home Manager module manages the client config file declaratively. + +## Import + +```nix +{ + imports = [ inputs.funnel.homeManagerModules.default ]; +} +``` + +## Basic configuration + +```nix +{ + programs.funnel = { + enable = true; + settings = { + currentContext = "production"; + contexts.production = { + server = "https://tunnel.example.com"; + }; + }; + }; +} +``` + +This generates `~/.config/funnel/config.toml` with the specified settings. + +## Multiple contexts + +```nix +{ + programs.funnel.settings.contexts = { + production = { + server = "https://tunnel.example.com"; + }; + staging = { + server = "https://staging.tunnel.example.com"; + }; + }; +} +``` + +## Tokens with sops-nix + +For tokens that shouldn't be in the Nix store, use `tokenFile`: + +```nix +{ + programs.funnel.settings.contexts.production = { + server = "https://tunnel.example.com"; + tokenFile = config.sops.secrets."funnel/production-token".path; + }; +} +``` + +When any context uses `tokenFile`, the module generates the config at activation time instead of symlinking to the Nix store, substituting the file contents at the token placeholder. + +## Environment variables + +Set environment variables for the funnel client: + +```nix +{ + programs.funnel.environment = { + FUNNEL_LOG_LEVEL = "debug"; + }; +} +``` diff --git a/docs/content/docs/nix/nixos-module.mdx b/docs/content/docs/nix/nixos-module.mdx new file mode 100644 index 0000000..546e48c --- /dev/null +++ b/docs/content/docs/nix/nixos-module.mdx @@ -0,0 +1,169 @@ +--- +title: NixOS Module +description: Declarative server deployment with NixOS +--- + +The funnel NixOS module runs the server as a systemd service with hardened security settings. + +## Import + +```nix +{ + imports = [ inputs.funnel.nixosModules.default ]; +} +``` + +## Basic configuration + +```nix +{ + services.funnel.server = { + enable = true; + host = "0.0.0.0"; + port = 8080; + quicPort = 4433; + seedApiKey = true; + }; +} +``` + +## Database + +### Embedded Turso + +```nix +{ + services.funnel.server = { + enable = true; + database.tursoPath = "/var/lib/funnel/funnel.db"; + }; +} +``` + +### PostgreSQL + +```nix +{ + services.funnel.server = { + enable = true; + database = { + postgresUrl = "postgres://funnel:password@localhost/funnel"; + maxConnections = 20; + }; + }; +} +``` + +To create a local PostgreSQL database automatically: + +```nix +{ + services.funnel.server = { + enable = true; + database.createLocally = true; + }; +} +``` + +## TLS + +```nix +{ + services.funnel.server = { + enable = true; + tls = { + enable = true; + port = 443; + certDir = "/var/lib/funnel/certs"; + acme = { + email = "admin@example.com"; + staging = false; + dnsProvidersConfigFile = "/run/secrets/dns-providers.json"; + }; + }; + }; +} +``` + +## OAuth + +### GitHub + +```nix +{ + services.funnel.server = { + enable = true; + auth = { + baseUrl = "https://tunnel.example.com"; + initialAdminEmail = "admin@example.com"; + github.clientId = "your-client-id"; + }; + # secret via environment file + environmentFile = "/run/secrets/funnel-env"; + }; +} +``` + +The environment file should contain: + +``` +GITHUB_CLIENT_SECRET=your-client-secret +``` + +### Generic OIDC + +```nix +{ + services.funnel.server.auth.oauth = { + providerName = "gitlab"; + clientId = "your-client-id"; + authorizeUrl = "https://gitlab.com/oauth/authorize"; + tokenUrl = "https://gitlab.com/oauth/token"; + userinfoUrl = "https://gitlab.com/api/v4/user"; + }; +} +``` + +## Secrets + +Use `environmentFile` or `environmentFiles` to inject secrets without putting them in the Nix store: + +```nix +{ + services.funnel.server.environmentFile = "/run/secrets/funnel-env"; +} +``` + +This integrates with sops-nix, agenix, or any secret management tool that writes files. + +## Firewall + +```nix +{ + services.funnel.server = { + enable = true; + openFirewall = true; # opens port and quicPort + }; +} +``` + +## Systemd hardening + +The module applies these security settings by default: + +- `DynamicUser`: runs as a transient system user +- `PrivateTmp`: isolated temp directory +- `ProtectSystem=strict`: read-only filesystem +- `ProtectHome`: no access to home directories +- `NoNewPrivileges`: prevents privilege escalation +- Restricted syscall filters and namespaces + +Override with `serviceConfig`: + +```nix +{ + services.funnel.server.serviceConfig = { + MemoryMax = "512M"; + }; +} +``` diff --git a/docs/content/docs/reference/architecture.mdx b/docs/content/docs/reference/architecture.mdx new file mode 100644 index 0000000..211e55a --- /dev/null +++ b/docs/content/docs/reference/architecture.mdx @@ -0,0 +1,72 @@ +--- +title: Architecture +description: How funnel tunnels work under the hood +--- + +## Overview + +Funnel is a client-server system where the client maintains a persistent QUIC connection to the server. When a public request arrives at the server, it opens a new QUIC stream to the client, which forwards the request to the local service and sends the response back. + +``` + QUIC connection + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” (persistent, muxed) โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Client โ”‚ โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ โ”‚ Server โ”‚ + โ””โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ + โ”‚ HTTP HTTP โ”‚ + โ–ผ โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Local โ”‚ โ”‚ Public โ”‚ + โ”‚ Serviceโ”‚ โ”‚ Internet โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Why QUIC + +Funnel uses QUIC instead of WebSocket or plain TCP for tunnel transport: + +- **Per-request streams**: each HTTP request gets its own QUIC stream. No head-of-line blocking. A slow response on one request doesn't delay others. +- **Built-in multiplexing**: multiple concurrent requests share a single connection without framing overhead. +- **Connection migration**: QUIC connections survive network changes (e.g. switching from WiFi to cellular). +- **0-RTT reconnection**: after the initial handshake, reconnections can send data immediately. + +## Connection flow + +1. Client connects to the server's QUIC port (default `4433`) +2. Client opens an initial stream and sends a registration message with the tunnel ID and auth token +3. Server validates the token, checks for ID conflicts, and registers the tunnel +4. Server assigns a subdomain: `.` +5. The QUIC connection stays open. The server opens new streams as requests arrive. + +## Request proxying + +When a request arrives at `.`: + +1. Server matches the subdomain to a registered tunnel +2. Server opens a new QUIC stream to the client +3. Server serializes the HTTP request (method, path, headers, body) and sends it over the stream +4. Client deserializes the request and forwards it to the local service via HTTP +5. Client reads the local response, serializes it, and sends it back over the same stream +6. Server reconstructs the HTTP response and sends it to the original caller + +Each request uses its own stream, so the server can handle many concurrent requests to the same tunnel without coordination. + +## WebSocket forwarding + +WebSocket upgrade requests are detected and handled specially. The tunnel transparently forwards WebSocket frames between the public client and the local service, maintaining the bidirectional connection. + +## Reconnection + +If the QUIC connection drops, the client reconnects with exponential backoff. During reconnection, the tunnel subdomain is unavailable. Once reconnected, the same tunnel ID is re-registered (if still available). + +## Subdomain routing + +The server uses a middleware layer that inspects the `Host` header of incoming requests. If the subdomain matches a registered tunnel, the request is routed to the tunnel proxy. Otherwise, it falls through to the API routes. + +## Proxy headers + +The server adds standard proxy headers to forwarded requests: + +- `X-Forwarded-For`: client IP address +- `X-Forwarded-Host`: original host header +- `X-Forwarded-Proto`: `http` or `https` diff --git a/docs/content/docs/reference/client-cli.mdx b/docs/content/docs/reference/client-cli.mdx deleted file mode 100644 index d3f0d5d..0000000 --- a/docs/content/docs/reference/client-cli.mdx +++ /dev/null @@ -1,333 +0,0 @@ ---- -title: client cli -icon: TerminalIcon -description: a reference for all `funnel` client command-line flags. ---- - -import { Callout } from 'fumadocs-ui/components/callout'; -import { Card, Cards } from 'fumadocs-ui/components/card'; -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; - - -the `funnel` client is the command-line tool you use on your local machine to connect your service to a funnel server. - - -## primary commands - - - - - - - -## `funnel http` - -this is the primary command for creating an http tunnel. - - -```bash -funnel http [address:port | port] [flags] -``` - - -### arguments - - -you can specify either just a port or a full address:port combination. - - -**`[address:port | port]`** (required): the local address of the service you want to expose. -- if you provide just a port (e.g., `3000`), it will default to `localhost:3000`. -- you can provide a full address (e.g., `127.0.0.1:8080`). - -### flags - -| flag | shorthand | default | description | -| :-------- | :-------- | :----------------------- | :----------------------------------------------------------------------- | -| `--server` | `-s` | (from config) | the url of the remote funnel server. use `http://` or `https://`. overrides config. | -| `--id` | `-i` | (randomly generated) | the custom subdomain to request for the tunnel. | -| `--inlet` | | `default` | the inlet configuration to use from your config file. | -| `--token` | `-t` | (from config) | authentication token for the server. overrides config. | -| `--help` | `-h` | | show help for the command. | - -## configuration - -the funnel client supports configuration files to save and reuse server endpoints. this allows you to quickly switch between different tunnel servers without specifying the full URL each time. - -### config file location - -the client looks for configuration in the following order: -1. `$XDG_CONFIG_HOME/funnel/config.toml` (if `XDG_CONFIG_HOME` is set) -2. `~/.config/funnel/config.toml` (default location) - - -use `funnel config set-server` and `funnel config set-token` to create and manage your config file automatically. - - -### config file format - -configuration uses TOML format with the following structure: - -```toml -[inlets.default] -server = "http://localhost:8080" -domain = "localhost:8080" -token = "sk_your_token_here" - -[inlets.production] -server = "https://api.tunneling.dev" -domain = "tunneling.dev" -token = "sk_production_token" - -[inlets.staging] -server = "https://staging.tunneling.dev" -domain = "staging.tunneling.dev" -token = "sk_staging_token" -``` - -### inlet configuration - -each inlet section contains: -- **`server`** (required): the URL of the tunnel server -- **`domain`** (optional): the domain for display purposes -- **`token`** (optional): authentication token for the server - - -the token is stored in plain text in the config file. the file is created with restricted permissions (0600), but ensure your machine is secure. - - -### using inlets - - -use the `--inlet` flag to specify which configuration to use, or omit it to use the default inlet. - - -```bash -# use default inlet -funnel http 3000 - -# use specific inlet -funnel http 3000 --inlet production - -# override config with command line -funnel http 3000 --inlet production --server https://custom.example.com -``` - - -if no configuration file exists or the specified inlet doesn't exist, the client will require you to specify the `--server` flag explicitly. - - -### examples - - - -expose a service on port 3000, using the default inlet: - -```bash -funnel http 3000 -``` - - -this will use the default inlet from your config file, or `localhost:8080` if no config exists. - - - - -use a specific inlet configuration: - -```bash -# use production inlet -funnel http 3000 --inlet production - -# use staging inlet with custom id -funnel http 3000 --inlet staging --id my-app-staging -``` - - -perfect for switching between different environments (development, staging, production) without remembering server URLs. - - - - -connect to a remote server with a custom subdomain: - -```bash -funnel http 3000 --server https://my-server.com --id my-app-demo -``` - - -use `https://` for secure connections when connecting to a server with TLS enabled. - - - - -expose a service on a different host and port: - -```bash -funnel http 192.168.1.100:8000 --server http://tunnel.mycompany.com:8080 --id staging-api -``` - - -you can expose services running on other machines in your network, but ensure they're accessible from your client machine. - - - - -## `funnel config` - -manage client configuration, including server urls and authentication tokens. - -### subcommands - - - -save an authentication token to your config file: - -```bash -funnel config set-token sk_your_token_here - -# for a specific inlet -funnel config set-token sk_prod_token --inlet production -``` - - -the token is stored in plain text in the config file. the file is created with restricted permissions (0600). - - - - -save a server url to your config file: - -```bash -funnel config set-server https://tunnel.example.com - -# for a specific inlet -funnel config set-server https://prod.tunnel.io --inlet production -``` - - - -display the current configuration: - -```bash -funnel config show - -# output: -# Config file: ~/.config/funnel/config.toml -# -# [default] -# server: http://localhost:8080 -# token: sk_7Fj2kL... -# -# [production] -# server: https://tunnel.example.com -# token: sk_prod_t... -``` - - - -show the config file path: - -```bash -funnel config path - -# output: -# /home/user/.config/funnel/config.toml -``` - - - -### config flags - -| flag | default | description | -| :--- | :------ | :---------- | -| `--inlet` | `default` | the inlet to configure | - -## `funnel version` - -displays the version of the funnel client. - -```bash -funnel version -``` - - -useful for verifying your installation and checking for updates. - - -## usage patterns - -### development workflow - - - - - - - -### common scenarios - - - -expose your local development server: - -```bash -# react development server -funnel http 3000 --inlet local --id my-react-app - -# next.js development server -funnel http 3000 --inlet local --id my-nextjs-app - -# vite development server -funnel http 5173 --inlet local --id my-vite-app -``` - - - -expose your local api for testing: - -```bash -# express api server -funnel http 8000 --inlet staging --id my-api-v1 - -# django development server -funnel http 8000 --inlet staging --id django-api - -# flask development server -funnel http 5000 --inlet staging --id flask-api -``` - - - -expose your local server for mobile app testing: - -```bash -# react native metro bundler -funnel http 8081 --inlet local --id rn-bundler - -# local api for mobile app -funnel http 3000 --inlet staging --id mobile-api -``` - - -perfect for testing your mobile app against a local backend without complex network configuration. - - - - -## troubleshooting - - -if you encounter connection issues, check these common problems: - - -- **connection refused**: ensure the server is running and accessible -- **tunnel id taken**: try a different `--id` or let the system generate one -- **local service not found**: verify your local service is running on the specified port -- **firewall blocking**: check that your firewall allows outbound websocket connections -- **inlet not found**: verify the inlet exists in your config file or use `--server` to override -- **config file not found**: create a config file manually at `~/.config/funnel/config.toml` or use `--server` flag - - -for server-specific configuration, see the [server CLI reference](/docs/reference/server-cli). - \ No newline at end of file diff --git a/docs/content/docs/reference/meta.json b/docs/content/docs/reference/meta.json deleted file mode 100644 index 709ce62..0000000 --- a/docs/content/docs/reference/meta.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "reference", - "icon": "FilesIcon", - "pages": ["client-cli", "server-cli", "server-api"] -} diff --git a/docs/content/docs/reference/server-api/api-keys.mdx b/docs/content/docs/reference/server-api/api-keys.mdx new file mode 100644 index 0000000..8189588 --- /dev/null +++ b/docs/content/docs/reference/server-api/api-keys.mdx @@ -0,0 +1,29 @@ +--- +title: API Keys +description: API key management +full: true +_openapi: + toc: + - depth: 2 + title: List_keys + url: '#list_keys' + - depth: 2 + title: Create_key + url: '#create_key' + - depth: 2 + title: Revoke_key + url: '#revoke_key' + structuredData: + headings: + - content: List_keys + id: list_keys + - content: Create_key + id: create_key + - content: Revoke_key + id: revoke_key + contents: [] +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/index.mdx b/docs/content/docs/reference/server-api/index.mdx index 9e61e41..5958235 100644 --- a/docs/content/docs/reference/server-api/index.mdx +++ b/docs/content/docs/reference/server-api/index.mdx @@ -1,76 +1,17 @@ --- title: Server API -description: Complete REST API reference for the Funnel Server -full: true +description: REST API reference for the funnel tunnel server --- -## Overview +The funnel server exposes a REST API at `/api/v1`. All endpoints except health and info require authentication via `Authorization: Bearer `. -The Funnel Server API provides comprehensive endpoints for tunnel management, monitoring, and statistics. All endpoints return JSON responses and follow RESTful conventions. +Browse the API reference by section: -## Base URL +- [Server](/docs/reference/server-api/server) - Health check and server info +- [Tunnels](/docs/reference/server-api/tunnels) - Tunnel management +- [API Keys](/docs/reference/server-api/api-keys) - API key management +- [Profile](/docs/reference/server-api/profile) - Current user profile, accounts, sessions +- [Users](/docs/reference/server-api/users) - User management (admin) +- [Teams](/docs/reference/server-api/teams) - Team management -``` -http://localhost:8080/api -``` - -## Authentication - -Currently, the API does not require authentication. This will be added in future versions. - -## Response Format - -All API responses follow this structure: - -```json -{ - "success": true, - "data": {...}, - "error": null -} -``` - -## Endpoints - -The API is organized into the following groups: - -### Server Endpoints -- **Health Check** - Basic server health status -- **Server Stats** - Basic server statistics -- **Metrics** - Comprehensive server-wide metrics -- **Historical Metrics** - Historical server data with time ranges - -### Tunnel Endpoints -- **List Tunnels** - Get all active tunnels -- **Tunnel Info** - Get specific tunnel information -- **Tunnel Stats** - Basic tunnel statistics -- **Tunnel Metrics** - Comprehensive tunnel metrics -- **Tunnel Historical** - Historical tunnel data -- **Delete Tunnel** - Remove a tunnel - -## Interactive Documentation - -Each endpoint has detailed interactive documentation with: -- Request/response schemas -- Example requests and responses -- Try-it-out functionality -- Code samples in multiple languages - -Browse the endpoints in the navigation sidebar to explore the complete API. - -## Error Handling - -Error responses include: -- HTTP status codes (400, 404, 500, etc.) -- Descriptive error messages in the `error` field -- `success: false` in the response body - -## Rate Limiting - -Currently, no rate limiting is implemented. - -## OpenAPI Specification - -The complete OpenAPI specification is available at: -- **JSON**: `http://localhost:8080/api/swagger/doc.json` -- **Swagger UI**: `http://localhost:8080/swagger/` +The interactive API reference is also available at `/api/v1/scalar` on a running server. diff --git a/docs/content/docs/reference/server-api/meta.json b/docs/content/docs/reference/server-api/meta.json index 0c6dd0a..3580db0 100644 --- a/docs/content/docs/reference/server-api/meta.json +++ b/docs/content/docs/reference/server-api/meta.json @@ -1,8 +1,12 @@ { "title": "Server API", - "icon": "CodeIcon", "pages": [ + "index", "server", - "tunnels" + "tunnels", + "api-keys", + "profile", + "users", + "teams" ] } diff --git a/docs/content/docs/reference/server-api/profile.mdx b/docs/content/docs/reference/server-api/profile.mdx new file mode 100644 index 0000000..7a198e5 --- /dev/null +++ b/docs/content/docs/reference/server-api/profile.mdx @@ -0,0 +1,29 @@ +--- +title: Profile +description: Current user profile and sessions +full: true +_openapi: + toc: + - depth: 2 + title: List_accounts + url: '#list_accounts' + - depth: 2 + title: Get_current_user + url: '#get_current_user' + - depth: 2 + title: List_sessions + url: '#list_sessions' + structuredData: + headings: + - content: List_accounts + id: list_accounts + - content: Get_current_user + id: get_current_user + - content: List_sessions + id: list_sessions + contents: [] +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/server.mdx b/docs/content/docs/reference/server-api/server.mdx new file mode 100644 index 0000000..0c480f8 --- /dev/null +++ b/docs/content/docs/reference/server-api/server.mdx @@ -0,0 +1,24 @@ +--- +title: Server +description: Server health and info +full: true +_openapi: + toc: + - depth: 2 + title: Get_health + url: '#get_health' + - depth: 2 + title: Get_server_info + url: '#get_server_info' + structuredData: + headings: + - content: Get_health + id: get_health + - content: Get_server_info + id: get_server_info + contents: [] +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/server/health/get.mdx b/docs/content/docs/reference/server-api/server/health/get.mdx deleted file mode 100644 index 7577b69..0000000 --- a/docs/content/docs/reference/server-api/server/health/get.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Health check -full: true -_openapi: - method: GET - route: /health - toc: [] - structuredData: - headings: [] - contents: - - content: Returns the health status of the server ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns the health status of the server - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/server/health/meta.json b/docs/content/docs/reference/server-api/server/health/meta.json deleted file mode 100644 index d0524a2..0000000 --- a/docs/content/docs/reference/server-api/server/health/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Health Check", - "pages": ["get"] -} diff --git a/docs/content/docs/reference/server-api/server/meta.json b/docs/content/docs/reference/server-api/server/meta.json deleted file mode 100644 index f20ea87..0000000 --- a/docs/content/docs/reference/server-api/server/meta.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "title": "Server", - "icon": "HardDrivesIcon", - "pages": [ - "health", - "metrics", - "server" - ] -} diff --git a/docs/content/docs/reference/server-api/server/metrics/get.mdx b/docs/content/docs/reference/server-api/server/metrics/get.mdx deleted file mode 100644 index 6e634e9..0000000 --- a/docs/content/docs/reference/server-api/server/metrics/get.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: Get comprehensive server metrics -full: true -_openapi: - method: GET - route: /metrics - toc: [] - structuredData: - headings: [] - contents: - - content: >- - Returns detailed server-wide metrics including performance, resource - usage, and aggregated statistics ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns detailed server-wide metrics including performance, resource usage, and aggregated statistics - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/server/metrics/historical/get.mdx b/docs/content/docs/reference/server-api/server/metrics/historical/get.mdx deleted file mode 100644 index 1a819e0..0000000 --- a/docs/content/docs/reference/server-api/server/metrics/historical/get.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: Get historical server metrics -full: true -_openapi: - method: GET - route: /metrics/historical - toc: [] - structuredData: - headings: [] - contents: - - content: >- - Returns time-series data for server-wide metrics over a specified time - range ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns time-series data for server-wide metrics over a specified time range - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/server/metrics/historical/meta.json b/docs/content/docs/reference/server-api/server/metrics/historical/meta.json deleted file mode 100644 index fa070ab..0000000 --- a/docs/content/docs/reference/server-api/server/metrics/historical/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Historical", - "pages": ["get"] -} diff --git a/docs/content/docs/reference/server-api/server/metrics/meta.json b/docs/content/docs/reference/server-api/server/metrics/meta.json deleted file mode 100644 index f3de3d2..0000000 --- a/docs/content/docs/reference/server-api/server/metrics/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Metrics", - "pages": ["get", "historical"] -} diff --git a/docs/content/docs/reference/server-api/server/server/meta.json b/docs/content/docs/reference/server-api/server/server/meta.json deleted file mode 100644 index 1c3f889..0000000 --- a/docs/content/docs/reference/server-api/server/server/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Server Stats", - "pages": ["stats"] -} diff --git a/docs/content/docs/reference/server-api/server/server/stats/get.mdx b/docs/content/docs/reference/server-api/server/server/stats/get.mdx deleted file mode 100644 index 5febf11..0000000 --- a/docs/content/docs/reference/server-api/server/server/stats/get.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: Get server statistics -full: true -_openapi: - method: GET - route: /server/stats - toc: [] - structuredData: - headings: [] - contents: - - content: >- - Returns comprehensive server-wide statistics including tunnel count, - request metrics, and cache performance ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns comprehensive server-wide statistics including tunnel count, request metrics, and cache performance - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/server/server/stats/meta.json b/docs/content/docs/reference/server-api/server/server/stats/meta.json deleted file mode 100644 index 79e89a2..0000000 --- a/docs/content/docs/reference/server-api/server/server/stats/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Stats", - "pages": ["get"] -} diff --git a/docs/content/docs/reference/server-api/teams.mdx b/docs/content/docs/reference/server-api/teams.mdx new file mode 100644 index 0000000..2d9f795 --- /dev/null +++ b/docs/content/docs/reference/server-api/teams.mdx @@ -0,0 +1,49 @@ +--- +title: Teams +description: Team management +full: true +_openapi: + toc: + - depth: 2 + title: List_teams + url: '#list_teams' + - depth: 2 + title: Create_team + url: '#create_team' + - depth: 2 + title: Delete_team + url: '#delete_team' + - depth: 2 + title: List_team_members + url: '#list_team_members' + - depth: 2 + title: Add_team_member + url: '#add_team_member' + - depth: 2 + title: Remove_team_member + url: '#remove_team_member' + - depth: 2 + title: Set_team_member_role + url: '#set_team_member_role' + structuredData: + headings: + - content: List_teams + id: list_teams + - content: Create_team + id: create_team + - content: Delete_team + id: delete_team + - content: List_team_members + id: list_team_members + - content: Add_team_member + id: add_team_member + - content: Remove_team_member + id: remove_team_member + - content: Set_team_member_role + id: set_team_member_role + contents: [] +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels.mdx b/docs/content/docs/reference/server-api/tunnels.mdx new file mode 100644 index 0000000..7cc8e3d --- /dev/null +++ b/docs/content/docs/reference/server-api/tunnels.mdx @@ -0,0 +1,29 @@ +--- +title: Tunnels +description: Tunnel management +full: true +_openapi: + toc: + - depth: 2 + title: List_tunnels + url: '#list_tunnels' + - depth: 2 + title: Get_tunnel + url: '#get_tunnel' + - depth: 2 + title: Delete_tunnel + url: '#delete_tunnel' + structuredData: + headings: + - content: List_tunnels + id: list_tunnels + - content: Get_tunnel + id: get_tunnel + - content: Delete_tunnel + id: delete_tunnel + contents: [] +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/meta.json b/docs/content/docs/reference/server-api/tunnels/meta.json deleted file mode 100644 index 9378ad3..0000000 --- a/docs/content/docs/reference/server-api/tunnels/meta.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Tunnels", - "icon": "LinkIcon", - "pages": [ - "tunnels" - ] -} diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/get.mdx b/docs/content/docs/reference/server-api/tunnels/tunnels/get.mdx deleted file mode 100644 index 7dfba12..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/get.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: List active tunnels -full: true -_openapi: - method: GET - route: /tunnels - toc: [] - structuredData: - headings: [] - contents: - - content: Returns a list of all currently active tunnel connections ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns a list of all currently active tunnel connections - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/meta.json b/docs/content/docs/reference/server-api/tunnels/tunnels/meta.json deleted file mode 100644 index b88a65a..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Tunnel Operations", - "pages": ["get", "tunnelid"] -} diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/delete.mdx b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/delete.mdx deleted file mode 100644 index da90271..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/delete.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Delete tunnel -full: true -_openapi: - method: DELETE - route: /tunnels/{tunnelId} - toc: [] - structuredData: - headings: [] - contents: - - content: Forcibly closes a tunnel connection ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Forcibly closes a tunnel connection - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/get.mdx b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/get.mdx deleted file mode 100644 index 6b81687..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/get.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Get tunnel details -full: true -_openapi: - method: GET - route: /tunnels/{tunnelId} - toc: [] - structuredData: - headings: [] - contents: - - content: Returns detailed information about a specific tunnel ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns detailed information about a specific tunnel - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/get.mdx b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/get.mdx deleted file mode 100644 index c67c749..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/get.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: Get tunnel historical data -full: true -_openapi: - method: GET - route: /tunnels/{tunnelId}/historical - toc: [] - structuredData: - headings: [] - contents: - - content: >- - Returns time-series data for a specific tunnel over a specified time - range ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns time-series data for a specific tunnel over a specified time range - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/meta.json b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/meta.json deleted file mode 100644 index fa070ab..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Historical", - "pages": ["get"] -} diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/meta.json b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/meta.json deleted file mode 100644 index e127078..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Specific Tunnel", - "pages": ["get", "delete", "stats", "metrics", "historical"] -} diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/get.mdx b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/get.mdx deleted file mode 100644 index d8d6923..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/get.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Get comprehensive tunnel metrics -full: true -_openapi: - method: GET - route: /tunnels/{tunnelId}/metrics - toc: [] - structuredData: - headings: [] - contents: - - content: Returns detailed performance and usage metrics for a specific tunnel ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns detailed performance and usage metrics for a specific tunnel - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/meta.json b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/meta.json deleted file mode 100644 index bd62b35..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Metrics", - "pages": ["get"] -} diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/get.mdx b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/get.mdx deleted file mode 100644 index 6128a50..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/get.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Get tunnel statistics -full: true -_openapi: - method: GET - route: /tunnels/{tunnelId}/stats - toc: [] - structuredData: - headings: [] - contents: - - content: Returns detailed statistics and metrics for a specific tunnel ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns detailed statistics and metrics for a specific tunnel - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/meta.json b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/meta.json deleted file mode 100644 index 79e89a2..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Stats", - "pages": ["get"] -} diff --git a/docs/content/docs/reference/server-api/users.mdx b/docs/content/docs/reference/server-api/users.mdx new file mode 100644 index 0000000..3ac86ea --- /dev/null +++ b/docs/content/docs/reference/server-api/users.mdx @@ -0,0 +1,34 @@ +--- +title: Users +description: User management (admin only) +full: true +_openapi: + toc: + - depth: 2 + title: List_users + url: '#list_users' + - depth: 2 + title: Deactivate_user + url: '#deactivate_user' + - depth: 2 + title: Reactivate_user + url: '#reactivate_user' + - depth: 2 + title: Set_user_role + url: '#set_user_role' + structuredData: + headings: + - content: List_users + id: list_users + - content: Deactivate_user + id: deactivate_user + - content: Reactivate_user + id: reactivate_user + - content: Set_user_role + id: set_user_role + contents: [] +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/docs/content/docs/reference/server-cli.mdx b/docs/content/docs/reference/server-cli.mdx deleted file mode 100644 index ff1c0ce..0000000 --- a/docs/content/docs/reference/server-cli.mdx +++ /dev/null @@ -1,173 +0,0 @@ ---- -title: server cli & tls -icon: TerminalWindowIcon -description: a reference for all `funnel-server` command-line flags and how to configure tls. ---- - -import { Callout } from 'fumadocs-ui/components/callout'; -import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; - - -the funnel server runs exclusively as a docker container for easy deployment and management. - - -## running the server - -basic command to start the server: - -```bash -docker run -d --name funnel-server [options] ghcr.io/karol-broda/funnel-server:latest -``` - -## configuration - - - -| variable | default | description | -| --- | --- | --- | -| `FUNNEL_PORT` | `8080` | http traffic port | -| `FUNNEL_HOST` | `0.0.0.0` | host interface to bind | -| `FUNNEL_REQUIRE_AUTH` | `false` | require token authentication | -| `FUNNEL_TOKEN_STORE` | `/var/lib/funnel/tokens.json` | token storage file path | -| `FUNNEL_ENABLE_TLS` | `false` | enable automatic tls | -| `FUNNEL_TLS_PORT` | `8443` | https traffic port | -| `FUNNEL_LETSENCRYPT_EMAIL` | - | email for let's encrypt (required for tls) | -| `FUNNEL_CERT_DIR` | `/var/lib/funnel/certs` | certificate storage directory | -| `FUNNEL_DNS_PROVIDERS_CONFIG` | - | dns provider config path (required for tls) | - - - -alternatively, pass flags directly to the container: - -| flag | shorthand | description | -| --- | --- | --- | -| `--port` | `-p` | http traffic port | -| `--require-auth` | - | require token authentication | -| `--token-store` | - | token storage file path | -| `--enable-tls` | - | enable automatic tls | -| `--tls-port` | - | https traffic port | -| `--letsencrypt-email` | - | email for let's encrypt | -| `--cert-dir` | - | certificate storage directory | -| `--dns-providers-config` | - | dns provider config path | -| `--help` | `-h` | show help | - - - -## examples - -basic server (no authentication): -```bash -docker run -d --name funnel-server -p 8080:8080 ghcr.io/karol-broda/funnel-server:latest -``` - -server with authentication: -```bash -docker run -d --name funnel-server \ - -p 8080:8080 \ - -v funnel-data:/var/lib/funnel \ - -e FUNNEL_REQUIRE_AUTH=true \ - ghcr.io/karol-broda/funnel-server:latest -``` - -server with custom port: -```bash -docker run -d --name funnel-server -p 9000:9000 -e FUNNEL_PORT=9000 ghcr.io/karol-broda/funnel-server:latest -``` - -## authentication - - -funnel supports api key-based authentication to control who can create tunnels. - - -### managing tokens - - - -```bash -# with docker -docker exec funnel-server funnel-server token create --name my-laptop - -# output: -# Token created for "my-laptop" -# -# sk_7Fj2kL9xMnPqRsTuVwXyZ0AbCdEfGhIj -# -# Save this token now - it will NOT be shown again. -# Token is persisted to disk and survives server restarts. -``` - - -save the token immediately - it cannot be retrieved later. only the hash is stored on the server. - - - - -```bash -docker exec funnel-server funnel-server token list - -# output: -# NAME PREFIX CREATED -# ---- ------ ------- -# my-laptop sk_7Fj2kL... 2 hours ago -# ci-pipeline sk_x8Yz9W... 5 days ago -``` - - - -```bash -docker exec funnel-server funnel-server token revoke --name my-laptop - -# output: -# Token "my-laptop" revoked. -``` - - - -### token storage - -tokens are hashed with sha-256 before storage. the token file is saved at `/var/lib/funnel/tokens.json` by default. - - -mount a volume at `/var/lib/funnel` to persist tokens across container restarts. - - -## tls configuration - - -funnel automatically provisions and renews tls certificates from let's encrypt using the dns-01 challenge. - - -### setup steps - -1. **create dns provider config** - see [lego documentation](https://go-acme.github.io/lego/dns/) - -```json title="dns-providers.json" -{ - "providers": [ - { - "name": "cloudflare", - "env": { - "CF_API_TOKEN": "your-cloudflare-api-token" - } - } - ] -} -``` - -2. **start with tls enabled** - -```bash -docker run -d --name funnel-server \ - -p 8080:8080 \ - -p 8443:8443 \ - -v $(pwd)/dns-providers.json:/etc/funnel/dns-providers.json \ - -v funnel-certs:/var/lib/funnel/certs \ - -e FUNNEL_ENABLE_TLS=true \ - -e FUNNEL_LETSENCRYPT_EMAIL=your-email@example.com \ - -e FUNNEL_DNS_PROVIDERS_CONFIG=/etc/funnel/dns-providers.json \ - ghcr.io/karol-broda/funnel-server:latest -``` - -certificates are generated automatically on first request and renewed before expiration. \ No newline at end of file diff --git a/docs/content/docs/roadmap.mdx b/docs/content/docs/roadmap.mdx index 8fc9a8a..be3c27a 100644 --- a/docs/content/docs/roadmap.mdx +++ b/docs/content/docs/roadmap.mdx @@ -1,172 +1,89 @@ --- -title: roadmap -icon: MapTrifoldIcon -description: current features and future plans for funnel development. +title: Roadmap +description: Planned features and future direction --- -import { Callout } from 'fumadocs-ui/components/callout'; -import { Card, Cards } from 'fumadocs-ui/components/card'; -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; - - -funnel aims to be a simple, secure, and powerful tunneling solution for developers. this roadmap outlines our progress and future plans. - - -## current status - - - - - - - - - - - - - - - - - - - - - - -our current focus is on building a web dashboard for visual tunnel management. - - - - - - - - - - - - - - -## feature breakdown - -### โœ… **what works now** - -**http tunneling** - the core functionality is complete and stable: -- websocket-based communication between client and server -- http request/response proxying through the tunnel -- subdomain-based routing for tunnel identification - -**custom tunnel ids** - flexible tunnel naming: -- use your own memorable subdomain names -- auto-generated ids using domain-safe alphabet -- 3-63 character length with validation - -**auto-reconnection** - reliable connection management: -- clients automatically reconnect when connection is lost -- exponential backoff prevents connection storms -- graceful handling of network interruptions - -**cross-platform support** - works everywhere: -- builds for linux, macos, windows -- supports both amd64 and arm64 architectures -- single binary distribution - -**custom domains** - flexible deployment: -- works with any domain name, not just localhost -- supports production deployments with real domains -- integrates with existing dns infrastructure - -**server api** - comprehensive rest api: -- complete tunnel lifecycle management (create, read, delete) -- detailed monitoring and statistics endpoints -- server health and metrics tracking -- historical data with configurable time ranges - -**tunnel statistics** - comprehensive monitoring: -- real-time request counts and bandwidth usage -- connection health and uptime monitoring -- historical data and performance analytics -- detailed metrics for individual tunnels and server-wide stats - -**openapi documentation** - interactive api docs: -- auto-generated swagger documentation from go annotations -- interactive api playground with try-it-out functionality -- comprehensive request/response schemas and examples -- integrated with fumadocs for seamless documentation experience - -**client authentication** - secure access control: -- api key-based authentication for clients -- token creation, listing, and revocation via cli -- tokens hashed with sha-256 on server (secure storage) -- client config file support for token storage -- tokens persist across server restarts - -### ๐Ÿ”„ **working on next** - -**web dashboard** - visual tunnel management: -- browser-based tunnel monitoring interface -- real-time connection status and metrics -- graphical configuration and control -- integrated with server api for complete management - -### ๐Ÿ“‹ **planned improvements** - -**client api** - programmatic client control: -- sdk for embedding client functionality -- configuration management and control -- integration with existing applications - -**https by default** - automatic security: -- automatic tls certificate generation -- secure tunnel endpoints by default -- let's encrypt integration - -**oauth integration** - enterprise security: -- secure tunnel access with oauth tokens -- integration with popular oauth providers -- token-based access control - -**tcp forwarding** - broader protocol support: -- tunnel any tcp service, not just http -- support for databases, ssh, and other tcp services -- port forwarding capabilities - -**multiple tunnels per client** - improved efficiency: -- run multiple services through a single client connection -- shared connection overhead -- simplified client management - -## contributing - - -we welcome contributions! check out our github repository to see how you can help with these features. - - -**ways to contribute:** -- **feature development** - help implement planned features -- **bug fixes** - improve stability and reliability -- **documentation** - enhance guides and examples -- **testing** - help test new features and find issues -- **feedback** - share your use cases and feature requests - -**development workflow:** -1. fork the repository -2. create feature branch: `git checkout -b feature/your-awesome-feature` -3. set up development: `make dev-setup` -4. make changes and test: `make build && make test` -5. format and lint: `make fmt && make lint` -6. commit changes: `git commit -m "description"` -7. push and create pull request - -## legend - -- **โœ… completed** - ready to use in current release -- **๐Ÿ”„ in progress** - actively being developed -- **๐Ÿ“‹ planned** - scheduled for future development - - -have ideas for features not on this roadmap? open an issue on github to discuss your suggestions! - \ No newline at end of file +## Project config file + +A `funnel.toml` in the project root that declares tunnels. `funnel up` reads it and starts everything. + +```toml +[tunnels.api] +port = 3000 +id = "my-api" +team = "backend" + +[tunnels.frontend] +port = 5173 +id = "my-frontend" +``` + +Committable to the repo so anyone on the team gets the same tunnel setup with one command. + +## Multiple tunnels per client + +Run multiple tunnels from a single process, sharing a single QUIC connection: + +```bash +funnel http 3000 --id api -- http 5173 --id frontend +``` + +## Shell completions + +```bash +funnel completion bash > /etc/bash_completion.d/funnel +funnel completion zsh > ~/.zfunc/_funnel +``` + +## Request inspection + +A local web UI at `http://127.0.0.1:4040` for inspecting requests flowing through tunnels. Live feed with headers, body, timing. Filter, replay, export as curl. + +## File serving + +```bash +funnel file ./dist +``` + +Embedded static file server with directory listing and content-type detection. + +## Tunnel access control + +```bash +funnel http 3000 --auth user:pass +funnel http 3000 --allow-ip 10.0.0.0/8 +funnel http 3000 --expires 2h +``` + +## TCP tunneling + +```bash +funnel tcp 5432 --id my-db +``` + +Raw TCP forwarding. The server allocates a port and forwards bytes bidirectionally. + +## TLS passthrough + +```bash +funnel tls 8443 --id secure-app +``` + +Forward TLS connections without terminating them. Routes via SNI. + +## UDP tunneling + +```bash +funnel udp 51820 --id wireguard +``` + +QUIC already runs over UDP, so the transport is a natural fit. + +## Other planned work + +- Self-update mechanism (`funnel update`) +- Rate limiting (per-user, per-team, per-tunnel) +- Audit logging (structured JSON events) +- OpenTelemetry integration +- HTTP/2 proxying (enables gRPC tunneling) +- Web dashboard (Leptos) diff --git a/docs/content/docs/server/authentication.mdx b/docs/content/docs/server/authentication.mdx new file mode 100644 index 0000000..ba14509 --- /dev/null +++ b/docs/content/docs/server/authentication.mdx @@ -0,0 +1,122 @@ +--- +title: Authentication +description: API keys, OAuth, and access control +--- + +Funnel uses API keys for all authenticated requests. OAuth provides a way to obtain keys through a browser flow. + +## API keys + +Every API request requires an `Authorization: Bearer ` header. Keys are created with scoped permissions: + +- **`tunnels`**: can create and use tunnels +- **`management`**: can manage keys, users, teams, and view sessions + +Keys are stored as hashes. The plaintext is only returned once at creation. + +### Seed key + +On first startup, use `--seed-api-key` to create an initial admin key: + +```bash +funnel-server --seed-api-key +``` + +The key is printed to the server logs. Use it to bootstrap your setup, then create dedicated keys for users. + +### Create keys via CLI + +```bash +funnel keys create --name "ci-deploy" +``` + +### Create keys via API + +```bash +curl -X POST https://tunnel.example.com/api/v1/keys \ + -H "Authorization: Bearer $ADMIN_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name": "ci-deploy", "scopes": ["tunnels"]}' +``` + +### Key expiry + +Keys can be created with an expiry time. After expiry, the key is rejected. + +### Revoke keys + +```bash +funnel keys list +# find the key ID, then: +curl -X DELETE https://tunnel.example.com/api/v1/keys/ \ + -H "Authorization: Bearer $ADMIN_KEY" +``` + +## OAuth + +OAuth lets users log in through a browser and receive an API key automatically. Two providers are supported. + +### GitHub + +```bash +funnel-server \ + --base-url https://tunnel.example.com \ + --github-client-id \ + --github-client-secret +``` + +| Flag | Env | +| --- | --- | +| `--github-client-id` | `GITHUB_CLIENT_ID` | +| `--github-client-secret` | `GITHUB_CLIENT_SECRET` | +| `--base-url` | `BASE_URL` | + +Set the GitHub OAuth callback URL to `https://tunnel.example.com/auth/v1/github/callback`. + +### Generic OIDC + +Any OpenID Connect provider (GitLab, Google, Keycloak, etc.): + +```bash +funnel-server \ + --base-url https://tunnel.example.com \ + --oauth-provider-name gitlab \ + --oauth-client-id \ + --oauth-client-secret \ + --oauth-authorize-url https://gitlab.com/oauth/authorize \ + --oauth-token-url https://gitlab.com/oauth/token \ + --oauth-userinfo-url https://gitlab.com/api/v4/user +``` + +| Flag | Env | Default | +| --- | --- | --- | +| `--oauth-provider-name` | `OAUTH_PROVIDER_NAME` | - | +| `--oauth-client-id` | `OAUTH_CLIENT_ID` | - | +| `--oauth-client-secret` | `OAUTH_CLIENT_SECRET` | - | +| `--oauth-authorize-url` | `OAUTH_AUTHORIZE_URL` | - | +| `--oauth-token-url` | `OAUTH_TOKEN_URL` | - | +| `--oauth-userinfo-url` | `OAUTH_USERINFO_URL` | - | +| `--oauth-scopes` | `OAUTH_SCOPES` | `openid email profile` | +| `--oauth-id-field` | `OAUTH_ID_FIELD` | `sub` | +| `--oauth-email-field` | `OAUTH_EMAIL_FIELD` | `email` | +| `--oauth-name-field` | `OAUTH_NAME_FIELD` | `name` | +| `--oauth-avatar-field` | `OAUTH_AVATAR_FIELD` | `picture` | + +The callback URL is `https://tunnel.example.com/auth/v1//callback`. + +### Client login + +```bash +funnel login # uses github by default +funnel login --provider gitlab # use a specific provider +``` + +The CLI starts a local HTTP server, opens the browser for the OAuth flow, and stores the received API key in the context config. + +## Initial admin + +Set `--initial-admin-email` to auto-promote a user to admin on their first OAuth login: + +```bash +funnel-server --initial-admin-email admin@example.com +``` diff --git a/docs/content/docs/server/deployment.mdx b/docs/content/docs/server/deployment.mdx index 54de7eb..053037e 100644 --- a/docs/content/docs/server/deployment.mdx +++ b/docs/content/docs/server/deployment.mdx @@ -1,143 +1,63 @@ --- -title: deployment -icon: PackageIcon +title: Deployment +description: Run the funnel server --- -import { Step, Steps } from 'fumadocs-ui/components/steps'; -this guide will walk you through deploying the funnel server using docker. +The server is a single binary that handles HTTP API, QUIC tunnel connections, and optionally TLS termination. -## prerequisites +## Minimal startup -- docker installed on your machine. -- a domain name for your server (for tls/https tunnels). -- dns provider credentials for automatic tls certificate generation (optional but recommended). - -## docker deployment - -the recommended way to deploy the server is using the official docker image. - - - - -### create a volume for certificates - -the server needs to persist tls certificates. create a docker volume to store them. +With the embedded Turso database (no external dependencies): ```bash -docker volume create funnel-certs +funnel-server --seed-api-key ``` - - -### basic deployment +This starts the server on port `8080` (HTTP) and `4433` (QUIC), creates an embedded database at `./funnel.db`, and prints a seed API key to stdout. -for a basic deployment without tls, you can run: +## With PostgreSQL ```bash -docker run -d --name funnel-server \ - -p 8080:8080 \ - --restart unless-stopped \ - ghcr.io/karol-broda/funnel-server:latest +funnel-server \ + --database-url postgres://user:pass@localhost/funnel \ + --seed-api-key ``` -this will start the server on port 8080 and make it accessible at `http://your-server-ip:8080`. - +Migrations run automatically on startup. The server verifies schema version compatibility before accepting connections. - -### deployment with tls (recommended) +## Configuration -for a production deployment with automatic tls certificates, you'll need to provide your domain name and dns provider configuration. +All options can be set via CLI flags or environment variables. -first, create a dns provider configuration file (see the [server cli reference](/docs/reference/server-cli) for details): +| Flag | Env | Default | Description | +| --- | --- | --- | --- | +| `--host` | `FUNNEL_HOST` | `0.0.0.0` | Bind address | +| `--port` | `FUNNEL_PORT` | `8080` | HTTP port | +| `--quic-port` | `FUNNEL_QUIC_PORT` | `4433` | QUIC port for tunnel connections | +| `--database-url` | `DATABASE_URL` | - | PostgreSQL connection URL | +| `--db-max-connections` | `FUNNEL_DB_MAX_CONNECTIONS` | `10` | Connection pool size | +| `--turso-db-path` | `FUNNEL_TURSO_DB_PATH` | `./funnel.db` | Embedded database path | +| `--seed-api-key` | `FUNNEL_SEED_API_KEY` | `false` | Create a seed API key on startup | +| `--initial-admin-email` | `FUNNEL_INITIAL_ADMIN_EMAIL` | - | Auto-promote this email to admin on first login | -```json title="dns-providers.json" -{ - "providers": [ - { - "name": "cloudflare", - "env": { - "CF_API_TOKEN": "your-cloudflare-api-token" - } - } - ] -} -``` +For TLS and OAuth options, see [TLS](/docs/server/tls) and [authentication](/docs/server/authentication). -then run the server with tls enabled: +## Database choice -```bash -docker run -d --name funnel-server \ - -p 80:8080 \ - -p 443:8443 \ - -v $(pwd)/dns-providers.json:/etc/funnel/dns-providers.json \ - -v funnel-certs:/var/lib/funnel/certs \ - -e FUNNEL_ENABLE_TLS=true \ - -e FUNNEL_LETSENCRYPT_EMAIL=your-email@example.com \ - -e FUNNEL_DNS_PROVIDERS_CONFIG=/etc/funnel/dns-providers.json \ - --restart unless-stopped \ - ghcr.io/karol-broda/funnel-server:latest -``` - -replace `your-email@example.com` with your actual email address. - +**PostgreSQL** is the production choice. It supports concurrent access, has proper ACID guarantees, and scales with connection pooling. - -### verify the deployment +**Embedded Turso** (libsql) is useful for development, single-user setups, or environments where running PostgreSQL is impractical. Data is stored in a single file. No external process needed. -check the logs to ensure the server started correctly: +If `--database-url` is set, PostgreSQL is used. Otherwise the server falls back to the embedded database at `--turso-db-path`. -```bash -docker logs -f funnel-server -``` +## Docker -you should see logs indicating that the server is listening on the configured ports. if tls is enabled, you'll also see logs about certificate generation. - - - - -## configuration - -the server can be configured using environment variables: - -| Variable | Description | Default | -| --- | --- | --- | -| `FUNNEL_HOST` | the host to listen on. | `0.0.0.0` | -| `FUNNEL_PORT` | the port for http traffic. | `8080` | -| `FUNNEL_TLS_PORT` | the port for https/tls traffic. | `8443` | -| `FUNNEL_ENABLE_TLS` | enable automatic tls certificate generation. | `false` | -| `FUNNEL_LETSENCRYPT_EMAIL` | email address for let's encrypt account (required for tls). | `""` | -| `FUNNEL_DNS_PROVIDERS_CONFIG` | path to the dns provider config file (required for tls). | `""` | -| `FUNNEL_CERT_DIR` | directory to store tls certificates. | `/var/lib/funnel/certs` | - -## docker compose (alternative) - -you can also use docker compose for easier management: - -```yaml title="docker-compose.yml" -version: '3.8' -services: - funnel-server: - image: ghcr.io/karol-broda/funnel-server:latest - container_name: funnel-server - ports: - - "80:8080" - - "443:8443" - volumes: - - ./dns-providers.json:/etc/funnel/dns-providers.json - - funnel-certs:/var/lib/funnel/certs - environment: - - FUNNEL_ENABLE_TLS=true - - FUNNEL_LETSENCRYPT_EMAIL=your-email@example.com - - FUNNEL_DNS_PROVIDERS_CONFIG=/etc/funnel/dns-providers.json - restart: unless-stopped - -volumes: - funnel-certs: -``` +OCI images are published for `linux/amd64` and `linux/arm64`: -then run: ```bash -docker-compose up -d +docker run -p 8080:8080 -p 4433:4433/udp ghcr.io/karol-broda/funnel-server ``` -for a complete list of configuration options, please refer to the [server cli reference](/docs/reference/server-cli). \ No newline at end of file +## NixOS + +For declarative NixOS deployments with systemd hardening, see the [NixOS module](/docs/nix/nixos-module). diff --git a/docs/content/docs/server/meta.json b/docs/content/docs/server/meta.json deleted file mode 100644 index 295ed72..0000000 --- a/docs/content/docs/server/meta.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "server", - "icon": "HardDrivesIcon", - "pages": ["deployment", "security"] -} diff --git a/docs/content/docs/server/security.mdx b/docs/content/docs/server/security.mdx deleted file mode 100644 index 4adb52f..0000000 --- a/docs/content/docs/server/security.mdx +++ /dev/null @@ -1,338 +0,0 @@ ---- -title: security -icon: ShieldCheckIcon -description: authentication, tls encryption, and deployment security considerations. ---- - -import { Step, Steps } from 'fumadocs-ui/components/steps'; -import { Callout } from 'fumadocs-ui/components/callout'; -import { Card, Cards } from 'fumadocs-ui/components/card'; -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; - - -funnel supports api key-based authentication. enable it with `--require-auth` to secure your server. - - -## current security features - - - - - - - - -## authentication - -funnel supports api key-based authentication to control who can create tunnels on your server. - -### enabling authentication - -start the server with the `--require-auth` flag: - -```bash -funnel-server --require-auth -``` - -or with docker: - -```bash -docker run -d --name funnel-server \ - -p 8080:8080 \ - -v funnel-tokens:/var/lib/funnel \ - -e FUNNEL_REQUIRE_AUTH=true \ - ghcr.io/karol-broda/funnel-server:latest -``` - -### managing tokens - - - -```bash -funnel-server token create --name my-laptop - -# output: -# Token created for "my-laptop" -# -# sk_7Fj2kL9xMnPqRsTuVwXyZ0AbCdEfGhIj -# -# Save this token now - it will NOT be shown again. -# Token is persisted to disk and survives server restarts. -``` - - -the plain token is only shown once. save it immediately - you cannot retrieve it later. - - - - -```bash -funnel-server token list - -# output: -# NAME PREFIX CREATED -# ---- ------ ------- -# my-laptop sk_7Fj2kL... 2 hours ago -# ci-pipeline sk_x8Yz9W... 5 days ago -``` - - - -```bash -funnel-server token revoke --name my-laptop - -# output: -# Token "my-laptop" revoked. -``` - - - -### token storage security - -| location | storage method | security | -| --- | --- | --- | -| server (`tokens.json`) | sha-256 hashed | cannot recover plain token if file leaks | -| client (`config.toml`) | plain text | file saved with 0600 permissions | - - -tokens are hashed before storage on the server. even if the token file is compromised, attackers cannot use the hashes to authenticate. - - -### client configuration - -clients can save their token to avoid passing it on every command: - -```bash -# save token to config -funnel config set-token sk_7Fj2kL9xMnPqRsTuVwXyZ0AbCdEfGhIj - -# save server url -funnel config set-server https://tunnel.example.com - -# now just run without flags -funnel http 3000 -``` - - -the client stores the token in plain text in `~/.config/funnel/config.toml`. the file is created with restricted permissions (0600), but ensure your machine is secure. - - -## current limitations - -- **no rate limiting** - no built-in protection against abuse -- **no ip restrictions** - accepts connections from any ip address -- **no user management** - tokens are not tied to user accounts - -## tls encryption - - -the server has built-in support for automatic tls certificate generation using let's encrypt. - - - - -enable automatic tls with let's encrypt: - -```bash -docker run -d --name funnel-server \ - -p 80:8080 \ - -p 443:8443 \ - -v $(pwd)/dns-providers.json:/etc/funnel/dns-providers.json \ - -v funnel-certs:/var/lib/funnel/certs \ - -e FUNNEL_ENABLE_TLS=true \ - -e FUNNEL_LETSENCRYPT_EMAIL=your-email@example.com \ - -e FUNNEL_DNS_PROVIDERS_CONFIG=/etc/funnel/dns-providers.json \ - ghcr.io/karol-broda/funnel-server:latest -``` - -requires dns provider configuration for certificate validation. - - - -use your own certificates: - -```bash -docker run -d --name funnel-server \ - -p 443:8443 \ - -v /path/to/certs:/var/lib/funnel/certs \ - -e FUNNEL_ENABLE_TLS=true \ - ghcr.io/karol-broda/funnel-server:latest -``` - - - -automatic certificate features: -- **on-demand generation** - certificates created on first request -- **automatic renewal** - renewed before expiration -- **wildcard support** - can generate `*.domain.com` certificates -- **secure storage** - stored in docker volume - - - -## deployment security - -for additional security beyond token authentication, consider these deployment strategies: - - - -### network-level protection - -use firewall rules to restrict access: - -```bash -# allow public access to tunnel traffic -sudo ufw allow 80/tcp -sudo ufw allow 443/tcp - -# restrict websocket connections to trusted networks -sudo ufw allow from 192.168.1.0/24 to any port 8080 -sudo ufw deny 8080/tcp - -sudo ufw enable -``` - - -allow public access to ports 80/443 for tunnel traffic, but restrict the websocket port (8080) to trusted networks only. - - - - -### reverse proxy with authentication - -use nginx or similar to add authentication: - -```nginx -server { - listen 443 ssl; - server_name tunnel.yourdomain.com; - - ssl_certificate /path/to/cert.pem; - ssl_certificate_key /path/to/key.pem; - - # require basic auth for websocket connections - location ~ ^/\?id= { - auth_basic "Tunnel Access"; - auth_basic_user_file /etc/nginx/.htpasswd; - - proxy_pass http://localhost:8080; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } - - # public access to tunnels - location / { - proxy_pass http://localhost:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } -} -``` - - -this adds basic authentication to websocket connections while keeping tunnel traffic public. - - - - -### vpn or private network - -deploy the server in a private network: -- use a vpn to access the server -- deploy on a private subnet -- use ssh tunneling for connections - - -the most secure approach is to deploy in a private network and use vpn/ssh for access. - - - - -## monitoring - - -the server provides comprehensive logging for monitoring connections and tunnel activity. - - -monitor these events: - - - -```bash -# view real-time connection logs -docker logs -f funnel-server - -# search for specific events -docker logs funnel-server | grep -i "websocket\|tunnel" -``` - -key events to monitor: -- websocket connection attempts -- tunnel creation and removal -- failed connection attempts - - - -```bash -# monitor certificate activities -docker logs funnel-server | grep -i "certificate\|tls\|acme" -``` - -certificate-related events: -- certificate generation -- renewal activities -- tls handshake errors - - - -```bash -# monitor errors and failures -docker logs funnel-server | grep -i "error\|failed\|rejected" -``` - -important errors: -- connection failures -- invalid tunnel ids -- certificate issues - - - -## future security features - - -we plan to add additional security features in future releases. - - -planned features: -- **rate limiting** - protection against abuse -- **ip allowlists** - built-in ip-based access control -- **user management** - user accounts and permissions -- **oauth integration** - enterprise sso support - -## security updates - - -regularly update to get the latest security patches and features. - - - - -### check for updates - -monitor [github releases](https://github.com/karol-broda/funnel/releases) for new versions. - - - -### update procedure - -```bash -# pull latest image -docker pull ghcr.io/karol-broda/funnel-server:latest - -# restart with new image -docker stop funnel-server -docker rm funnel-server -docker run -d --name funnel-server [your-config] ghcr.io/karol-broda/funnel-server:latest -``` - - \ No newline at end of file diff --git a/docs/content/docs/server/teams.mdx b/docs/content/docs/server/teams.mdx new file mode 100644 index 0000000..3cc29e3 --- /dev/null +++ b/docs/content/docs/server/teams.mdx @@ -0,0 +1,59 @@ +--- +title: Teams +description: Team management and team-scoped tunnels +--- + +Teams group users together and scope tunnel visibility. + +## Roles + +**User roles** (server-wide): +- **admin**: full access to all resources +- **member**: access to own resources and team resources + +**Team roles**: +- **owner**: can manage team members +- **member**: can create tunnels under the team + +## Create a team + +Admins can create teams: + +```bash +funnel teams create --name backend +``` + +## Manage members + +```bash +funnel teams add-member --team --user +funnel teams remove-member --team --user +funnel teams set-member-role --team --user --role owner +``` + +List members: + +```bash +funnel teams list-members --team +``` + +## Team-scoped tunnels + +Associate a tunnel with a team using `--team`: + +```bash +funnel http 3000 --team +``` + +Team-scoped tunnels are visible to all team members in the status and sessions views. Non-members cannot see or interact with them. + +## User management + +Admins can list users, change roles, and deactivate accounts: + +```bash +funnel users list +funnel users set-role --user --role admin +``` + +Deactivated users cannot authenticate and their active tunnels are rejected. diff --git a/docs/content/docs/server/tls.mdx b/docs/content/docs/server/tls.mdx new file mode 100644 index 0000000..5d8eac6 --- /dev/null +++ b/docs/content/docs/server/tls.mdx @@ -0,0 +1,89 @@ +--- +title: TLS +description: Automatic TLS with Let's Encrypt +--- + +Funnel can automatically obtain and renew TLS certificates from Let's Encrypt using DNS-01 challenges. This is required for wildcard certificates that cover all tunnel subdomains. + +## Enable TLS + +```bash +funnel-server \ + --enable-tls \ + --tls-port 443 \ + --letsencrypt-email admin@example.com \ + --dns-providers-config ./dns-providers.json +``` + +| Flag | Env | Default | Description | +| --- | --- | --- | --- | +| `--enable-tls` | `FUNNEL_ENABLE_TLS` | `false` | Enable TLS | +| `--tls-port` | `FUNNEL_TLS_PORT` | `8443` | HTTPS port | +| `--cert-dir` | `FUNNEL_CERT_DIR` | `./certs` | Certificate storage directory | +| `--letsencrypt-email` | `LETSENCRYPT_EMAIL` | - | ACME registration email | +| `--dns-providers-config` | `DNS_PROVIDERS_CONFIG` | - | Path to DNS provider config | +| `--acme-staging` | `FUNNEL_ACME_STAGING` | `false` | Use Let's Encrypt staging | + +When TLS is enabled, the HTTP port serves redirects to HTTPS. + +## DNS provider config + +The `--dns-providers-config` flag points to a JSON file that maps domain patterns to DNS providers. This tells funnel which provider to use for DNS-01 challenges. + +### Cloudflare + +```json +[ + { + "pattern": "*.tunnel.example.com", + "provider": "cloudflare", + "api_token": "your-cloudflare-api-token", + "zone_id": "your-zone-id" + } +] +``` + +The API token needs `Zone:DNS:Edit` permissions for the relevant zone. + +### Route53 + +```json +[ + { + "pattern": "*.tunnel.example.com", + "provider": "route53", + "access_key_id": "AKIA...", + "secret_access_key": "...", + "hosted_zone_id": "Z1234..." + } +] +``` + +### Exec provider + +For DNS providers not natively supported, use the `exec` provider which calls a script: + +```json +[ + { + "pattern": "*.tunnel.example.com", + "provider": "exec", + "create_command": "/usr/local/bin/dns-create.sh", + "delete_command": "/usr/local/bin/dns-delete.sh" + } +] +``` + +The scripts receive the domain and TXT record value as arguments. + +## Certificate storage + +Certificates and private keys are stored in `--cert-dir`. On restart, the server loads existing certificates before requesting new ones. Renewal happens automatically before expiry. + +## Development + +Use `--acme-staging` during development to avoid Let's Encrypt rate limits: + +```bash +funnel-server --enable-tls --acme-staging --letsencrypt-email dev@example.com --dns-providers-config ./dns.json +``` diff --git a/docs/mdx-components.tsx b/docs/mdx-components.tsx new file mode 100644 index 0000000..093f47d --- /dev/null +++ b/docs/mdx-components.tsx @@ -0,0 +1,27 @@ +import type { MDXComponents } from 'mdx/types' +import defaultMdxComponents from 'fumadocs-ui/mdx' +import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock' +import { ImageZoom, type ImageZoomProps } from 'fumadocs-ui/components/image-zoom' +import * as TabsComponents from 'fumadocs-ui/components/tabs' +import { APIPage } from 'fumadocs-openapi/ui' + +export function getMDXComponents(components?: MDXComponents): MDXComponents { + return { + ...defaultMdxComponents, + ...TabsComponents, + APIPage, + img: (props: React.ComponentProps<'img'>) => ( + + ), + pre: ({ ref: _ref, ...props }: React.ComponentProps<'pre'>) => ( + +
{props.children}
+
+ ), + ...components, + } +} + +export function useMDXComponents(components: MDXComponents): MDXComponents { + return getMDXComponents(components) +} diff --git a/docs/next.config.mjs b/docs/next.config.mjs index 340f69b..9333e30 100644 --- a/docs/next.config.mjs +++ b/docs/next.config.mjs @@ -1,68 +1,17 @@ -import { createMDX } from "fumadocs-mdx/next"; - -const withMDX = createMDX(); +import { createMDX } from 'fumadocs-mdx/next' /** @type {import('next').NextConfig} */ const config = { reactStrictMode: true, - serverExternalPackages: ["typescript", "twoslash"], - compress: true, - poweredByHeader: false, - generateEtags: true, - - trailingSlash: false, - skipTrailingSlashRedirect: true, - - images: { - formats: ["image/avif", "image/webp"], - minimumCacheTTL: 31536000, + serverExternalPackages: [ + '@takumi-rs/core', + '@takumi-rs/image-response', + ], + typescript: { + ignoreBuildErrors: true, }, +} +const withMDX = createMDX() - - headers: async () => { - return [ - { - source: "/(.*)", - headers: [ - { - key: "X-Content-Type-Options", - value: "nosniff", - }, - { - key: "X-Frame-Options", - value: "DENY", - }, - { - key: "X-XSS-Protection", - value: "1; mode=block", - }, - { - key: "Referrer-Policy", - value: "strict-origin-when-cross-origin", - }, - ], - }, - { - source: "/sitemap.xml", - headers: [ - { - key: "Cache-Control", - value: "public, max-age=86400, s-maxage=86400", - }, - ], - }, - { - source: "/robots.txt", - headers: [ - { - key: "Cache-Control", - value: "public, max-age=86400, s-maxage=86400", - }, - ], - }, - ]; - }, -}; - -export default withMDX(config); \ No newline at end of file +export default withMDX(config) diff --git a/docs/openapi.json b/docs/openapi.json index 3dcdb47..a242cb8 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1,949 +1,1773 @@ { - "schemes": ["http"], - "swagger": "2.0", - "info": { - "description": "REST API for tunnel management and monitoring in the funnel tunneling solution\n\nThis API provides endpoints for:\n- Health checking\n- Server statistics and monitoring\n- Tunnel management (list, view, delete)\n- Individual tunnel statistics", - "title": "Funnel Server API", - "contact": { - "name": "Funnel Support", - "url": "https://github.com/karol-broda/funnel" - }, - "license": { - "name": "MIT", - "url": "https://github.com/karol-broda/funnel/blob/master/LICENSE.md" - }, - "version": "1.0" + "openapi": "3.1.0", + "info": { + "title": "Funnel API", + "description": "Funnel tunnel server REST API", + "license": { + "name": "MIT" }, - "host": "localhost:8080", - "basePath": "/api", - "paths": { - "/health": { - "get": { - "description": "Returns the health status of the server", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Health check", - "responses": { - "200": { - "description": "Server is healthy", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.HealthData" - } - } - } - ] - } - } + "version": "0.1.0" + }, + "servers": [ + { + "url": "/api/v1", + "description": "API v1" + } + ], + "paths": { + "/accounts": { + "get": { + "tags": [ + "Profile" + ], + "operationId": "list_accounts", + "responses": { + "200": { + "description": "Linked OAuth accounts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AccountView" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + } }, - "/metrics": { - "get": { - "description": "Returns detailed server-wide metrics including performance, resource usage, and aggregated statistics", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Get comprehensive server metrics", - "responses": { - "200": { - "description": "Comprehensive server metrics", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.ServerMetricsResponse" - } - } - } - ] - } - } + "security": [ + { + "bearer": [] + } + ] + } + }, + "/health": { + "get": { + "tags": [ + "Server" + ], + "operationId": "get_health", + "responses": { + "200": { + "description": "Server is healthy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/info": { + "get": { + "tags": [ + "Server" + ], + "operationId": "get_server_info", + "responses": { + "200": { + "description": "Server version and QUIC port", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerInfo" } + } } + } + } + } + }, + "/keys": { + "get": { + "tags": [ + "API Keys" + ], + "operationId": "list_keys", + "responses": { + "200": { + "description": "List of API keys for the current user", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiKeyView" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "/metrics/historical": { - "get": { - "description": "Returns time-series data for server-wide metrics over a specified time range", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Get historical server metrics", - "parameters": [ - { - "type": "string", - "default": "24h", - "description": "Time range (1h, 24h, 7d)", - "name": "range", - "in": "query" - }, - { - "type": "string", - "default": "5m", - "description": "Data interval (1m, 5m, 1h)", - "name": "interval", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Historical server metrics", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.HistoricalMetricsResponse" - } - } - } - ] - } - } + "security": [ + { + "bearer": [] + } + ] + }, + "post": { + "tags": [ + "API Keys" + ], + "operationId": "create_key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "API key created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKeyResponse" } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "/server/stats": { - "get": { - "description": "Returns comprehensive server-wide statistics including tunnel count, request metrics, and cache performance", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Get server statistics", - "responses": { - "200": { - "description": "Server statistics retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.ServerStats" - } - } - } - ] - } - } + "security": [ + { + "bearer": [] + } + ] + } + }, + "/keys/{id}": { + "delete": { + "tags": [ + "API Keys" + ], + "operationId": "revoke_key", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "API key ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "API key revoked", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + }, + "404": { + "description": "Key not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "/tunnels": { - "get": { - "description": "Returns a list of all currently active tunnel connections", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "List active tunnels", - "responses": { - "200": { - "description": "List of active tunnels", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/server.TunnelInfo" - } - } - } - } - ] - } - } + "security": [ + { + "bearer": [] + } + ] + } + }, + "/me": { + "get": { + "tags": [ + "Profile" + ], + "operationId": "get_current_user", + "responses": { + "200": { + "description": "Current user profile", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "/tunnels/{tunnelId}": { - "get": { - "description": "Returns detailed information about a specific tunnel", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get tunnel details", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID (3-63 characters, lowercase letters, numbers, and hyphens only)", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Tunnel details retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.TunnelInfo" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } - } - }, - "delete": { - "description": "Forcibly closes a tunnel connection", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Delete tunnel", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID to delete", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Tunnel deleted successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.TunnelDeleteData" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } + "security": [ + { + "bearer": [] + } + ] + } + }, + "/sessions": { + "get": { + "tags": [ + "Profile" + ], + "operationId": "list_sessions", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "all", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Tunnel session history", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunnelSession" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + } }, - "/tunnels/{tunnelId}/historical": { - "get": { - "description": "Returns time-series data for a specific tunnel over a specified time range", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get tunnel historical data", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID", - "name": "tunnelId", - "in": "path", - "required": true - }, - { - "type": "string", - "default": "24h", - "description": "Time range (1h, 24h, 7d)", - "name": "range", - "in": "query" - }, - { - "type": "string", - "description": "Start time (RFC3339 format)", - "name": "start", - "in": "query" - }, - { - "type": "string", - "description": "End time (RFC3339 format)", - "name": "end", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Tunnel historical data", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.HistoricalMetricsResponse" - } - } - } - ] - } - }, - "400": { - "description": "Invalid parameters", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } + "security": [ + { + "bearer": [] + } + ] + } + }, + "/teams": { + "get": { + "tags": [ + "Teams" + ], + "operationId": "list_teams", + "responses": { + "200": { + "description": "List of teams", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Team" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + }, + "post": { + "tags": [ + "Teams" + ], + "operationId": "create_team", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamRequest" + } } + }, + "required": true }, - "/tunnels/{tunnelId}/metrics": { - "get": { - "description": "Returns detailed performance and usage metrics for a specific tunnel", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get comprehensive tunnel metrics", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Comprehensive tunnel metrics", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.DetailedTunnelMetrics" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } + "responses": { + "200": { + "description": "Team created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "409": { + "description": "Team name already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + } }, - "/tunnels/{tunnelId}/stats": { - "get": { - "description": "Returns detailed statistics and metrics for a specific tunnel", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get tunnel statistics", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Tunnel statistics retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.TunnelStats" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } + "security": [ + { + "bearer": [] + } + ] + } + }, + "/teams/{id}": { + "delete": { + "tags": [ + "Teams" + ], + "operationId": "delete_team", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Team deleted", + "content": { + "application/json": { + "schema": { + "type": "object" } + } } - } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Team not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } }, - "definitions": { - "server.APIResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data (present on success)" - }, - "error": { - "description": "Error message (present on failure)", - "type": "string", - "example": "error message" - }, - "success": { - "description": "Indicates if the request was successful", - "type": "boolean", - "example": true + "/teams/{id}/members": { + "get": { + "tags": [ + "Teams" + ], + "operationId": "list_team_members", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "List of team members", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamMembership" + } } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Not a team member", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Team not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.DetailedTunnelMetrics": { - "type": "object", - "properties": { - "average_response_time_ms": { - "description": "Average response time in ms", - "type": "number", - "example": 25.5 - }, - "client_ip": { - "description": "Client IP address", - "type": "string", - "example": "192.168.1.100:52341" - }, - "connection_errors": { - "description": "Connection errors", - "type": "integer", - "example": 2 - }, - "connections_active": { - "description": "Active connections", - "type": "integer", - "example": 1 - }, - "connections_total": { - "description": "Total connections made", - "type": "integer", - "example": 3 - }, - "created_at": { - "description": "Creation time", - "type": "string", - "example": "2025-08-08T18:30:00Z" - }, - "data_errors": { - "description": "Data errors", - "type": "integer", - "example": 1 - }, - "last_activity": { - "description": "Last activity time", - "type": "string", - "example": "2025-08-08T20:30:00Z" - }, - "max_response_time_ms": { - "description": "Maximum response time in ms", - "type": "number", - "example": 150.8 - }, - "min_response_time_ms": { - "description": "Minimum response time in ms", - "type": "number", - "example": 5.2 - }, - "reconnect_count": { - "description": "Number of reconnections", - "type": "integer", - "example": 2 - }, - "requests_per_second": { - "description": "Requests per second", - "type": "number", - "example": 12.4 - }, - "timeout_count": { - "description": "Number of timeouts", - "type": "integer", - "example": 5 - }, - "total_bytes_in": { - "description": "Total bytes received", - "type": "integer", - "example": 125430 - }, - "total_bytes_out": { - "description": "Total bytes sent", - "type": "integer", - "example": 987650 - }, - "total_requests": { - "description": "Total requests processed", - "type": "integer", - "example": 1542 - }, - "total_requests_error": { - "description": "Failed requests", - "type": "integer", - "example": 44 - }, - "total_requests_success": { - "description": "Successful requests", - "type": "integer", - "example": 1498 - }, - "tunnel_id": { - "description": "Tunnel identifier", - "type": "string", - "example": "my-tunnel" - }, - "user_agent": { - "description": "Client user agent", - "type": "string", - "example": "FunnelClient/1.0" + "security": [ + { + "bearer": [] + } + ] + }, + "post": { + "tags": [ + "Teams" + ], + "operationId": "add_team_member", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddMemberRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Member added", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamMembership" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + }, + "403": { + "description": "Not a team owner", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Team not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "409": { + "description": "Already a member", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.HealthData": { - "type": "object", - "properties": { - "status": { - "description": "Service status", - "type": "string", - "example": "healthy" - }, - "timestamp": { - "description": "Current timestamp", - "type": "string", - "example": "2025-08-08T20:11:00Z" - }, - "uptime": { - "description": "Server uptime duration", - "type": "string", - "example": "6.4281425s" + "security": [ + { + "bearer": [] + } + ] + } + }, + "/teams/{id}/members/{user_id}": { + "delete": { + "tags": [ + "Teams" + ], + "operationId": "remove_team_member", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "user_id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member removed", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Cannot remove last owner", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Not a team owner", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Membership not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/teams/{id}/members/{user_id}/role": { + "put": { + "tags": [ + "Teams" + ], + "operationId": "set_team_member_role", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "user_id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetTeamMemberRoleRequest" + } } + }, + "required": true }, - "server.HistoricalDataPoint": { - "type": "object", - "properties": { - "active_connections": { - "type": "integer" - }, - "bytes_in": { - "type": "integer" - }, - "bytes_out": { - "type": "integer" - }, - "error_count": { - "type": "integer" - }, - "request_count": { - "type": "integer" - }, - "response_time_ms": { - "type": "number" - }, - "timestamp": { - "type": "string" + "responses": { + "200": { + "description": "Member role updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamMembership" } + } } + }, + "400": { + "description": "Invalid role or last owner", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Not a team owner", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Membership not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.HistoricalMetricsResponse": { - "type": "object", - "properties": { - "data_points": { - "description": "Historical data points", - "type": "array", - "items": { - "$ref": "#/definitions/server.HistoricalDataPoint" - } - }, - "interval": { - "description": "Data point interval", - "type": "string", - "example": "1m" - }, - "time_range": { - "description": "Time range of data", - "type": "string", - "example": "24h" - }, - "tunnel_id": { - "description": "Tunnel identifier", - "type": "string", - "example": "my-tunnel" + "security": [ + { + "bearer": [] + } + ] + } + }, + "/tunnels": { + "get": { + "tags": [ + "Tunnels" + ], + "operationId": "list_tunnels", + "responses": { + "200": { + "description": "List of active tunnels", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunnelInfo" + } } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.ServerMetricsResponse": { - "type": "object", - "properties": { - "active_tunnels": { - "description": "Currently active tunnels", - "type": "integer", - "example": 8 - }, - "average_response_time_ms": { - "description": "Server average response time", - "type": "number", - "example": 28.7 - }, - "cpu_usage_percent": { - "description": "CPU usage percentage", - "type": "number", - "example": 15.5 - }, - "error_rate_percent": { - "description": "Error rate percentage", - "type": "number", - "example": 0.18 - }, - "memory_usage_bytes": { - "description": "Memory usage in bytes", - "type": "integer", - "example": 67108864 - }, - "network_connections": { - "description": "Active network connections", - "type": "integer", - "example": 12 - }, - "server_requests_per_second": { - "description": "Server-wide RPS", - "type": "number", - "example": 45.2 - }, - "server_uptime": { - "description": "Server uptime", - "type": "string", - "example": "5h23m15s" - }, - "total_bytes_transferred": { - "description": "Total bytes transferred", - "type": "integer", - "example": 5670000 - }, - "total_errors": { - "description": "Total errors", - "type": "integer", - "example": 234 - }, - "total_requests": { - "description": "Total requests processed", - "type": "integer", - "example": 125430 - }, - "total_tunnels": { - "description": "Total tunnels created", - "type": "integer", - "example": 15 + "security": [ + { + "bearer": [] + } + ] + } + }, + "/tunnels/{id}": { + "get": { + "tags": [ + "Tunnels" + ], + "operationId": "get_tunnel", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tunnel ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tunnel details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TunnelInfo" } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Tunnel not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.ServerStats": { - "type": "object", - "properties": { - "active_tunnels": { - "description": "Number of currently active tunnel connections", - "type": "integer", - "example": 2 - }, - "cache_hit_rate": { - "description": "Cache hit rate percentage", - "type": "string", - "example": "90.9%" - }, - "cache_hits": { - "description": "Number of cache hits for hostname resolution", - "type": "integer", - "example": 1401 - }, - "cache_misses": { - "description": "Number of cache misses for hostname resolution", - "type": "integer", - "example": 141 - }, - "router_stats": { - "description": "Detailed router statistics", - "type": "object", - "additionalProperties": true - }, - "total_requests": { - "description": "Total number of HTTP requests processed", - "type": "integer", - "example": 1542 - }, - "uptime": { - "description": "Server uptime duration", - "type": "string", - "example": "2h15m30s" + "security": [ + { + "bearer": [] + } + ] + }, + "delete": { + "tags": [ + "Tunnels" + ], + "operationId": "delete_tunnel", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tunnel ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tunnel disconnected", + "content": { + "application/json": { + "schema": { + "type": "object" } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Tunnel not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.TunnelDeleteData": { - "type": "object", - "properties": { - "message": { - "description": "Success message", - "type": "string", - "example": "tunnel deleted successfully" - }, - "tunnel_id": { - "description": "Deleted tunnel ID", - "type": "string", - "example": "my-tunnel" + "security": [ + { + "bearer": [] + } + ] + } + }, + "/users": { + "get": { + "tags": [ + "Users" + ], + "operationId": "list_users", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "List of all users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + } }, - "server.TunnelInfo": { - "type": "object", - "properties": { - "bytes_received": { - "description": "Total bytes received from client", - "type": "integer", - "example": 12543 - }, - "bytes_sent": { - "description": "Total bytes sent to client", - "type": "integer", - "example": 98765 - }, - "created_at": { - "description": "Tunnel creation timestamp", - "type": "string", - "example": "2025-08-08T18:30:00Z" - }, - "id": { - "description": "Unique tunnel identifier", - "type": "string", - "example": "my-tunnel" - }, - "messages_received": { - "description": "Number of messages received from client", - "type": "integer", - "example": 45 - }, - "messages_sent": { - "description": "Number of messages sent to client", - "type": "integer", - "example": 45 - }, - "status": { - "description": "Current tunnel status", - "type": "string", - "enum": [ - "connected", - "disconnected" - ], - "example": "connected" - }, - "uptime": { - "description": "Tunnel connection uptime", - "type": "string", - "example": "1h15m30s" + "security": [ + { + "bearer": [] + } + ] + } + }, + "/users/{id}/deactivate": { + "post": { + "tags": [ + "Users" + ], + "operationId": "deactivate_user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "User deactivated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Cannot deactivate last admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.TunnelStats": { - "type": "object", - "properties": { - "bytes_received": { - "description": "Bytes received total", - "type": "integer", - "example": 12543 - }, - "bytes_sent": { - "description": "Bytes sent total", - "type": "integer", - "example": 98765 - }, - "connection_info": { - "description": "Connection details", - "type": "object", - "additionalProperties": true - }, - "created_at": { - "description": "Creation timestamp", - "type": "string", - "example": "2025-08-08T18:30:00Z" - }, - "messages_received": { - "description": "Messages received count", - "type": "integer", - "example": 45 - }, - "messages_sent": { - "description": "Messages sent count", - "type": "integer", - "example": 45 - }, - "status": { - "description": "Connection status", - "type": "string", - "example": "connected" - }, - "tunnel_id": { - "description": "Tunnel identifier", - "type": "string", - "example": "my-tunnel" - }, - "uptime": { - "description": "Connection uptime", - "type": "string", - "example": "1h15m30s" + "security": [ + { + "bearer": [] + } + ] + } + }, + "/users/{id}/reactivate": { + "post": { + "tags": [ + "Users" + ], + "operationId": "reactivate_user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "User reactivated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" } + } } - } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } }, - "tags": [ - { - "description": "Server health and statistics endpoints", - "name": "Server" + "/users/{id}/role": { + "put": { + "tags": [ + "Users" + ], + "operationId": "set_user_role", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetUserRoleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User role updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Invalid role or last admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - { - "description": "Tunnel management and monitoring endpoints", - "name": "Tunnels" + "security": [ + { + "bearer": [] + } + ] + } + } + }, + "components": { + "schemas": { + "AccountView": { + "type": "object", + "required": [ + "id", + "provider", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "provider": { + "type": "string" + } + } + }, + "AddMemberRequest": { + "type": "object", + "required": [ + "user_id" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "ApiErrorBody": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } } - ] + }, + "ApiKeyView": { + "type": "object", + "required": [ + "id", + "name", + "key_prefix", + "scopes", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "key_prefix": { + "type": "string" + }, + "name": { + "type": "string" + }, + "revoked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CreateKeyRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "name": { + "type": "string" + }, + "scopes": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + } + }, + "CreateKeyResponse": { + "type": "object", + "required": [ + "key", + "info" + ], + "properties": { + "info": { + "$ref": "#/components/schemas/ApiKeyView" + }, + "key": { + "type": "string" + } + } + }, + "CreateTeamRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "owner_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "HealthResponse": { + "type": "object", + "required": [ + "status", + "uptime_secs" + ], + "properties": { + "status": { + "type": "string" + }, + "uptime_secs": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ServerInfo": { + "type": "object", + "required": [ + "version", + "quic_port" + ], + "properties": { + "quic_port": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "version": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "SetTeamMemberRoleRequest": { + "type": "object", + "required": [ + "role" + ], + "properties": { + "role": { + "type": "string" + } + } + }, + "SetUserRoleRequest": { + "type": "object", + "required": [ + "role" + ], + "properties": { + "role": { + "type": "string" + } + } + }, + "Team": { + "type": "object", + "required": [ + "id", + "name", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "TeamMembership": { + "type": "object", + "required": [ + "id", + "team_id", + "user_id", + "role", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "role": { + "type": "string" + }, + "team_id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "TunnelInfo": { + "type": "object", + "required": [ + "id", + "uptime_secs", + "stats", + "owner_id" + ], + "properties": { + "id": { + "type": "string" + }, + "owner_id": { + "type": "string", + "format": "uuid" + }, + "stats": { + "$ref": "#/components/schemas/TunnelStatsSnapshot" + }, + "team_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "uptime_secs": { + "type": "number", + "format": "double" + } + } + }, + "TunnelSession": { + "type": "object", + "required": [ + "id", + "user_id", + "tunnel_id", + "connected_at", + "bytes_in", + "bytes_out", + "requests" + ], + "properties": { + "bytes_in": { + "type": "integer", + "format": "int64" + }, + "bytes_out": { + "type": "integer", + "format": "int64" + }, + "client_ip": { + "type": [ + "string", + "null" + ] + }, + "connected_at": { + "type": "string", + "format": "date-time" + }, + "disconnected_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "requests": { + "type": "integer", + "format": "int64" + }, + "tunnel_id": { + "type": "string" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "TunnelStatsSnapshot": { + "type": "object", + "required": [ + "bytes_in", + "bytes_out", + "requests" + ], + "properties": { + "bytes_in": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "bytes_out": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "requests": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "User": { + "type": "object", + "required": [ + "id", + "email", + "role", + "metadata", + "created_at", + "updated_at" + ], + "properties": { + "avatar_url": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deactivated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "metadata": { + "type": "object" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "securitySchemes": { + "bearer": { + "type": "http", + "scheme": "bearer" + } + } + }, + "tags": [ + { + "name": "Server", + "description": "Server health and info", + "x-displayName": "Server" + }, + { + "name": "Tunnels", + "description": "Tunnel management", + "x-displayName": "Tunnels" + }, + { + "name": "API Keys", + "description": "API key management", + "x-displayName": "API Keys" + }, + { + "name": "Profile", + "description": "Current user profile and sessions", + "x-displayName": "Profile" + }, + { + "name": "Users", + "description": "User management (admin only)", + "x-displayName": "Users" + }, + { + "name": "Teams", + "description": "Team management", + "x-displayName": "Teams" + } + ] } \ No newline at end of file diff --git a/docs/package.json b/docs/package.json index 014ea5b..9bf0438 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,56 +1,41 @@ { - "name": "docs", - "version": "0.0.0", + "name": "funnel-docs", + "version": "0.0.1", "private": true, "scripts": { + "dev": "next dev", + "generate:api": "bun run scripts/generate-api-docs.mts", + "prebuild": "bun run generate:api && fumadocs-mdx", "build": "next build", - "dev": "NODE_OPTIONS='--inspect' next dev --turbo", "start": "next start", - "postinstall": "fumadocs-mdx", - "types": "tsc --noEmit", - "lint": "next lint" + "typecheck": "tsc --noEmit" }, - "browserslist": [ - "chrome 64", - "edge 79", - "firefox 67", - "opera 51", - "safari 12" - ], "dependencies": { - "@catppuccin/palette": "1.7.1", - "@phosphor-icons/react": "2.1.10", - "ajv": "^8.17.1", - "fumadocs-core": "15.6.3", - "fumadocs-mdx": "11.6.10", + "@tailwindcss/postcss": "4.2.4", + "@takumi-rs/core": "1.1.2", + "@takumi-rs/core-linux-x64-gnu": "1.1.2", + "@takumi-rs/image-response": "1.1.2", + "@tsparticles/path-perlin-noise": "^3.9.1", + "@tsparticles/react": "^3.0.0", + "@tsparticles/slim": "^3.9.1", + "fumadocs-core": "16.8.5", + "fumadocs-mdx": "14.3.2", "fumadocs-openapi": "9.1.10", - "fumadocs-twoslash": "3.1.4", - "fumadocs-ui": "15.6.3", - "gsap": "3.13.0", - "katex": "0.16.22", - "mermaid": "11.8.1", - "next": "15.3.7", - "ogl": "1.0.11", - - "react": "19.1.3", - "react-dom": "19.1.3", - "rehype-katex": "7.0.1", - "remark-math": "6.0.0", - "shiki": "3.9.2", - "twoslash": "0.3.2" + "fumadocs-ui": "16.8.5", + "motion": "^12.38.0", + "next": "16.2.4", + "postcss": "8.5.12", + "react": "19.2.5", + "react-dom": "19.2.5", + "tailwind-variants": "^3.2.2", + "tailwindcss": "4.2.4" }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.11", - "@types/bun": "1.2.19", - "@types/mdx": "^2.0.13", - "@types/node": "24.0.7", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "eslint": "^8", - "eslint-config-next": "15.3.7", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.11", - "typescript": "^5.8.3" + "@types/mdx": "2.0.13", + "@types/node": "20.19.19", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3" }, - "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" + "packageManager": "bun@1.3.3" } diff --git a/docs/postcss.config.mjs b/docs/postcss.config.mjs index a34a3d5..a7f73a2 100644 --- a/docs/postcss.config.mjs +++ b/docs/postcss.config.mjs @@ -2,4 +2,4 @@ export default { plugins: { '@tailwindcss/postcss': {}, }, -}; +} diff --git a/docs/public/.well-known/security.txt b/docs/public/.well-known/security.txt deleted file mode 100644 index 7d868aa..0000000 --- a/docs/public/.well-known/security.txt +++ /dev/null @@ -1,6 +0,0 @@ -Contact: https://github.com/karol-broda/funnel/issues -Contact: mailto:me@karolbroda.com -Expires: 2025-12-31T23:59:59.000Z -Acknowledgments: https://github.com/karol-broda/funnel -Preferred-Languages: en -Canonical: https://funnel.karolbroda.com/.well-known/security.txt diff --git a/docs/public/dm-serif-display-latin-400-normal.woff2 b/docs/public/dm-serif-display-latin-400-normal.woff2 new file mode 100644 index 0000000..75c0a69 Binary files /dev/null and b/docs/public/dm-serif-display-latin-400-normal.woff2 differ diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg deleted file mode 100644 index 1d1ef42..0000000 --- a/docs/public/favicon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - ๐Ÿ•ณ๏ธ - \ No newline at end of file diff --git a/docs/public/humans.txt b/docs/public/humans.txt deleted file mode 100644 index dc497ad..0000000 --- a/docs/public/humans.txt +++ /dev/null @@ -1,36 +0,0 @@ -/* TEAM */ -Creator: Karol Broda -Contact: https://github.com/karol-broda -Location: ๐Ÿ‡ฉ๐Ÿ‡ช - -/* SITE */ -Standards: HTML5, CSS3, JavaScript -Components: Next.js, React, TypeScript, Go -Software: Git - -/* DESCRIPTION */ -A tunneling solution built with Go. -Expose local services to the internet through websocket connections. -Perfect for development, testing, and demonstration purposes. - - -o/- - +ooo/ - +oooo+ - +ooooo+ - +oooooo+ - +ooooooo+ - +oooooooo+ - +ooooooooo+ - +oooooooooo+ - +ooooooooooo+ - +oooooooooooo+ - +ooooooooooooo+ - +oooooooooooooo+ - +ooooooooooooooo+ - +oooooooooooooooo+ - +ooooooooooooooooo+ - +oooooooooooooooooo+ - oooooooooooooooooooo - oooooooooooooooooooo - oooooooooooooooooooo - oooooooooooooooooooo diff --git a/docs/public/icon.svg b/docs/public/icon.svg new file mode 100644 index 0000000..7116253 --- /dev/null +++ b/docs/public/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/public/robots.txt b/docs/public/robots.txt deleted file mode 100644 index eb89fb4..0000000 --- a/docs/public/robots.txt +++ /dev/null @@ -1,15 +0,0 @@ -User-agent: * -Allow: / - -# Sitemap -Sitemap: https://funnel.karolbroda.com/sitemap.xml - -# Crawl delay -Crawl-delay: 1 - -# Specific rules for common bots -User-agent: Googlebot -Allow: / - -User-agent: Bingbot -Allow: / \ No newline at end of file diff --git a/docs/public/site.webmanifest b/docs/public/site.webmanifest deleted file mode 100644 index 15af569..0000000 --- a/docs/public/site.webmanifest +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "funnel docs", - "short_name": "funnel", - "description": "A tunneling solution built with Go. Expose local services to the internet through websocket connections.", - "start_url": "/", - "display": "standalone", - "background_color": "#000000", - "theme_color": "#000000", - "icons": [ - { - "src": "/favicon.svg", - "sizes": "any", - "type": "image/svg+xml" - } - ] -} \ No newline at end of file diff --git a/docs/scripts/generate-api-docs.mts b/docs/scripts/generate-api-docs.mts new file mode 100644 index 0000000..1f72500 --- /dev/null +++ b/docs/scripts/generate-api-docs.mts @@ -0,0 +1,9 @@ +import { generateFiles } from 'fumadocs-openapi' + +void generateFiles({ + input: ['./openapi.json'], + output: './content/docs/reference/server-api', + per: 'tag', + groupBy: 'tag', + addGeneratedComment: true, +}) diff --git a/docs/scripts/generate-openapi-docs.mjs b/docs/scripts/generate-openapi-docs.mjs deleted file mode 100644 index 354a43e..0000000 --- a/docs/scripts/generate-openapi-docs.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import { generateFiles } from 'fumadocs-openapi'; - -void generateFiles({ - // OpenAPI schema file - use the one we downloaded from server - input: ['./openapi.json'], - // Output directory for generated MDX files - output: './content/docs/reference/server-api', - // Include endpoint descriptions in MDX - includeDescription: true, - // Generate per operation (each endpoint gets its own file) - per: 'operation', - // Group operations by tag for better organization - groupBy: 'tag', -}); - -console.log('โœ… OpenAPI documentation generated successfully!'); -console.log('๐Ÿ“ Files created in: ./content/docs/reference/server-api/'); -console.log('๐Ÿ”— Make sure your funnel server is running on http://localhost:8080'); diff --git a/docs/source.config.ts b/docs/source.config.ts index 5ff6f1b..4132d31 100644 --- a/docs/source.config.ts +++ b/docs/source.config.ts @@ -1,26 +1,16 @@ -import { defineConfig, defineDocs } from 'fumadocs-mdx/config'; -import { rehypeCodeDefaultOptions } from 'fumadocs-core/mdx-plugins'; -import { transformerTwoslash } from 'fumadocs-twoslash'; -import rehypeKatex from 'rehype-katex'; -import remarkMath from 'remark-math'; +import { defineDocs, defineConfig } from 'fumadocs-mdx/config' export const docs = defineDocs({ dir: 'content/docs', -}); +}) export default defineConfig({ mdxOptions: { - remarkPlugins: [remarkMath], - rehypePlugins: (v) => [rehypeKatex, ...v], rehypeCodeOptions: { themes: { - light: 'github-light', - dark: 'github-dark', + light: 'github-light-default', + dark: 'github-dark-default', }, - transformers: [ - ...(rehypeCodeDefaultOptions.transformers ?? []), - transformerTwoslash(), - ], }, }, -}); +}) diff --git a/docs/src/app/(home)/_components/feature-grid.tsx b/docs/src/app/(home)/_components/feature-grid.tsx new file mode 100644 index 0000000..a5ab5a4 --- /dev/null +++ b/docs/src/app/(home)/_components/feature-grid.tsx @@ -0,0 +1,30 @@ +'use client' + +import { motion } from 'motion/react' +import { fadeUp } from '@/lib/animation' + +const features = [ + ['QUIC streams', 'Each HTTP request gets its own QUIC stream. No head-of-line blocking, no framing overhead.'], + ['Automatic TLS', "Wildcard certificates from Let's Encrypt via DNS-01. Cloudflare, Route53, or your own provider."], + ['Teams & OAuth', 'GitHub and generic OIDC login. API keys with scoped permissions. Team-scoped tunnels.'], + ['Self-hosted', 'Single binary, embedded database option. Your infrastructure, your data.'], + ['NixOS native', 'NixOS module with systemd hardening. Home Manager with sops-nix. OCI containers.'], + ['Observable', 'Prometheus metrics for tunnels, bandwidth, and latency. Per-tunnel request stats.'], +] as const + +export function FeatureGrid() { + return ( +
+ {features.map(([title, detail], i) => ( + +

{title}

+

{detail}

+
+ ))} +
+ ) +} diff --git a/docs/src/app/(home)/_components/flow-field.tsx b/docs/src/app/(home)/_components/flow-field.tsx new file mode 100644 index 0000000..35a8c6d --- /dev/null +++ b/docs/src/app/(home)/_components/flow-field.tsx @@ -0,0 +1,299 @@ +'use client' + +import { useEffect, useRef } from 'react' + +function simplex2d(x: number, y: number): number { + const F2 = 0.5 * (Math.sqrt(3) - 1) + const G2 = (3 - Math.sqrt(3)) / 6 + const s = (x + y) * F2 + const i = Math.floor(x + s) + const j = Math.floor(y + s) + const t = (i + j) * G2 + const x0 = x - (i - t) + const y0 = y - (j - t) + const i1 = x0 > y0 ? 1 : 0 + const j1 = x0 > y0 ? 0 : 1 + const x1 = x0 - i1 + G2 + const y1 = y0 - j1 + G2 + const x2 = x0 - 1 + 2 * G2 + const y2 = y0 - 1 + 2 * G2 + const hash = (n: number) => { const h = Math.sin(n) * 43758.5453; return h - Math.floor(h) } + const grad = (h: number, gx: number, gy: number) => { const a = h * Math.PI * 2; return Math.cos(a) * gx + Math.sin(a) * gy } + let n0 = 0, n1 = 0, n2 = 0 + let t0 = 0.5 - x0 * x0 - y0 * y0 + if (t0 >= 0) { t0 *= t0; n0 = t0 * t0 * grad(hash(i * 127.1 + j * 311.7), x0, y0) } + let t1 = 0.5 - x1 * x1 - y1 * y1 + if (t1 >= 0) { t1 *= t1; n1 = t1 * t1 * grad(hash((i + i1) * 127.1 + (j + j1) * 311.7), x1, y1) } + let t2 = 0.5 - x2 * x2 - y2 * y2 + if (t2 >= 0) { t2 *= t2; n2 = t2 * t2 * grad(hash((i + 1) * 127.1 + (j + 1) * 311.7), x2, y2) } + return 70 * (n0 + n1 + n2) +} + +const VERT = /* glsl */ ` + attribute vec2 aPos; + attribute float aAlpha; + attribute float aHue; + attribute float aSize; + uniform vec2 uRes; + varying float vAlpha; + varying float vHue; + void main() { + vec2 ndc = (aPos / uRes) * 2.0 - 1.0; + ndc.y = -ndc.y; + gl_Position = vec4(ndc, 0.0, 1.0); + gl_PointSize = aSize; + vAlpha = aAlpha; + vHue = aHue; + } +` + +const FRAG = /* glsl */ ` + precision highp float; + varying float vAlpha; + varying float vHue; + uniform float uDark; + void main() { + float d = length(gl_PointCoord - 0.5) * 2.0; + if (d > 1.0) discard; + float soft = 1.0 - d * d; + vec3 warmD = vec3(0.55, 0.45, 0.27); + vec3 goldD = vec3(0.83, 0.66, 0.27); + vec3 coolL = vec3(0.23, 0.31, 0.47); + vec3 blueL = vec3(0.33, 0.42, 0.56); + vec3 color = mix( + mix(coolL, blueL, vHue), + mix(warmD, goldD, vHue), + uDark + ); + float a = vAlpha * soft; + gl_FragColor = vec4(color * a, a); + } +` + +const TRAIL = 12 +const COUNT = 1200 +const MAX_VERTS = COUNT * TRAIL +const FLOATS_PER = 5 + +interface Particle { + trail: Float64Array + head: number + vx: number + vy: number + life: number + maxLife: number + hue: number +} + +export function FlowField() { + const canvasRef = useRef(null) + const rafRef = useRef(0) + const mouseRef = useRef({ x: -1, y: -1 }) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const gl = canvas.getContext('webgl', { alpha: true, antialias: true }) + if (!gl) return + + const compile = (type: number, src: string) => { + const s = gl.createShader(type)! + gl.shaderSource(s, src) + gl.compileShader(s) + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + console.error('shader compile:', gl.getShaderInfoLog(s)) + } + return s + } + const prog = gl.createProgram()! + gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT)) + gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG)) + gl.linkProgram(prog) + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + console.error('program link:', gl.getProgramInfoLog(prog)) + } + gl.useProgram(prog) + + const aPos = gl.getAttribLocation(prog, 'aPos') + const aAlpha = gl.getAttribLocation(prog, 'aAlpha') + const aHue = gl.getAttribLocation(prog, 'aHue') + const aSize = gl.getAttribLocation(prog, 'aSize') + const uRes = gl.getUniformLocation(prog, 'uRes') + const uDark = gl.getUniformLocation(prog, 'uDark') + + const buf = gl.createBuffer()! + gl.bindBuffer(gl.ARRAY_BUFFER, buf) + gl.bufferData(gl.ARRAY_BUFFER, MAX_VERTS * FLOATS_PER * 4, gl.DYNAMIC_DRAW) + + const stride = FLOATS_PER * 4 + gl.enableVertexAttribArray(aPos) + gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, stride, 0) + gl.enableVertexAttribArray(aAlpha) + gl.vertexAttribPointer(aAlpha, 1, gl.FLOAT, false, stride, 8) + gl.enableVertexAttribArray(aHue) + gl.vertexAttribPointer(aHue, 1, gl.FLOAT, false, stride, 12) + gl.enableVertexAttribArray(aSize) + gl.vertexAttribPointer(aSize, 1, gl.FLOAT, false, stride, 16) + + gl.enable(gl.BLEND) + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) + + const vertexData = new Float32Array(MAX_VERTS * FLOATS_PER) + + const dpr = devicePixelRatio + let w = 0, h = 0 + + const resize = () => { + w = canvas.clientWidth + h = canvas.clientHeight + canvas.width = w * dpr + canvas.height = h * dpr + gl.viewport(0, 0, canvas.width, canvas.height) + gl.uniform2f(uRes, w, h) + } + resize() + addEventListener('resize', resize) + + const onMouse = (e: MouseEvent) => { + const r = canvas.getBoundingClientRect() + mouseRef.current = { x: e.clientX - r.left, y: e.clientY - r.top } + } + const onLeave = () => { mouseRef.current = { x: -1, y: -1 } } + canvas.addEventListener('mousemove', onMouse) + canvas.addEventListener('mouseleave', onLeave) + + const spawn = (): Particle => { + const x = Math.random() * w + const y = Math.random() * h + const trail = new Float64Array(TRAIL * 2) + for (let k = 0; k < TRAIL; k++) { trail[k * 2] = x; trail[k * 2 + 1] = y } + return { trail, head: 0, vx: 0, vy: 0, life: 0, maxLife: 250 + Math.random() * 350, hue: Math.random() } + } + + const particles: Particle[] = [] + for (let i = 0; i < COUNT; i++) { + const p = spawn() + p.life = Math.random() * p.maxLife + particles.push(p) + } + + let time = Math.random() * 1000 + let paused = false + let last = performance.now() + let acc = 0 + const dt = 1 / 60 + + const onVis = () => { + paused = document.hidden + if (!paused) { last = performance.now(); rafRef.current = requestAnimationFrame(loop) } + } + document.addEventListener('visibilitychange', onVis) + + let darkVal = document.documentElement.classList.contains('dark') ? 1.0 : 0.0 + let darkTarget = darkVal + const obs = new MutationObserver(() => { + darkTarget = document.documentElement.classList.contains('dark') ? 1.0 : 0.0 + }) + obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) + + const tick = () => { + time += 0.002 + const mx = mouseRef.current.x + const my = mouseRef.current.y + const hasM = mx >= 0 && my >= 0 + + for (const p of particles) { + const tx = p.trail[p.head * 2] + const ty = p.trail[p.head * 2 + 1] + const angle = simplex2d(tx * 0.0015 + time, ty * 0.0015 + time * 0.5) * Math.PI * 1.8 + let fx = Math.cos(angle) * 0.6 + let fy = Math.sin(angle) * 0.6 + + if (hasM) { + const dx = mx - tx, dy = my - ty + const d = Math.sqrt(dx * dx + dy * dy) + if (d < 200 && d > 1) { const f = (1 - d / 200) * 1.5; fx += (dx / d) * f; fy += (dy / d) * f } + } + + p.vx = p.vx * 0.95 + fx * 0.05 + p.vy = p.vy * 0.95 + fy * 0.05 + p.head = (p.head + 1) % TRAIL + p.trail[p.head * 2] = tx + p.vx + p.trail[p.head * 2 + 1] = ty + p.vy + p.life++ + + const nx = p.trail[p.head * 2], ny = p.trail[p.head * 2 + 1] + if (p.life >= p.maxLife || nx < -20 || nx > w + 20 || ny < -20 || ny > h + 20) { + const s = spawn(); p.trail.set(s.trail); p.head = 0; p.vx = 0; p.vy = 0 + p.life = 0; p.maxLife = s.maxLife; p.hue = s.hue + } + } + } + + const draw = () => { + darkVal += (darkTarget - darkVal) * 0.05 + + gl.clearColor(0, 0, 0, 0) + gl.clear(gl.COLOR_BUFFER_BIT) + gl.uniform1f(uDark, darkVal) + + let vi = 0 + for (const p of particles) { + const lr = p.life / p.maxLife + const fi = Math.min(lr * 4, 1) + const fo = lr > 0.75 ? (1 - lr) / 0.25 : 1 + const base = fi * fo + if (base <= 0.01) continue + + for (let i = 0; i < TRAIL; i++) { + const idx = (p.head - i + TRAIL) % TRAIL + const tf = 1 - i / TRAIL + const a = base * tf * tf * (darkVal > 0.5 ? 0.5 : 0.35) + if (a < 0.01) continue + + const off = vi * FLOATS_PER + vertexData[off] = p.trail[idx * 2] + vertexData[off + 1] = p.trail[idx * 2 + 1] + vertexData[off + 2] = a + vertexData[off + 3] = p.hue + vertexData[off + 4] = (1 + tf * 2) * dpr + vi++ + } + } + + if (vi > 0) { + gl.bufferSubData(gl.ARRAY_BUFFER, 0, vertexData.subarray(0, vi * FLOATS_PER)) + gl.drawArrays(gl.POINTS, 0, vi) + } + } + + const loop = () => { + if (paused) return + const now = performance.now() + acc += Math.min((now - last) / 1000, 0.05) + last = now + + let n = 0 + while (acc >= dt && n < 3) { tick(); acc -= dt; n++ } + acc = Math.min(acc, dt) + + draw() + rafRef.current = requestAnimationFrame(loop) + } + + for (let i = 0; i < 60; i++) tick() + draw() + rafRef.current = requestAnimationFrame(loop) + + return () => { + cancelAnimationFrame(rafRef.current) + removeEventListener('resize', resize) + canvas.removeEventListener('mousemove', onMouse) + canvas.removeEventListener('mouseleave', onLeave) + document.removeEventListener('visibilitychange', onVis) + obs.disconnect() + } + }, []) + + return +} diff --git a/docs/src/app/(home)/_components/flow.tsx b/docs/src/app/(home)/_components/flow.tsx new file mode 100644 index 0000000..c249419 --- /dev/null +++ b/docs/src/app/(home)/_components/flow.tsx @@ -0,0 +1,89 @@ +'use client' + +import { motion } from 'motion/react' +import { fadeUp } from '@/lib/animation' + +const steps = [ + { + icon: ( + + + + + ), + title: 'Your service', + detail: 'localhost:3000', + accent: false, + }, + { + icon: ( + + + + ), + title: 'QUIC tunnel', + detail: 'Per-request streams', + accent: true, + }, + { + icon: ( + + + + + ), + title: 'Public URL', + detail: '*.tunnel.example.com', + accent: false, + }, +] as const + +function StepNode({ + icon, + title, + detail, + accent, + index, +}: { + icon: React.ReactNode + title: string + detail: string + accent: boolean + index: number +}) { + return ( + +
+ {icon} +
+
+
{title}
+
{detail}
+
+
+ ) +} + +export function FlowDiagram() { + return ( +
+
+
+
+
+
+
+
+ {steps.map((step, i) => ( + + ))} +
+
+ ) +} diff --git a/docs/src/app/(home)/_components/github-icon.tsx b/docs/src/app/(home)/_components/github-icon.tsx new file mode 100644 index 0000000..fea6720 --- /dev/null +++ b/docs/src/app/(home)/_components/github-icon.tsx @@ -0,0 +1,7 @@ +export function GitHubIcon({ className }: { className?: string }) { + return ( + + + + ) +} diff --git a/docs/src/app/(home)/_components/hero-button.ts b/docs/src/app/(home)/_components/hero-button.ts new file mode 100644 index 0000000..8d1e25a --- /dev/null +++ b/docs/src/app/(home)/_components/hero-button.ts @@ -0,0 +1,13 @@ +import { tv } from 'tailwind-variants' + +export const heroButton = tv({ + base: 'flex items-center justify-center gap-2 rounded-full px-6 py-2.5 text-sm font-medium ring-1 ring-inset backdrop-blur transition-all', + variants: { + variant: { + primary: + 'bg-fd-primary/90 text-fd-primary-foreground ring-fd-primary/20 hover:bg-fd-primary hover:ring-fd-primary/40', + secondary: + 'bg-fd-foreground/[0.06] text-fd-foreground/70 ring-fd-foreground/[0.08] hover:bg-fd-foreground/[0.12] hover:text-fd-foreground/90', + }, + }, +}) diff --git a/docs/src/app/(home)/_components/mesh-gradient.tsx b/docs/src/app/(home)/_components/mesh-gradient.tsx new file mode 100644 index 0000000..4849a0f --- /dev/null +++ b/docs/src/app/(home)/_components/mesh-gradient.tsx @@ -0,0 +1,35 @@ +'use client' + +export function MeshGradient() { + return ( +
+
+
+
+
+ ) +} diff --git a/docs/src/app/(home)/_components/terminal.tsx b/docs/src/app/(home)/_components/terminal.tsx new file mode 100644 index 0000000..5789c53 --- /dev/null +++ b/docs/src/app/(home)/_components/terminal.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { motion } from "motion/react"; +import { ease } from "@/lib/animation"; + +function Dot() { + return
; +} + +function OutputLine({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ {" "} + + {label} + + {children} +
+ ); +} + +export function Terminal() { + return ( + +
+
+
+ + + + + terminal + +
+
+
+ $ funnel http 3000 --id + my-app +
+
+
funnel
+
+ + + https://my-app.tunnel.example.com + + + localhost:3000 + my-app +
+
+
+
+ + ); +} diff --git a/docs/src/app/(home)/layout.tsx b/docs/src/app/(home)/layout.tsx index 38bef26..a1e6605 100644 --- a/docs/src/app/(home)/layout.tsx +++ b/docs/src/app/(home)/layout.tsx @@ -1,13 +1,7 @@ -import type { ReactNode } from "react"; -import { HomeLayout } from "fumadocs-ui/layouts/home"; -import { baseOptions } from "@/app/layout.config"; -import { Footer } from "@/components/layout/footer"; +import { HomeLayout } from 'fumadocs-ui/layouts/home' +import { baseOptions } from '@/lib/layout.shared' +import type { ReactNode } from 'react' export default function Layout({ children }: { children: ReactNode }) { - return ( - <> - {children} -