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