diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e37e60e7..8ec3e994 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,65 +8,83 @@ on: workflow_dispatch: {} jobs: + build-web: + name: Build the frontend + runs-on: ubuntu-latest + env: {FORCE_COLOR: 'true', NPM_PREFIX: './web'} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: {node-version-file: ./web/package.json, cache: 'npm', cache-dependency-path: ./web/package-lock.json} + - run: npm --prefix "$NPM_PREFIX" install -dd --no-audit + - run: npm --prefix "$NPM_PREFIX" run generate + - run: npm --prefix "$NPM_PREFIX" run build + - uses: actions/upload-artifact@v4 + with: {name: web-dist, path: ./web/dist/, if-no-files-found: error, retention-days: 1} + build-app: - name: Build for ${{ matrix.os }} (${{ matrix.arch }}) + name: Build the app runs-on: ubuntu-latest strategy: fail-fast: false matrix: os: [linux, darwin, windows] arch: [amd64, arm64] - env: {FORCE_COLOR: 'true', NPM_PREFIX: './web'} + needs: [build-web] steps: - uses: actions/checkout@v4 - {uses: gacts/github-slug@v1, id: slug} - id: values run: | - majorMinorPatch="${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}" - echo "version=${majorMinorPatch}" >> $GITHUB_OUTPUT - echo "binary-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT - echo "deb-name=webhook-tester_${majorMinorPatch}-1_${{ matrix.arch }}" >> $GITHUB_OUTPUT - # build the frontend - - uses: actions/setup-node@v4 - with: {node-version-file: ./web/package.json, cache: 'npm', cache-dependency-path: ./web/package-lock.json} - - run: | - npm --prefix "$NPM_PREFIX" install --no-audit - npm --prefix "$NPM_PREFIX" run generate - npm --prefix "$NPM_PREFIX" run build - # build the backend + echo "bin-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT - {uses: actions/setup-go@v5, with: {go-version-file: go.mod}} - run: go install "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1" - run: go generate -skip readme ./... + - {uses: actions/download-artifact@v4, with: {name: web-dist, path: ./web/dist}} # put the built frontend - env: GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} CGO_ENABLED: 0 - LDFLAGS: -s -w -X gh.tarampamp.am/webhook-tester/v2/internal/version.version=${{ steps.slug.outputs.version }} - run: go build -trimpath -ldflags "$LDFLAGS" -o ./${{ steps.values.outputs.binary-name }} ./cmd/webhook-tester/ - # upload the binary + LDFLAGS: -s -w -X gh.tarampamp.am/webhook-tester/v2/internal/version.version=${{ steps.slug.outputs.version-semantic }} + run: go build -trimpath -ldflags "$LDFLAGS" -o "./${{ steps.values.outputs.bin-name }}" ./cmd/webhook-tester/ + - uses: actions/upload-artifact@v4 + with: + name: webhook-tester-${{ matrix.os }}-${{ matrix.arch }} + path: ./${{ steps.values.outputs.bin-name }} + if-no-files-found: error + retention-days: 1 - uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.values.outputs.binary-name }} - asset_name: ${{ steps.values.outputs.binary-name }} + file: ./${{ steps.values.outputs.bin-name }} + asset_name: ${{ steps.values.outputs.bin-name }} tag: ${{ github.ref }} - # build a Debian package - - if: matrix.os == 'linux' && matrix.arch == 'amd64' - env: - PKG_NAME: ${{ steps.values.outputs.deb-name }} + + build-deb-package: + name: Build the Debian package + runs-on: ubuntu-latest + strategy: {matrix: {arch: [amd64, arm64]}} + needs: [build-app] + steps: + - {uses: gacts/github-slug@v1, id: slug} + - {uses: actions/download-artifact@v4, with: {name: "webhook-tester-linux-${{ matrix.arch }}"}} + - id: values run: | - mkdir -p ${{ env.PKG_NAME }}/usr/local/bin ${{ env.PKG_NAME }}/DEBIAN - cp ${{ steps.values.outputs.binary-name }} ${{ env.PKG_NAME }}/usr/local/bin/webhook-tester - echo -e "Package: webhook-tester\nVersion: ${{ steps.values.outputs.version }}" > ${{ env.PKG_NAME }}/DEBIAN/control - echo -e "Architecture: ${{ matrix.arch }}\nMaintainer: ${{ github.actor }}" >> ${{ env.PKG_NAME }}/DEBIAN/control - echo -e "Description: Powerful tool for testing WebHooks and more" >> ${{ env.PKG_NAME }}/DEBIAN/control - dpkg-deb --build --root-owner-group ${{ env.PKG_NAME }} - - if: matrix.os == 'linux' && matrix.arch == 'amd64' - uses: svenstaro/upload-release-action@v2 + majorMinorPatch="${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}" + echo "version=${majorMinorPatch}" >> $GITHUB_OUTPUT + echo "pkg-name=webhook-tester_${majorMinorPatch}-1_${{ matrix.arch }}" >> $GITHUB_OUTPUT + - run: | + mkdir -p ./${{ steps.values.outputs.pkg-name }}/usr/local/bin ./${{ steps.values.outputs.pkg-name }}/DEBIAN + mv ./webhook-tester-linux-${{ matrix.arch }} ./${{ steps.values.outputs.pkg-name }}/usr/local/bin/webhook-tester + echo -e "Package: webhook-tester\nVersion: ${{ steps.values.outputs.version }}" > ./${{ steps.values.outputs.pkg-name }}/DEBIAN/control + echo -e "Architecture: ${{ matrix.arch }}\nMaintainer: ${{ github.actor }}" >> ./${{ steps.values.outputs.pkg-name }}/DEBIAN/control + echo -e "Description: Powerful tool for testing WebHooks and more" >> ./${{ steps.values.outputs.pkg-name }}/DEBIAN/control + dpkg-deb --build --root-owner-group ${{ steps.values.outputs.pkg-name }} + - uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.values.outputs.deb-name }}.deb - asset_name: ${{ steps.values.outputs.binary-name }}.deb + file: ./${{ steps.values.outputs.pkg-name }}.deb + asset_name: ${{ steps.values.outputs.pkg-name }}.deb tag: ${{ github.ref }} build-docker-image: @@ -92,7 +110,7 @@ jobs: file: ./Dockerfile push: true platforms: linux/amd64,linux/arm/v7,linux/arm64/v8 - build-args: "APP_VERSION=${{ steps.slug.outputs.version }}" + build-args: "APP_VERSION=${{ steps.slug.outputs.version-semantic }}" tags: | # TODO: add `ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:latest` and `docker.io/tarampampam/webhook-tester:latest` ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version }} ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0e646b8a..941a615d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,8 +24,8 @@ jobs: - {uses: actions/checkout@v4, with: {fetch-depth: 0}} - uses: gacts/gitleaks@v1 - lint-and-test: - name: Test and lint (backend) + lint-and-test-backend: + name: Test and lint the backend runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -35,8 +35,8 @@ jobs: - uses: golangci/golangci-lint-action@v6 - run: go test -race -covermode=atomic ./... - lint-and-test-web: - name: Test and lint (web) + lint-and-test-frontend: + name: Test and lint the frontend runs-on: ubuntu-latest env: {FORCE_COLOR: 'true', NPM_PREFIX: './web'} steps: @@ -47,69 +47,78 @@ jobs: - run: npm --prefix "$NPM_PREFIX" run generate - run: npm --prefix "$NPM_PREFIX" run lint - run: npm --prefix "$NPM_PREFIX" run test + + build-web: + name: Build the frontend + runs-on: ubuntu-latest + env: {FORCE_COLOR: 'true', NPM_PREFIX: './web'} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: {node-version-file: ./web/package.json, cache: 'npm', cache-dependency-path: ./web/package-lock.json} + - run: npm --prefix "$NPM_PREFIX" install -dd --no-audit + - run: npm --prefix "$NPM_PREFIX" run generate - run: npm --prefix "$NPM_PREFIX" run build + - uses: actions/upload-artifact@v4 + with: {name: web-dist, path: ./web/dist/, if-no-files-found: error, retention-days: 1} build-app: - name: Build for ${{ matrix.os }} (${{ matrix.arch }}) + name: Build the app runs-on: ubuntu-latest strategy: fail-fast: false - matrix: + matrix: # https://pkg.go.dev/internal/platform os: [linux, darwin, windows] arch: [amd64, arm64] - env: {FORCE_COLOR: 'true', NPM_PREFIX: './web'} - needs: [lint-and-test, lint-and-test-web] + needs: [lint-and-test-backend, lint-and-test-frontend, build-web] steps: - uses: actions/checkout@v4 - {uses: gacts/github-slug@v1, id: slug} - id: values run: | - majorMinorPatch="${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}" - echo "version=${majorMinorPatch}" >> $GITHUB_OUTPUT - echo "binary-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT - echo "deb-name=webhook-tester_${majorMinorPatch}-1_${{ matrix.arch }}" >> $GITHUB_OUTPUT - # build the frontend - - uses: actions/setup-node@v4 - with: {node-version-file: ./web/package.json, cache: 'npm', cache-dependency-path: ./web/package-lock.json} - - run: | - npm --prefix "$NPM_PREFIX" install --no-audit - npm --prefix "$NPM_PREFIX" run generate - npm --prefix "$NPM_PREFIX" run build - # build the backend + echo "app-version=${{ steps.slug.outputs.version-semantic }}@${{ steps.slug.outputs.commit-hash-short }}" >> $GITHUB_OUTPUT + echo "bin-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT - {uses: actions/setup-go@v5, with: {go-version-file: go.mod}} - run: go install "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1" - run: go generate -skip readme ./... + - {uses: actions/download-artifact@v4, with: {name: web-dist, path: ./web/dist}} # put the built frontend - env: GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} CGO_ENABLED: 0 - LDFLAGS: -s -w -X gh.tarampamp.am/webhook-tester/v2/internal/version.version=v${{ steps.values.outputs.version }}@${{ steps.slug.outputs.commit-hash-short }} - run: go build -trimpath -ldflags "$LDFLAGS" -o ./${{ steps.values.outputs.binary-name }} ./cmd/webhook-tester/ - - if: matrix.os == runner.os && matrix.arch == 'amd64' # try to run the binary - run: ./${{ steps.values.outputs.binary-name }} -h - # upload the binary + LDFLAGS: -s -w -X gh.tarampamp.am/webhook-tester/v2/internal/version.version=${{ steps.values.outputs.app-version }} + run: go build -trimpath -ldflags "$LDFLAGS" -o "./${{ steps.values.outputs.bin-name }}" ./cmd/webhook-tester/ - uses: actions/upload-artifact@v4 with: - name: ${{ steps.values.outputs.binary-name }} - path: ./${{ steps.values.outputs.binary-name }} + name: webhook-tester-${{ matrix.os }}-${{ matrix.arch }} + path: ./${{ steps.values.outputs.bin-name }} if-no-files-found: error retention-days: 7 - # build a Debian package - - if: matrix.os == 'linux' && matrix.arch == 'amd64' - env: - PKG_NAME: ${{ steps.values.outputs.deb-name }} + + build-deb-package: + name: Build the Debian package + runs-on: ubuntu-latest + strategy: {matrix: {arch: [amd64, arm64]}} + needs: [build-app] + steps: + - {uses: gacts/github-slug@v1, id: slug} + - {uses: actions/download-artifact@v4, with: {name: "webhook-tester-linux-${{ matrix.arch }}"}} + - id: values run: | - mkdir -p ${{ env.PKG_NAME }}/usr/local/bin ${{ env.PKG_NAME }}/DEBIAN - cp ${{ steps.values.outputs.binary-name }} ${{ env.PKG_NAME }}/usr/local/bin/webhook-tester - echo -e "Package: webhook-tester\nVersion: ${{ steps.values.outputs.version }}" > ${{ env.PKG_NAME }}/DEBIAN/control - echo -e "Architecture: ${{ matrix.arch }}\nMaintainer: ${{ github.actor }}" >> ${{ env.PKG_NAME }}/DEBIAN/control - echo -e "Description: Powerful tool for testing WebHooks and more" >> ${{ env.PKG_NAME }}/DEBIAN/control - dpkg-deb --build --root-owner-group ${{ env.PKG_NAME }} - - if: matrix.os == 'linux' && matrix.arch == 'amd64' - uses: actions/upload-artifact@v4 + majorMinorPatch="${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}" + echo "version=${majorMinorPatch}" >> $GITHUB_OUTPUT + echo "pkg-name=webhook-tester_${majorMinorPatch}-1_${{ matrix.arch }}" >> $GITHUB_OUTPUT + - run: | + mkdir -p ./${{ steps.values.outputs.pkg-name }}/usr/local/bin ./${{ steps.values.outputs.pkg-name }}/DEBIAN + mv ./webhook-tester-linux-${{ matrix.arch }} ./${{ steps.values.outputs.pkg-name }}/usr/local/bin/webhook-tester + echo -e "Package: webhook-tester\nVersion: ${{ steps.values.outputs.version }}" > ./${{ steps.values.outputs.pkg-name }}/DEBIAN/control + echo -e "Architecture: ${{ matrix.arch }}\nMaintainer: ${{ github.actor }}" >> ./${{ steps.values.outputs.pkg-name }}/DEBIAN/control + echo -e "Description: Powerful tool for testing WebHooks and more" >> ./${{ steps.values.outputs.pkg-name }}/DEBIAN/control + dpkg-deb --build --root-owner-group ${{ steps.values.outputs.pkg-name }} + - uses: actions/upload-artifact@v4 with: - name: ${{ steps.values.outputs.binary-name }}.deb - path: ./${{ steps.values.outputs.deb-name }}.deb + name: ${{ steps.values.outputs.pkg-name }}.deb + path: ./${{ steps.values.outputs.pkg-name }}.deb if-no-files-found: error retention-days: 7 @@ -144,7 +153,7 @@ jobs: build-docker-image: name: Build the docker image runs-on: ubuntu-latest - needs: [lint-and-test, lint-and-test-web] + needs: [lint-and-test-backend, lint-and-test-frontend] steps: - uses: actions/checkout@v4 - uses: docker/build-push-action@v6 diff --git a/README.md b/README.md index 598b42d4..64ef03dc 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ the UI—no need for third-party solutions like `pusher.com`! - Standalone operation with in-memory storage/pubsub - no third-party dependencies needed - Fully customizable response code, headers, and body for webhooks +- Option to expose your locally running instance to the global internet (via tunneling) - Fast, built-in UI based on `ReactJS` - Multi-architecture Docker image based on `scratch` - Runs as an unprivileged user in Docker @@ -54,6 +55,15 @@ with the `--pubsub-driver` flag). When running multiple instances of the app, the Redis driver is required. +### 🚀 Tunneling + +Capture webhook requests from the global internet using the `ngrok` tunnel driver. Enable it by setting the +`--tunnel-driver=ngrok` flag and providing your `ngrok` authentication token with `--ngrok-auth-token`. Once enabled, +the app automatically creates the tunnel for you – no need to install or run `ngrok` manually (even using docker). + +With this public URL, you can test your webhooks from external services like GitHub, GitLab, Bitbucket, and more. +You'll never miss a request! + ## 🧩 Installation Download the latest binary for your architecture from the [releases page][link_releases]. For example, to install @@ -62,11 +72,16 @@ on an **amd64** system (e.g., Debian, Ubuntu): [link_releases]:https://github.com/tarampampam/webhook-tester/releases ```shell -$ curl -SsL -o ./webhook-tester https://github.com/tarampampam/webhook-tester/releases/latest/download/webhook-tester-linux-amd64 -$ chmod +x ./webhook-tester -$ ./webhook-tester start +curl -SsL -o ./webhook-tester https://github.com/tarampampam/webhook-tester/releases/latest/download/webhook-tester-linux-amd64 +chmod +x ./webhook-tester +./webhook-tester start ``` +> [!TIP] +> Each release includes binaries for **linux**, **darwin** (macOS) and **windows** (`amd64` and `arm64` architectures). +> You can download the binary for your system from the [releases page][link_releases] (section `Assets`). And - yes, +> all what you need is just download and run single binary file. + Alternatively, you can use the Docker image: | Registry | Image | @@ -80,8 +95,20 @@ Alternatively, you can use the Docker image: ## ⚙ Usage +The easiest way to run the app is by using the Docker image: + +```shell +docker run --rm -t -p "8080:8080/tcp" ghcr.io/tarampampam/webhook-tester:2 +``` + > [!NOTE] -> TODO: Add usage examples +> This command starts the app with the default configuration on port `8080` (the first port in the `-p` argument is +> the host port, and the second is the application port inside the container). + +Next, open your browser at [`localhost:8080`](http://localhost:8080) to begin testing your webhooks. To stop the app, press `Ctrl+C` in +the terminal where it's running. + +For custom configuration options, refer to the CLI help below or execute the app with the `--help` flag. [link_ghcr]:https://github.com/users/tarampampam/packages/container/package/webhook-tester [link_docker_hub]:https://hub.docker.com/r/tarampampam/webhook-tester/ @@ -130,6 +157,8 @@ The following flags are supported: | `--max-request-body-size="…"` | maximal webhook request body size (in bytes), zero means unlimited | `0` | `MAX_REQUEST_BODY_SIZE` | | `--auto-create-sessions` | automatically create sessions for incoming requests | `false` | `AUTO_CREATE_SESSIONS` | | `--pubsub-driver="…"` | pub/sub driver (memory/redis) | `memory` | `PUBSUB_DRIVER` | +| `--tunnel-driver="…"` | tunnel driver to expose your locally running app to the internet (ngrok, empty to disable) | | `TUNNEL_DRIVER` | +| `--ngrok-auth-token="…"` | ngrok authentication token (required for ngrok tunnel; create a new one at https://dashboard.ngrok.com/authtokens/new) | | `NGROK_AUTHTOKEN` | | `--redis-dsn="…"` | redis-like (redis, keydb) server DSN (e.g. redis://user:pwd@127.0.0.1:6379/0 or unix://user:pwd@/path/to/redis.sock?db=0) | `redis://127.0.0.1:6379/0` | `REDIS_DSN` | | `--shutdown-timeout="…"` | maximum duration for graceful shutdown | `15s` | `SHUTDOWN_TIMEOUT` | | `--use-live-frontend` | use frontend from the local directory instead of the embedded one (useful for development) | `false` | *none* | diff --git a/api/openapi.yml b/api/openapi.yml index 9fcf40f8..6014c17f 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -244,7 +244,15 @@ components: session_ttl: {type: integer, x-go-type: uint32, example: 5, description: In seconds} required: [max_requests, max_request_body_size, session_ttl] additionalProperties: false - required: [limits] + tunnel: + type: object + description: Tunnel settings + properties: + enabled: {type: boolean, example: true} + url: {type: string, example: 'https://tunnel.example.com/'} # optional + required: [enabled] + additionalProperties: false + required: [limits, tunnel] additionalProperties: false CapturedRequest: @@ -269,7 +277,7 @@ components: description: The same as CapturedRequest, but without the request payload properties: uuid: {$ref: '#/components/schemas/UUID'} - client_address: {type: string, format: IPv4, example: '214.184.32.7'} + client_address: {type: string, example: '214.184.32.7', description: 'May be IPv6 like 2a0e:4005:1002:ffff:185:40:4:132'} method: {$ref: '#/components/schemas/HttpMethod'} headers: {type: array, items: {$ref: '#/components/schemas/HttpHeader'}} url: {type: string, example: 'https://example.com/path?query=string'} diff --git a/go.mod b/go.mod index 04759ece..2322ae98 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/urfave/cli-docs/v3 v3.0.0-alpha5 github.com/urfave/cli/v3 v3.0.0-alpha9.1 go.uber.org/zap v1.27.0 + golang.ngrok.com/ngrok v1.11.0 ) require ( @@ -21,9 +22,22 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-stack/stack v1.8.1 // indirect + github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible // indirect + github.com/inconshreveable/log15/v3 v3.0.0-testing.5 // indirect + github.com/jpillora/backoff v1.0.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect - go.uber.org/multierr v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.ngrok.com/muxado/v2 v2.0.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/term v0.23.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index db802555..1927f77b 100644 --- a/go.sum +++ b/go.sum @@ -19,11 +19,28 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= +github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible h1:VryeOTiaZfAzwx8xBcID1KlJCeoWSIpsNbSk+/D2LNk= +github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o= +github.com/inconshreveable/log15/v3 v3.0.0-testing.5 h1:h4e0f3kjgg+RJBlKOabrohjHe47D3bbAB9BgMrc3DYA= +github.com/inconshreveable/log15/v3 v3.0.0-testing.5/go.mod h1:3GQg1SVrLoWGfRv/kAZMsdyU5cp8eFc1P3cw+Wwku94= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -45,11 +62,33 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.ngrok.com/muxado/v2 v2.0.0 h1:bu9eIDhRdYNtIXNnqat/HyMeHYOAbUH55ebD7gTvW6c= +golang.ngrok.com/muxado/v2 v2.0.0/go.mod h1:wzxJYX4xiAtmwumzL+QsukVwFRXmPNv86vB8RPpOxyM= +golang.ngrok.com/ngrok v1.11.0 h1:lvbBcoOvH+Ek15wgrjvxpCB+PBM7vinU6jQPsrCdOLw= +golang.ngrok.com/ngrok v1.11.0/go.mod h1:1/gLOyOJm7ygHJlcEbtldFLQwQnQ42z+rucpLE2YsvA= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= +golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/start/command.go b/internal/cli/start/command.go index c7fa2e2f..48f6f654 100644 --- a/internal/cli/start/command.go +++ b/internal/cli/start/command.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "net" + "net/url" "strings" "time" @@ -20,6 +21,7 @@ import ( "gh.tarampamp.am/webhook-tester/v2/internal/logger" "gh.tarampamp.am/webhook-tester/v2/internal/pubsub" "gh.tarampamp.am/webhook-tester/v2/internal/storage" + "gh.tarampamp.am/webhook-tester/v2/internal/tunnel" "gh.tarampamp.am/webhook-tester/v2/internal/version" ) @@ -44,6 +46,12 @@ type ( pubSub struct { driver string // Pub/Sub driver } + tunnel struct { + driver string // tunnel driver + } + ngrok struct { + authToken string // ngrok authentication token + } redis struct { dsn string // redis-like server DSN } @@ -57,18 +65,16 @@ type ( ) const ( - PubSubDriverMemory = "memory" - PubSubDriverRedis = "redis" - - StorageDriverMemory = "memory" - StorageDriverRedis = "redis" + pubSubDriverMemory, pubSubDriverRedis = "memory", "redis" + storageDriverMemory, storageDriverRedis = "memory", "redis" + tunnelDriverNgrok = "ngrok" ) // NewCommand creates new `start` command. func NewCommand(log *zap.Logger, defaultHttpPort uint16) *cli.Command { //nolint:funlen var cmd command - const httpCategory = "HTTP" + const httpCategory, tunnelCategory = "HTTP", "TUNNEL" var ( httpAddrFlag = cli.StringFlag{ @@ -135,14 +141,14 @@ func NewCommand(log *zap.Logger, defaultHttpPort uint16) *cli.Command { //nolint } storageDriverFlag = cli.StringFlag{ Name: "storage-driver", - Value: StorageDriverMemory, - Usage: "storage driver (" + strings.Join([]string{StorageDriverMemory, StorageDriverRedis}, "/") + ")", + Value: storageDriverMemory, + Usage: "storage driver (" + strings.Join([]string{storageDriverMemory, storageDriverRedis}, "/") + ")", Sources: cli.EnvVars("STORAGE_DRIVER"), OnlyOnce: true, Config: cli.StringConfig{TrimSpace: true}, Validator: func(s string) error { switch s { - case StorageDriverMemory, StorageDriverRedis: + case storageDriverMemory, storageDriverRedis: return nil default: return fmt.Errorf("wrong storage driver [%s]", s) @@ -193,20 +199,49 @@ func NewCommand(log *zap.Logger, defaultHttpPort uint16) *cli.Command { //nolint } pubSubDriverFlag = cli.StringFlag{ Name: "pubsub-driver", - Value: PubSubDriverMemory, - Usage: "pub/sub driver (" + strings.Join([]string{PubSubDriverMemory, PubSubDriverRedis}, "/") + ")", + Value: pubSubDriverMemory, + Usage: "pub/sub driver (" + strings.Join([]string{pubSubDriverMemory, pubSubDriverRedis}, "/") + ")", Sources: cli.EnvVars("PUBSUB_DRIVER"), OnlyOnce: true, Config: cli.StringConfig{TrimSpace: true}, Validator: func(s string) error { switch s { - case PubSubDriverMemory, PubSubDriverRedis: + case pubSubDriverMemory, pubSubDriverRedis: return nil default: return fmt.Errorf("wrong pub/sub driver [%s]", s) } }, } + tunnelDriverFlag = cli.StringFlag{ + Name: "tunnel-driver", + Category: tunnelCategory, + Value: "", // no driver by default + Usage: "tunnel driver to expose your locally running app to the internet " + + "(" + strings.Join([]string{tunnelDriverNgrok}, "/") + ", empty to disable)", + Sources: cli.EnvVars("TUNNEL_DRIVER"), + OnlyOnce: true, + Config: cli.StringConfig{TrimSpace: true}, + Validator: func(s string) error { + switch s { + case "": + return nil // no tunnel + case tunnelDriverNgrok: + return nil // ngrok + default: + return fmt.Errorf("wrong tunnel driver [%s]", s) + } + }, + } + ngrokAuthTokenFlag = cli.StringFlag{ + Name: "ngrok-auth-token", + Category: tunnelCategory, + Usage: "ngrok authentication token (required for ngrok tunnel; create a new one " + + "at https://dashboard.ngrok.com/authtokens/new)", + Sources: cli.EnvVars("NGROK_AUTHTOKEN"), + OnlyOnce: true, + Config: cli.StringConfig{TrimSpace: true}, + } redisServerDsnFlag = cli.StringFlag{ Name: "redis-dsn", Usage: "redis-like (redis, keydb) server DSN (e.g. redis://user:pwd@127.0.0.1:6379/0 or " + @@ -251,10 +286,18 @@ func NewCommand(log *zap.Logger, defaultHttpPort uint16) *cli.Command { //nolint opt.maxRequestPayloadSize = uint32(c.Uint(maxRequestPayloadSizeFlag.Name)) //nolint:gosec opt.autoCreateSessions = c.Bool(autoCreateSessionsFlag.Name) opt.pubSub.driver = c.String(pubSubDriverFlag.Name) + opt.tunnel.driver = c.String(tunnelDriverFlag.Name) + opt.ngrok.authToken = c.String(ngrokAuthTokenFlag.Name) opt.redis.dsn = c.String(redisServerDsnFlag.Name) opt.timeouts.shutdown = c.Duration(shutdownTimeoutFlag.Name) opt.frontend.useLive = c.Bool(useLiveFrontendFlag.Name) + if opt.tunnel.driver == tunnelDriverNgrok && opt.ngrok.authToken == "" { + return fmt.Errorf("ngrok authentication token (--%s or %s) is required", + ngrokAuthTokenFlag.Name, ngrokAuthTokenFlag.Sources.String(), + ) + } + return cmd.Run(ctx, log) }, Flags: []cli.Flag{ @@ -269,6 +312,8 @@ func NewCommand(log *zap.Logger, defaultHttpPort uint16) *cli.Command { //nolint &maxRequestPayloadSizeFlag, &autoCreateSessionsFlag, &pubSubDriverFlag, + &tunnelDriverFlag, + &ngrokAuthTokenFlag, &redisServerDsnFlag, &shutdownTimeoutFlag, &useLiveFrontendFlag, @@ -305,7 +350,7 @@ func (cmd *command) Run(parentCtx context.Context, log *zap.Logger) error { //no var rdc *redis.Client // may be nil // establish connection to Redis server if needed - if cmd.options.pubSub.driver == PubSubDriverRedis || cmd.options.storage.driver == StorageDriverRedis { + if cmd.options.pubSub.driver == pubSubDriverRedis || cmd.options.storage.driver == storageDriverRedis { var opt, pErr = redis.ParseURL(cmd.options.redis.dsn) if pErr != nil { return fmt.Errorf("failed to parse Redis DSN: %w", pErr) @@ -325,11 +370,11 @@ func (cmd *command) Run(parentCtx context.Context, log *zap.Logger) error { //no // create the storage switch cmd.options.storage.driver { - case StorageDriverMemory: + case storageDriverMemory: var inMemory = storage.NewInMemory(cmd.options.storage.sessionTTL, uint32(cmd.options.storage.maxRequests)) //nolint:contextcheck,lll defer func() { _ = inMemory.Close() }() db = inMemory //nolint:wsl - case StorageDriverRedis: + case storageDriverRedis: db = storage.NewRedis(rdc, cmd.options.storage.sessionTTL, uint32(cmd.options.storage.maxRequests)) default: return fmt.Errorf("unknown storage driver [%s]", cmd.options.storage.driver) @@ -339,9 +384,9 @@ func (cmd *command) Run(parentCtx context.Context, log *zap.Logger) error { //no // create the Pub/Sub switch cmd.options.pubSub.driver { - case PubSubDriverMemory: + case pubSubDriverMemory: pubSub = pubsub.NewInMemory[pubsub.CapturedRequest]() - case PubSubDriverRedis: + case pubSubDriverRedis: pubSub = pubsub.NewRedis[pubsub.CapturedRequest](rdc, encoding.JSON{}) default: return fmt.Errorf("unknown Pub/Sub driver [%s]", cmd.options.pubSub.driver) @@ -349,6 +394,13 @@ func (cmd *command) Run(parentCtx context.Context, log *zap.Logger) error { //no var httpLog = log.Named("http") + var appSettings = config.AppSettings{ + MaxRequests: cmd.options.storage.maxRequests, + MaxRequestBodySize: cmd.options.maxRequestPayloadSize, + SessionTTL: cmd.options.storage.sessionTTL, + AutoCreateSessions: cmd.options.autoCreateSessions, + } + // create HTTP server var server = appHttp.NewServer(ctx, httpLog, appHttp.WithReadTimeout(cmd.options.timeouts.httpRead), @@ -359,12 +411,7 @@ func (cmd *command) Run(parentCtx context.Context, log *zap.Logger) error { //no httpLog, cmd.readinessChecker(rdc), cmd.latestAppVersionGetter(), - config.AppSettings{ - MaxRequests: cmd.options.storage.maxRequests, - MaxRequestBodySize: cmd.options.maxRequestPayloadSize, - SessionTTL: cmd.options.storage.sessionTTL, - AutoCreateSessions: cmd.options.autoCreateSessions, - }, + &appSettings, db, pubSub, cmd.options.frontend.useLive, @@ -396,6 +443,27 @@ func (cmd *command) Run(parentCtx context.Context, log *zap.Logger) error { //no }(), cmd.options.http.tcpPort)), ) + var tun tunnel.Tunneler + + if cmd.options.tunnel.driver == tunnelDriverNgrok { + tun = tunnel.NewNgrok(cmd.options.ngrok.authToken, tunnel.WithNgrokLogger(log.Named("ngrok"))) + } + + if tun != nil { + defer func() { _ = tun.Close() }() + + if pubUrl, err := tun.Expose(ctx, cmd.options.http.tcpPort); err != nil { + log.Error("Failed to start tunnel", zap.Error(err)) + } else { + log.Info("Tunnel started", zap.String("url", pubUrl)) + + if u, uErr := url.Parse(pubUrl); uErr == nil { + // FIXME: concurrent write to the appSettings without mutex + appSettings.TunnelEnabled, appSettings.TunnelURL = true, u + } + } + } + if err := server.StartHTTP(ctx, httpLn); err != nil { cancel() // cancel the context on error (this is critical for us) diff --git a/internal/config/config.go b/internal/config/config.go index e70941fc..81f24275 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,10 +1,15 @@ package config -import "time" +import ( + "net/url" + "time" +) type AppSettings struct { - MaxRequests uint16 - MaxRequestBodySize uint32 - SessionTTL time.Duration - AutoCreateSessions bool + MaxRequests uint16 // how many requests can be stored in the storage + MaxRequestBodySize uint32 // max size of the request body + SessionTTL time.Duration // session time to live + AutoCreateSessions bool // feature: auto create sessions + TunnelEnabled bool // feature: tunnel (public url to local server) enabled + TunnelURL *url.URL // tunnel public url } diff --git a/internal/http/frontend/middleware_test.go b/internal/http/frontend/handler_test.go similarity index 100% rename from internal/http/frontend/middleware_test.go rename to internal/http/frontend/handler_test.go diff --git a/internal/http/handlers/settings_get/handler.go b/internal/http/handlers/settings_get/handler.go index c5acaed3..77ca9666 100644 --- a/internal/http/handlers/settings_get/handler.go +++ b/internal/http/handlers/settings_get/handler.go @@ -5,14 +5,20 @@ import ( "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" ) -type Handler struct{ cfg config.AppSettings } +type Handler struct{ cfg *config.AppSettings } -func New(s config.AppSettings) *Handler { return &Handler{cfg: s} } +func New(s *config.AppSettings) *Handler { return &Handler{cfg: s} } func (h *Handler) Handle() (resp openapi.SettingsResponse) { resp.Limits.MaxRequestBodySize = h.cfg.MaxRequestBodySize resp.Limits.MaxRequests = h.cfg.MaxRequests resp.Limits.SessionTtl = uint32(h.cfg.SessionTTL.Seconds()) + if h.cfg.TunnelEnabled && h.cfg.TunnelURL != nil { + var tunnelUrl = h.cfg.TunnelURL.String() + + resp.Tunnel.Enabled, resp.Tunnel.Url = true, &tunnelUrl + } + return } diff --git a/internal/http/middleware/webhook/middleware.go b/internal/http/middleware/webhook/middleware.go index a995abbe..9f98bfd7 100644 --- a/internal/http/middleware/webhook/middleware.go +++ b/internal/http/middleware/webhook/middleware.go @@ -25,7 +25,7 @@ func New( //nolint:funlen,gocognit,gocyclo log *zap.Logger, db storage.Storage, pub pubsub.Publisher[pubsub.CapturedRequest], - cfg config.AppSettings, + cfg *config.AppSettings, ) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/openapi.go b/internal/http/openapi.go index 467daa41..16272aa9 100644 --- a/internal/http/openapi.go +++ b/internal/http/openapi.go @@ -61,7 +61,7 @@ func NewOpenAPI( log *zap.Logger, rdyChecker func(context.Context) error, lastAppVer func(context.Context) (string, error), - cfg config.AppSettings, + cfg *config.AppSettings, db storage.Storage, pubSub pubsub.PubSub[pubsub.CapturedRequest], ) *OpenAPI { diff --git a/internal/http/server.go b/internal/http/server.go index 48513ebb..c53c3429 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -63,7 +63,7 @@ func (s *Server) Register( log *zap.Logger, rdyChk func(context.Context) error, lastAppVer func(context.Context) (string, error), - cfg config.AppSettings, + cfg *config.AppSettings, db storage.Storage, pubSub pubsub.PubSub[pubsub.CapturedRequest], useLiveFrontend bool, diff --git a/internal/http/server_test.go b/internal/http/server_test.go index 3f27a34b..16afef9a 100644 --- a/internal/http/server_test.go +++ b/internal/http/server_test.go @@ -48,7 +48,7 @@ func TestServer_StartHTTP(t *testing.T) { log, func(context.Context) error { return nil }, func(context.Context) (string, error) { return "v1.0.0", nil }, - config.AppSettings{}, + &config.AppSettings{}, db, pubsub.NewInMemory[pubsub.CapturedRequest](), false, diff --git a/internal/tunnel/conn_pool.go b/internal/tunnel/conn_pool.go new file mode 100644 index 00000000..2dd33106 --- /dev/null +++ b/internal/tunnel/conn_pool.go @@ -0,0 +1,191 @@ +package tunnel + +import ( + "context" + "net" + "sync" +) + +type ( + ConnectionsPool struct { + poolCh chan Connection // a channel to keep active connections + needNewCh chan struct{} // a channel with notifications about the need to create a new connection + stop chan struct{} // a channel to stop all goroutines + onError func(error) // may be nil or a function to call on error + dialer func(_ context.Context, network, address string) (net.Conn, error) + } + + Connection struct { + Conn net.Conn + onRelease func() // a function to call when the connection is released + } +) + +// Release closes the connection and notifies the pool about the need to create a new connection. +// Call this method when you no longer need the connection, this is very important to keep the pool! +func (rc Connection) Release() { + if rc.Conn != nil { + _ = rc.Conn.Close() + } + + if rc.onRelease != nil { + rc.onRelease() + } +} + +// ConnectionsPoolOption allows you to configure the ConnectionsPool during creation. +type ConnectionsPoolOption func(*ConnectionsPool) + +// WithConnectionsPoolErrorsHandler sets the error handler for the ConnectionsPool. +func WithConnectionsPoolErrorsHandler(fn func(error)) ConnectionsPoolOption { + return func(cp *ConnectionsPool) { cp.onError = fn } +} + +// WithConnectionsPoolDialer sets the dialer for the ConnectionsPool. By default, the pool uses the +// [net.Dialer.DialContext] method. Useful for testing purposes. +func WithConnectionsPoolDialer(dialer func(context.Context, string, string) (net.Conn, error)) ConnectionsPoolOption { + return func(cp *ConnectionsPool) { cp.dialer = dialer } +} + +// NewConnectionsPool creates a new pool of connections to the remote server. The pool will create +// connections in the background and will keep the total number of connections equal to the size +// parameter. The pool will create new connections when the connection will be released using the +// [Connection.Release] method. +// +// To close the pool and release all connections, call the returned cleanup function (it will close +// all connections, stop all goroutines and empty the pool of connections). +func NewConnectionsPool( //nolint:funlen,gocognit,gocyclo + ctx context.Context, + remoteAddr string, + size uint, + opts ...ConnectionsPoolOption, +) (ConnectionsPool, func()) { + var cp = ConnectionsPool{ + poolCh: make(chan Connection, size), + needNewCh: make(chan struct{}, size), + stop: make(chan struct{}), + dialer: (&net.Dialer{}).DialContext, // default dialer + } + + for _, opt := range opts { + opt(&cp) + } + + var wg sync.WaitGroup // is used to wait for all goroutines to exit + + for range size { + // fill the channel with the initial amount of notifications + cp.needNewCh <- struct{}{} + + // increment the counter of the goroutines + wg.Add(1) + + // the following goroutine is responsible for creating new connections to the remote server + // and adding them to the pool. in case of an error, it will notify the pool about the need + // to create a new connection. the goroutine will exit when the context is canceled or the + // stop channel is closed + go func() { + var ( + conn net.Conn + connErr error + ) + + defer func() { + if conn != nil { // close the last established connection on exit + _ = conn.Close() + } + + wg.Done() + }() + + for { // infinite loop to create new connections + select { + case <-ctx.Done(): + return // exit the goroutine if the context was canceled + case <-cp.stop: + return // or if the stop channel was closed + case _, isOpened := <-cp.needNewCh: + if !isOpened { // if the channel is closed, exit the goroutine + return + } + + if ctx.Err() != nil { // check if the context was canceled + return + } + + // try to establish a connection to the remote server + conn, connErr = cp.dialer(ctx, "tcp", remoteAddr) + if connErr != nil { // on connection error + if cp.onError != nil { + cp.onError(connErr) // call the error handler + } + + select { + case <-ctx.Done(): + return + case <-cp.stop: + return + case cp.needNewCh <- struct{}{}: + continue // notify about the need to create a new connection, and jump to retry + } + } + + // on success, add the connection to the poolCh + cp.poolCh <- Connection{ + Conn: conn, + onRelease: func() { + select { + case <-ctx.Done(): + case <-cp.stop: + case cp.needNewCh <- struct{}{}: // notify about the need to create a new connection + } + }, + } + } + } + }() + } + + return cp, sync.OnceFunc(func() { + close(cp.stop) // close the stop channel to stop all goroutines + wg.Wait() // wait for all goroutines to exit + + // empty the poolCh + for len(cp.poolCh) > 0 { + conn := <-cp.poolCh + conn.Release() + } + + close(cp.poolCh) // close the poolCh channel + + // empty the needNewCh channel + for len(cp.needNewCh) > 0 { + <-cp.needNewCh + } + + close(cp.needNewCh) // close the needNewCh channel + }) +} + +// Get returns an active connection from the pool. If the pool is empty, the method will block until +// a new connection is established and added to the pool, or the context is canceled. +// +// Use the 2nd return value to check if the connection was successfully received. +func (cp ConnectionsPool) Get(optionalCtx ...context.Context) (Connection, bool) { + var ctx context.Context + + if len(optionalCtx) > 0 { + ctx = optionalCtx[0] + } else { + ctx = context.Background() + } + + select { + case <-cp.stop: + return Connection{}, false + case <-ctx.Done(): + return Connection{}, false + case conn, isOpened := <-cp.poolCh: + return conn, isOpened + } +} diff --git a/internal/tunnel/conn_pool_test.go b/internal/tunnel/conn_pool_test.go new file mode 100644 index 00000000..04b4413f --- /dev/null +++ b/internal/tunnel/conn_pool_test.go @@ -0,0 +1,78 @@ +package tunnel_test + +import ( + "context" + "net" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "gh.tarampamp.am/webhook-tester/v2/internal/tunnel" +) + +type fakeDialer struct { + dialCount atomic.Int32 + giveConn net.Conn + giveErr error +} + +func (fd *fakeDialer) dial(_ context.Context, _, _ string) (net.Conn, error) { + fd.dialCount.Add(1) + + return fd.giveConn, fd.giveErr +} + +func TestConnectionsPool_Get(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + dialer = &fakeDialer{giveConn: &net.TCPConn{}} + ) + + // create a new connections pool + tc, stop := tunnel.NewConnectionsPool(ctx, "", 10, tunnel.WithConnectionsPoolDialer(dialer.dial)) + + t.Cleanup(stop) // schedule the pool to be stopped on test exit + + var wg sync.WaitGroup + + for range 100 { + wg.Add(1) + + go func() { + defer wg.Done() + + // get a new connection from the pool + conn, got := tc.Get(ctx) + require.True(t, got) + require.NotNil(t, conn.Conn) + require.NotPanics(t, conn.Release) + }() + } + + wg.Wait() // wait until all goroutines are finished + + <-time.After(time.Millisecond) // wait for the last dial to be completed + runtime.Gosched() // give the scheduler a chance to run + + // the dial function should be called 110 times (10 initial dials + 100 dials from goroutines) + require.Equal(t, int32(100+10), dialer.dialCount.Load()) + + stop() // after this line, each attempt to get a new connection should return false and nil connection + + for range 100 { + conn, got := tc.Get(ctx) + require.False(t, got) + require.Nil(t, conn.Conn) + require.NotPanics(t, conn.Release) + } + + conn, got := tc.Get() // without context + require.False(t, got) + require.Nil(t, conn.Conn) +} diff --git a/internal/tunnel/ngrok.go b/internal/tunnel/ngrok.go new file mode 100644 index 00000000..18443fd2 --- /dev/null +++ b/internal/tunnel/ngrok.go @@ -0,0 +1,116 @@ +package tunnel + +import ( + "context" + "errors" + "fmt" + "net/url" + "sync/atomic" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "golang.ngrok.com/ngrok" + "golang.ngrok.com/ngrok/config" + ngrokLog "golang.ngrok.com/ngrok/log" +) + +type Ngrok struct { + tunnel atomic.Pointer[ngrok.Forwarder] + authToken string + log ngrokLog.Logger +} + +// NgrokOption is a functional option for the Ngrok instance. +type NgrokOption func(*Ngrok) + +// WithNgrokLogger sets the logger for the Ngrok instance. +func WithNgrokLogger(log *zap.Logger) NgrokOption { + return func(n *Ngrok) { n.log = &ngrokLogAdapter{zap: log} } +} + +// NewNgrok creates a new Ngrok instance with the given auth token and options. +func NewNgrok(authToken string, opts ...NgrokOption) *Ngrok { + var n = Ngrok{ + authToken: authToken, + log: &ngrokLogAdapter{zap: zap.NewNop()}, + } + + for _, opt := range opts { + opt(&n) + } + + return &n +} + +func (n *Ngrok) Expose(ctx context.Context, localPort uint16) (string, error) { + if n.tunnel.Load() != nil { + return "", errors.New("tunnel already started") + } + + var backendUrl, uErr = url.Parse(fmt.Sprintf("http://127.0.0.1:%d", localPort)) + if uErr != nil { + return "", fmt.Errorf("failed to parse backend url: %w", uErr) + } + + ln, tErr := ngrok.ListenAndForward( + ctx, + backendUrl, + config.HTTPEndpoint(), + ngrok.WithAuthtoken(n.authToken), + ngrok.WithLogger(n.log), + ) + if tErr != nil { + return "", tErr + } + + n.tunnel.Store(&ln) + + return ln.URL(), nil +} + +func (n *Ngrok) Close() error { + if old := n.tunnel.Swap(nil); old != nil { + return (*old).Close() + } + + return errors.New("tunnel not started") +} + +// ngrokLogAdapter is an adapter for the [ngrokLog.Logger] interface. +type ngrokLogAdapter struct{ zap *zap.Logger } + +var _ ngrokLog.Logger = (*ngrokLogAdapter)(nil) // ensure ngrokLogAdapter implements [ngrokLog.Logger] + +// Log a message at the given level with data key/value pairs. data may be nil. +func (n *ngrokLogAdapter) Log(_ context.Context, level ngrokLog.LogLevel, msg string, data map[string]any) { + var lvl zapcore.Level + + switch level { + case ngrokLog.LogLevelTrace: + lvl = zapcore.DebugLevel + case ngrokLog.LogLevelDebug: + lvl = zapcore.DebugLevel + case ngrokLog.LogLevelInfo: + lvl = zapcore.InfoLevel + case ngrokLog.LogLevelWarn: + lvl = zapcore.WarnLevel + case ngrokLog.LogLevelError: + lvl = zapcore.ErrorLevel + case ngrokLog.LogLevelNone: + lvl = zapcore.DebugLevel + default: + n.zap.Error(fmt.Sprintf("invalid log level: %v", level)) + + return + } + + if ce := n.zap.Check(lvl, msg); ce != nil { + var fields = make([]zap.Field, 0, len(data)) + + for k, v := range data { + fields = append(fields, zap.Any(k, v)) + } + + ce.Write(fields...) + } +} diff --git a/internal/tunnel/tinnel.go b/internal/tunnel/tinnel.go new file mode 100644 index 00000000..286e714f --- /dev/null +++ b/internal/tunnel/tinnel.go @@ -0,0 +1,13 @@ +package tunnel + +import ( + "context" +) + +type Tunneler interface { + // Close the tunnel. + Close() error + + // Expose starts a tunnel to the local port and returns the public URL. To close/stop the tunnel, call Close. + Expose(ctx context.Context, localPort uint16) (string, error) +} diff --git a/web/public/web-app-manifest-screenshot-640x320.png~ b/web/public/web-app-manifest-screenshot-640x320.png~ deleted file mode 100644 index 42ec57b0..00000000 Binary files a/web/public/web-app-manifest-screenshot-640x320.png~ and /dev/null differ diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3b07a9a2..06be2126 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -11,6 +11,10 @@ type AppSettings = Readonly<{ maxRequestBodySize: number // In bytes sessionTTL: number // In seconds }> + tunnel: Readonly<{ + enabled: boolean + url: URL | null + }> }> type SessionOptions = Readonly<{ @@ -47,7 +51,7 @@ export class Client { constructor(opt?: ClientOptions) { const baseUrl: string | null = opt?.baseUrl - ? opt.baseUrl.replace(/\/+$/, '') + ? opt.baseUrl : typeof window !== 'undefined' // for non-browser environments, like tests ? window.location.protocol + '//' + window.location.host : null @@ -58,7 +62,7 @@ export class Client { this.baseUrl = new URL(baseUrl) - this.api = createClient(opt) + this.api = createClient({ ...opt, baseUrl: baseUrl.toString() }) this.api.use(throwIfNotJSON, throwIfNotValidResponse) } @@ -135,6 +139,10 @@ export class Client { maxRequestBodySize: data.limits.max_request_body_size, sessionTTL: data.limits.session_ttl, // in seconds }), + tunnel: Object.freeze({ + enabled: data.tunnel.enabled, + url: data?.tunnel.url ? new URL(data.tunnel.url) : null, + }), }) return this.cache.settings diff --git a/web/src/screens/components/header.tsx b/web/src/screens/components/header.tsx index 4fbf7a4b..1ab90c3f 100644 --- a/web/src/screens/components/header.tsx +++ b/web/src/screens/components/header.tsx @@ -4,6 +4,7 @@ import { notifications as notify } from '@mantine/notifications' import { IconAdjustmentsAlt, IconBrandGithubFilled, + IconBuildingTunnel, IconCirclePlusFilled, IconCopy, IconGrave2, @@ -34,11 +35,13 @@ export default function Header({ currentVersion: SemVer | null latestVersion: SemVer | null sID: string | null - appSettings: { + appSettings: Readonly<{ setMaxRequestsPerSession: number maxRequestBodySize: number sessionTTLSec: number - } | null + tunnelEnabled: boolean + tunnelUrl: URL | null + }> | null webHookUrl: URL | null isBurgerOpened: boolean onBurgerClick: () => void @@ -117,7 +120,7 @@ export default function Header({ style={{ height: 20 }} title={currentVersion ? 'v' + currentVersion.toString() : undefined} /> - + + )} + + + {isUpdateAvailable && !!latestVersion ? (