mirror of
https://github.com/aykhans/sarin.git
synced 2026-08-29 03:24:33 +00:00
Compare commits
94
Commits
v1.2.2
...
8beff615bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8beff615bb | ||
|
|
a85c82e751
|
||
|
|
ad5cfadaf2 | ||
|
|
8078b2c1fe | ||
|
|
3c4f449c97
|
||
|
|
deba048e3c | ||
|
|
77e8bc1eb8 | ||
|
|
a81d95ef6d
|
||
|
|
8195b92547 | ||
|
|
f42c4c70bf | ||
|
|
f3f25e3ba1
|
||
|
|
8121d75a2c
|
||
|
|
3867140704 | ||
|
|
740b6f6bbb
|
||
|
|
88180ae288
|
||
|
|
0314ea4f3e
|
||
|
|
9602595216
|
||
|
|
c195ca2ab2
|
||
|
|
e13eb9b8a8
|
||
|
|
d68c557dd3
|
||
|
|
d08b6dc222
|
||
|
|
8552aab3b1
|
||
|
|
79143beec9 | ||
|
|
bb1ed1c2ce | ||
|
|
a3434b096a | ||
|
|
e83e9346a3
|
||
|
|
01b5233167
|
||
|
|
e93b729145 | ||
|
|
372d228bb1
|
||
|
|
52a6e78e0c | ||
|
|
b22e3a0d25
|
||
|
|
4b52556b30
|
||
|
|
755545d838
|
||
|
|
dc2cd6cf63 | ||
|
|
838d65d83f | ||
|
|
0fe0587823 | ||
|
|
5b301c42a9
|
||
|
|
d35295c1d9
|
||
|
|
a13d9d11f7
|
||
|
|
3393f9e767 | ||
|
|
2144f8e67d
|
||
|
|
9d1a73dd43 | ||
|
|
f4d13d83aa
|
||
|
|
07e9287c39
|
||
|
|
80a8ba1d59
|
||
|
|
31af2d0d3c | ||
|
|
02b28dba2f | ||
|
|
d7bc0706ca | ||
|
|
6e5c6eb0fe | ||
|
|
a349ffd6dd | ||
|
|
606aec693c | ||
|
|
99e442691d | ||
|
|
11ddabc7d4 | ||
|
|
dd23ea73fd | ||
|
|
c4faac9127 | ||
|
|
7a75a4e096 | ||
|
|
ac6f1a1a65
|
||
|
|
db19bee457 | ||
|
|
89c72e12e2
|
||
|
|
1c36e6fa70 | ||
|
|
b0af20ddf5 | ||
|
|
d23f1a7685 | ||
|
|
4ba612908c
|
||
|
|
f36d52a05d | ||
|
|
ce1f585342 | ||
|
|
a4b1c7c89c | ||
|
|
bdbf3b0b91
|
||
|
|
26f72a53f1 | ||
|
|
b628a999f0 | ||
|
|
b2e6e200bd | ||
|
|
edf82ec7c1
|
||
|
|
f24aea819f | ||
|
|
e0db4df17a | ||
|
|
475dda98ff | ||
|
|
dcb0e3171f | ||
|
|
2eebac68c9 | ||
|
|
e62fd33f9c | ||
|
|
e9b9b8890c | ||
|
|
8577c771e4 | ||
|
|
c839b71c9e | ||
|
|
cea692cf1b | ||
|
|
88f5171132 | ||
|
|
16b0081d3e | ||
|
|
1bd58a02b7 | ||
|
|
006029aad1 | ||
|
|
0e0ef72778 | ||
|
|
8d10198f02 | ||
|
|
cf3c8f4cde | ||
|
|
65ef05f960 | ||
|
|
14280f4e43 | ||
|
|
c95b06b1ad | ||
|
|
88d6a0132e | ||
|
|
e33c549f6d | ||
|
|
4a4feb4570 |
@@ -13,10 +13,10 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.26.1
|
||||
go-version: 1.26.5
|
||||
- name: go fix
|
||||
run: |
|
||||
go fix ./...
|
||||
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.11.2
|
||||
version: v2.12.2
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Ref to check out. Defaults to the triggering ref."
|
||||
type: string
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
github_access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: nix build
|
||||
run: nix build .#default --no-link --print-build-logs
|
||||
@@ -21,13 +21,49 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
name: Verify Nix package
|
||||
uses: ./.github/workflows/nix.yaml
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
# nix/package.nix carries its own version string that has to be bumped by
|
||||
# hand; catch a forgotten bump before the tag ships.
|
||||
version:
|
||||
name: Verify package version
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
- name: Check nix/package.nix matches the release tag
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
expected="${TAG#v}"
|
||||
actual="$(sed -n 's/^[[:space:]]*version = "\([^"]*\)".*/\1/p' nix/package.nix | head -1)"
|
||||
if [ -z "$actual" ]; then
|
||||
echo "::error file=nix/package.nix::could not read the version string"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
echo "::error file=nix/package.nix::version is '$actual' but the release tag is '$TAG' (expected '$expected')"
|
||||
exit 1
|
||||
fi
|
||||
echo "nix/package.nix version '$actual' matches tag '$TAG'"
|
||||
|
||||
build:
|
||||
name: Build binaries
|
||||
needs: [nix, version]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
@@ -35,11 +71,11 @@ jobs:
|
||||
run: |
|
||||
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
|
||||
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||
echo "GO_VERSION=1.26.1" >> $GITHUB_ENV
|
||||
echo "GO_VERSION=1.26.5" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Go
|
||||
if: github.event_name == 'release' || inputs.build_binaries
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
@@ -55,6 +91,8 @@ jobs:
|
||||
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./cmd/cli/main.go
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-freebsd-amd64 ./cmd/cli/main.go
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-freebsd-arm64 ./cmd/cli/main.go
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./cmd/cli/main.go
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./cmd/cli/main.go
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
|
||||
@@ -62,29 +100,29 @@ jobs:
|
||||
|
||||
- name: Upload Release Assets
|
||||
if: github.event_name == 'release' || inputs.build_binaries
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ inputs.tag || github.ref_name }}
|
||||
files: ./sarin-*
|
||||
|
||||
- name: Set up QEMU
|
||||
if: github.event_name == 'release' || inputs.build_docker
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name == 'release' || inputs.build_docker
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name == 'release' || inputs.build_docker
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: github.event_name == 'release' || inputs.build_docker
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
bin/*
|
||||
|
||||
# Nix build artifacts
|
||||
result
|
||||
result-*
|
||||
|
||||
@@ -81,6 +81,11 @@ linters:
|
||||
- staticcheck
|
||||
text: "SA5011"
|
||||
|
||||
- linters:
|
||||
- errcheck
|
||||
- gosec
|
||||
source: "lipgloss\\.Println\\("
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
ARG GO_VERSION=1.26.1
|
||||
ARG GO_VERSION=1.26.5
|
||||
|
||||
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
|
||||
|
||||
[](https://pkg.go.dev/go.aykhans.me/sarin)
|
||||
[](https://goreportcard.com/report/go.aykhans.me/sarin)
|
||||
[](https://hub.docker.com/r/aykhans/sarin)
|
||||
[](https://github.com/aykhans/sarin/releases)
|
||||
[](https://github.com/aykhans/sarin/actions/workflows/lint.yaml)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
</div>
|
||||
@@ -20,23 +22,23 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused.
|
||||
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity and features like templating add zero overhead when unused.
|
||||
|
||||
| ✅ Supported | ❌ Not Supported |
|
||||
| ---------------------------------------------------------- | ------------------------------- |
|
||||
| High-performance with low memory footprint | Detailed response body analysis |
|
||||
| Long-running duration/count based tests | Extensive response statistics |
|
||||
| Dynamic requests via 320+ template functions | Web UI or complex TUI |
|
||||
| High-performance with low memory footprint | Web UI or complex TUI |
|
||||
| Dynamic requests via 340+ template functions | Detailed response body analysis |
|
||||
| Request scripting with Lua and JavaScript | Distributed load testing |
|
||||
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
|
||||
| Flexible config (CLI, ENV, YAML) | Plugins / extensions ecosystem |
|
||||
| Captcha solving<br>(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem |
|
||||
|
||||
## Installation
|
||||
|
||||
### Docker (Recommended)
|
||||
<details open>
|
||||
<summary><b>Docker</b></summary>
|
||||
|
||||
```sh
|
||||
docker pull aykhans/sarin:latest
|
||||
docker run --rm -it aykhans/sarin:latest --version
|
||||
```
|
||||
|
||||
With a local config file:
|
||||
@@ -51,11 +53,46 @@ With a remote config file:
|
||||
docker run --rm -it aykhans/sarin -f https://example.com/config.yaml
|
||||
```
|
||||
|
||||
### Pre-built Binaries
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Nix</b></summary>
|
||||
|
||||
Run directly without installing (requires flakes enabled):
|
||||
|
||||
```sh
|
||||
nix run github:aykhans/sarin -- -U http://example.com -r 100 -c 10
|
||||
```
|
||||
|
||||
Install into your profile:
|
||||
|
||||
```sh
|
||||
nix profile install github:aykhans/sarin
|
||||
```
|
||||
|
||||
Or add it to your own flake via the overlay:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.sarin.url = "github:aykhans/sarin";
|
||||
|
||||
# In your outputs, apply the overlay to nixpkgs:
|
||||
# nixpkgs.overlays = [ inputs.sarin.overlays.default ];
|
||||
# then reference pkgs.sarin
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Pre-built Binaries</b></summary>
|
||||
|
||||
Download the latest binaries from the [releases](https://github.com/aykhans/sarin/releases) page.
|
||||
|
||||
### Building from Source
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Building from Source</b></summary>
|
||||
|
||||
Requires [Go 1.26+](https://golang.org/dl/).
|
||||
|
||||
@@ -71,6 +108,8 @@ CGO_ENABLED=0 go build \
|
||||
-o sarin ./cmd/cli/main.go
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Quick Start
|
||||
|
||||
Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request:
|
||||
@@ -105,7 +144,7 @@ For detailed documentation on all configuration options (URL, method, timeout, c
|
||||
|
||||
## Templating
|
||||
|
||||
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 320+ built-in functions to generate dynamic data for each request.
|
||||
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 340+ built-in functions to generate dynamic data for each request.
|
||||
|
||||
**Example:**
|
||||
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ version: "3"
|
||||
|
||||
vars:
|
||||
BIN_DIR: ./bin
|
||||
GOLANGCI_LINT_VERSION: v2.11.2
|
||||
GOLANGCI_LINT_VERSION: v2.12.2
|
||||
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
||||
|
||||
tasks:
|
||||
@@ -74,7 +74,7 @@ tasks:
|
||||
- test -f {{.GOLANGCI}}
|
||||
cmds:
|
||||
- rm -f {{.GOLANGCI}}
|
||||
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b {{.BIN_DIR}} {{.GOLANGCI_LINT_VERSION}}
|
||||
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/main/install.sh | sh -s -- -b {{.BIN_DIR}} {{.GOLANGCI_LINT_VERSION}}
|
||||
- mv {{.BIN_DIR}}/golangci-lint {{.GOLANGCI}}
|
||||
|
||||
docker-build:
|
||||
|
||||
+19
-16
@@ -7,6 +7,7 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"go.aykhans.me/sarin/internal/config"
|
||||
"go.aykhans.me/sarin/internal/sarin"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
@@ -14,8 +15,9 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel is called in listenForTermination goroutine
|
||||
go listenForTermination(func() { cancel() })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
stopCtrl := sarin.NewStopController(cancel)
|
||||
go listenForTermination(stopCtrl.Stop)
|
||||
|
||||
combinedConfig := config.ReadAllConfigs()
|
||||
|
||||
@@ -31,13 +33,13 @@ func main() {
|
||||
utilsErr.OnType(func(err types.FieldValidationErrors) error {
|
||||
for _, fieldErr := range err.Errors {
|
||||
if fieldErr.Value == "" {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s': ", fieldErr.Field))+fieldErr.Err.Error(),
|
||||
)
|
||||
))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s' (%s): ", fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
os.Exit(1)
|
||||
@@ -49,31 +51,31 @@ func main() {
|
||||
ctx,
|
||||
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
|
||||
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
|
||||
*combinedConfig.Quiet, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers,
|
||||
*combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers,
|
||||
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
|
||||
*combinedConfig.Output != config.ConfigOutputTypeNone,
|
||||
*combinedConfig.DryRun,
|
||||
*combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile,
|
||||
combinedConfig.Lua, combinedConfig.Js,
|
||||
)
|
||||
_ = utilsErr.MustHandle(err,
|
||||
utilsErr.OnType(func(err types.ProxyDialError) error {
|
||||
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[PROXY] ")+err.Error())
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[PROXY] ")+err.Error()))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error {
|
||||
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[SCRIPT] ")+err.Error()))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
utilsErr.OnType(func(err types.ScriptLoadError) error {
|
||||
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[SCRIPT] ")+err.Error()))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
|
||||
srn.Start(ctx)
|
||||
srn.Start(ctx, stopCtrl)
|
||||
|
||||
switch *combinedConfig.Output {
|
||||
case config.ConfigOutputTypeNone:
|
||||
@@ -87,9 +89,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func listenForTermination(do func()) {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
func listenForTermination(stop func()) {
|
||||
sigChan := make(chan os.Signal, 4)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
do()
|
||||
for range sigChan {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
+66
-45
@@ -1,6 +1,6 @@
|
||||
# Configuration
|
||||
|
||||
Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalent—YAML files have the most configuration options, followed by CLI flags, and then environment variables.
|
||||
Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalent: YAML files have the most configuration options, followed by CLI flags, and then environment variables.
|
||||
|
||||
When the same option is specified in multiple sources, the following priority order applies:
|
||||
|
||||
@@ -26,7 +26,9 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
|
||||
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
|
||||
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
|
||||
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
|
||||
| [Quiet](#quiet) | `quiet`<br>(boolean) | `-quiet` / `-q`<br>(boolean) | `SARIN_QUIET`<br>(boolean) | `false` | Hide progress bar and logs |
|
||||
| [Log Level](#log-level) | `logLevel`<br>(string) | `-log-level` / `-l`<br>(string) | `SARIN_LOG_LEVEL`<br>(string) | `error` | Runtime log levels to emit |
|
||||
| [Log File](#log-file) | `logFile`<br>(string) | `-log-file` / `-w`<br>(string) | `SARIN_LOG_FILE`<br>(string) | - | Write runtime logs to a file |
|
||||
| [Progress](#progress) | `progress`<br>(string) | `-progress` / `-p`<br>(string) | `SARIN_PROGRESS`<br>(string) | `bar` | Progress display (bar/none) |
|
||||
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
|
||||
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
|
||||
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
|
||||
@@ -107,9 +109,9 @@ If all four files define `url`, the value from `config3.yaml` wins.
|
||||
|
||||
**Merge behavior by field:**
|
||||
|
||||
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.) — higher priority overrides lower priority
|
||||
- **Method and Body** — higher priority overrides lower priority (no merging)
|
||||
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js** — accumulated across all config files
|
||||
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.): higher priority overrides lower priority
|
||||
- **Method and Body**: higher priority overrides lower priority (no merging)
|
||||
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js**: accumulated across all config files
|
||||
|
||||
## URL
|
||||
|
||||
@@ -131,7 +133,7 @@ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10
|
||||
|
||||
## Method
|
||||
|
||||
HTTP method(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
|
||||
HTTP method(s). Defaults to `GET`. If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Supports [templating](templating.md).
|
||||
|
||||
**YAML example:**
|
||||
|
||||
@@ -141,9 +143,9 @@ method: GET
|
||||
# OR
|
||||
|
||||
method:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -160,7 +162,7 @@ SARIN_METHOD=GET
|
||||
|
||||
## Timeout
|
||||
|
||||
Request timeout. Must be greater than 0.
|
||||
Request timeout. Must be greater than 0. Defaults to `10s`.
|
||||
|
||||
Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
||||
|
||||
@@ -168,7 +170,7 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
||||
|
||||
## Concurrency
|
||||
|
||||
Number of concurrent workers. Must be between 1 and 100,000,000.
|
||||
Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`.
|
||||
|
||||
## Requests
|
||||
|
||||
@@ -182,15 +184,34 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
||||
|
||||
**Examples:** `1m30s`, `25s`, `1h`
|
||||
|
||||
## Quiet
|
||||
## Log Level
|
||||
|
||||
Hide the progress bar and runtime logs.
|
||||
Runtime log levels to emit, comma-separated. Valid levels: `info`, `error`. Defaults to `error`.
|
||||
|
||||
- `error`: errors that occur while generating or sending a request
|
||||
- `info`: every completed response
|
||||
|
||||
Leave empty to disable logging entirely.
|
||||
|
||||
**Examples:** `error` (only errors), `info` (only responses), `info,error` (both)
|
||||
|
||||
## Log File
|
||||
|
||||
Write runtime logs to this file instead of the terminal or stderr. The parent directory must exist.
|
||||
|
||||
```sh
|
||||
sarin -U http://example.com -r 1000 --log-file ./run.log
|
||||
```
|
||||
|
||||
## Progress
|
||||
|
||||
Progress display. Valid values: `bar` (default), `none`. Use `none` to hide the progress bar.
|
||||
|
||||
## Output
|
||||
|
||||
Output format for response statistics.
|
||||
|
||||
Valid formats: `table`, `json`, `yaml`, `none`
|
||||
Valid formats: `table` (default), `json`, `yaml`, `none`
|
||||
|
||||
Using `none` disables output and reduces memory usage since response statistics are not stored.
|
||||
|
||||
@@ -204,7 +225,7 @@ Skip TLS certificate verification.
|
||||
|
||||
## Body
|
||||
|
||||
Request body. If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
|
||||
Request body. If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Supports [templating](templating.md).
|
||||
|
||||
**YAML example:**
|
||||
|
||||
@@ -214,9 +235,9 @@ body: '{"product": "car"}'
|
||||
# OR
|
||||
|
||||
body:
|
||||
- '{"product": "car"}'
|
||||
- '{"product": "phone"}'
|
||||
- '{"product": "watch"}'
|
||||
- '{"product": "car"}'
|
||||
- '{"product": "phone"}'
|
||||
- '{"product": "watch"}'
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -241,19 +262,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
|
||||
|
||||
```yaml
|
||||
params:
|
||||
key1: value1
|
||||
key2: [value2, value3] # cycles between value2 and value3
|
||||
key1: value1
|
||||
key2: [value2, value3] # cycles between value2 and value3
|
||||
|
||||
# OR
|
||||
|
||||
params:
|
||||
- key1: value1
|
||||
- key2: [value2, value3] # cycles between value2 and value3
|
||||
- key1: value1
|
||||
- key2: [value2, value3] # cycles between value2 and value3
|
||||
|
||||
# To send both values in every request, use separate entries:
|
||||
params:
|
||||
- key2: value2
|
||||
- key2: value3 # both sent in every request
|
||||
- key2: value2
|
||||
- key2: value3 # both sent in every request
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -278,19 +299,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
|
||||
|
||||
```yaml
|
||||
headers:
|
||||
key1: value1
|
||||
key2: [value2, value3] # cycles between value2 and value3
|
||||
key1: value1
|
||||
key2: [value2, value3] # cycles between value2 and value3
|
||||
|
||||
# OR
|
||||
|
||||
headers:
|
||||
- key1: value1
|
||||
- key2: [value2, value3] # cycles between value2 and value3
|
||||
- key1: value1
|
||||
- key2: [value2, value3] # cycles between value2 and value3
|
||||
|
||||
# To send both values in every request, use separate entries:
|
||||
headers:
|
||||
- key2: value2
|
||||
- key2: value3 # both sent in every request
|
||||
- key2: value2
|
||||
- key2: value3 # both sent in every request
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -315,19 +336,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
|
||||
|
||||
```yaml
|
||||
cookies:
|
||||
key1: value1
|
||||
key2: [value2, value3] # cycles between value2 and value3
|
||||
key1: value1
|
||||
key2: [value2, value3] # cycles between value2 and value3
|
||||
|
||||
# OR
|
||||
|
||||
cookies:
|
||||
- key1: value1
|
||||
- key2: [value2, value3] # cycles between value2 and value3
|
||||
- key1: value1
|
||||
- key2: [value2, value3] # cycles between value2 and value3
|
||||
|
||||
# To send both values in every request, use separate entries:
|
||||
cookies:
|
||||
- key2: value2
|
||||
- key2: value3 # both sent in every request
|
||||
- key2: value2
|
||||
- key2: value3 # both sent in every request
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -344,7 +365,7 @@ SARIN_COOKIE="key1=value1"
|
||||
|
||||
## Proxy
|
||||
|
||||
Proxy URL(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request.
|
||||
Proxy URL(s). If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point.
|
||||
|
||||
Supported protocols: `http`, `https`, `socks5`, `socks5h`
|
||||
|
||||
@@ -356,9 +377,9 @@ proxy: http://proxy1.com
|
||||
# OR
|
||||
|
||||
proxy:
|
||||
- http://proxy1.com
|
||||
- socks5://proxy2.com
|
||||
- socks5h://proxy3.com
|
||||
- http://proxy1.com
|
||||
- socks5://proxy2.com
|
||||
- socks5h://proxy3.com
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -387,9 +408,9 @@ values: "key=value"
|
||||
# OR
|
||||
|
||||
values: |
|
||||
key1=value1
|
||||
key2=value2
|
||||
key3=value3
|
||||
key1=value1
|
||||
key2=value2
|
||||
key3=value3
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -408,7 +429,7 @@ SARIN_VALUES="key1=value1"
|
||||
|
||||
Lua script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent.
|
||||
|
||||
If multiple Lua scripts are provided, they are chained in order—the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||
If multiple Lua scripts are provided, they are chained in order-the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||
|
||||
**Script sources:**
|
||||
|
||||
@@ -473,7 +494,7 @@ SARIN_LUA='function transform(req) req.headers["X-Custom"] = "my-value" return r
|
||||
|
||||
JavaScript script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent.
|
||||
|
||||
If multiple JavaScript scripts are provided, they are chained in order—the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||
If multiple JavaScript scripts are provided, they are chained in order-the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||
|
||||
**Script sources:**
|
||||
|
||||
|
||||
+64
-5
@@ -8,10 +8,12 @@ This guide provides practical examples for common Sarin use cases.
|
||||
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
|
||||
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
|
||||
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
|
||||
- [Solving Captchas](#solving-captchas)
|
||||
- [Request Bodies](#request-bodies)
|
||||
- [File Uploads](#file-uploads)
|
||||
- [Using Proxies](#using-proxies)
|
||||
- [Output Formats](#output-formats)
|
||||
- [Runtime Logging](#runtime-logging)
|
||||
- [Docker Usage](#docker-usage)
|
||||
- [Dry Run Mode](#dry-run-mode)
|
||||
- [Show Configuration](#show-configuration)
|
||||
@@ -371,7 +373,29 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act
|
||||
|
||||
</details>
|
||||
|
||||
> For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**.
|
||||
> For the complete list of 340+ template functions, see the **[Templating Guide](templating.md)**.
|
||||
|
||||
## Solving Captchas
|
||||
|
||||
Sarin can solve captchas through third-party services and embed the resulting token into the request. Three services are supported via dedicated template functions: **2Captcha**, **Anti-Captcha**, and **CapSolver**.
|
||||
|
||||
**Solve a reCAPTCHA v2 and submit the token in the request body:**
|
||||
|
||||
```sh
|
||||
sarin -U https://example.com/login -M POST -r 1 \
|
||||
-B '{"g-recaptcha-response": "{{ twocaptcha_RecaptchaV2 "YOUR_API_KEY" "SITE_KEY" "https://example.com/login" }}"}'
|
||||
```
|
||||
|
||||
**Reuse a single solved token across multiple requests via `values`:**
|
||||
|
||||
```sh
|
||||
sarin -U https://example.com/api -M POST -r 5 \
|
||||
-V 'TOKEN={{ anticaptcha_Turnstile "YOUR_API_KEY" "SITE_KEY" "https://example.com/api" }}' \
|
||||
-H "X-Turnstile-Token: {{ .Values.TOKEN }}" \
|
||||
-B '{"token": "{{ .Values.TOKEN }}"}'
|
||||
```
|
||||
|
||||
> See the **[Templating Guide](templating.md#captcha-functions)** for the full list of captcha functions and per-service support.
|
||||
|
||||
## Request Bodies
|
||||
|
||||
@@ -696,7 +720,7 @@ proxy: socks5://proxy.example.com:1080
|
||||
|
||||
</details>
|
||||
|
||||
**Multiple proxies (load balanced):**
|
||||
**Multiple proxies (randomly cycled):**
|
||||
|
||||
```sh
|
||||
sarin -U http://example.com -r 1000 -c 10 \
|
||||
@@ -813,10 +837,10 @@ output: none
|
||||
|
||||
</details>
|
||||
|
||||
**Quiet mode (hide progress bar):**
|
||||
**Hide the progress bar:**
|
||||
|
||||
```sh
|
||||
sarin -U http://example.com -r 1000 -c 10 -q
|
||||
sarin -U http://example.com -r 1000 -c 10 -p none
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -826,7 +850,42 @@ sarin -U http://example.com -r 1000 -c 10 -q
|
||||
url: http://example.com
|
||||
requests: 1000
|
||||
concurrency: 10
|
||||
quiet: true
|
||||
progress: none
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Runtime Logging
|
||||
|
||||
`--log-level` selects which runtime logs Sarin emits (comma-separated `info` and `error`, default `error`). `error` covers request and generation errors, `info` covers every completed response (status, duration, headers, body). Logs appear in the progress log box on an interactive terminal, go to stderr when piped, or go to a file with `--log-file`.
|
||||
|
||||
**Log responses and errors:**
|
||||
|
||||
```sh
|
||||
sarin -U http://example.com -r 1000 -c 10 -l info,error
|
||||
```
|
||||
|
||||
**Write logs to a file (the progress bar stays on screen):**
|
||||
|
||||
```sh
|
||||
sarin -U http://example.com -r 1000 -c 10 -l info --log-file ./run.log
|
||||
```
|
||||
|
||||
**Capture logs while keeping results on stdout:**
|
||||
|
||||
```sh
|
||||
sarin -U http://example.com -r 1000 -l info -o json > stats.json 2> run.log
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>YAML equivalent</summary>
|
||||
|
||||
```yaml
|
||||
url: http://example.com
|
||||
requests: 1000
|
||||
concurrency: 10
|
||||
logLevel: info,error
|
||||
logFile: ./run.log
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
+134
-11
@@ -4,16 +4,23 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
|
||||
|
||||
> **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates.
|
||||
|
||||
> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Using Values](#using-values)
|
||||
- [General Functions](#general-functions)
|
||||
- [String Functions](#string-functions)
|
||||
- [Collection Functions](#collection-functions)
|
||||
- [JSON Functions](#json-functions)
|
||||
- [Time Functions](#time-functions)
|
||||
- [Crypto Functions](#crypto-functions)
|
||||
- [Body Functions](#body-functions)
|
||||
- [File Functions](#file-functions)
|
||||
- [Captcha Functions](#captcha-functions)
|
||||
- [2Captcha](#2captcha)
|
||||
- [Anti-Captcha](#anti-captcha)
|
||||
- [CapSolver](#capsolver)
|
||||
- [Fake Data Functions](#fake-data-functions)
|
||||
- [File](#file)
|
||||
- [ID](#id)
|
||||
@@ -111,6 +118,33 @@ sarin -U http://example.com/users \
|
||||
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` |
|
||||
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` |
|
||||
|
||||
### JSON Functions
|
||||
|
||||
Build JSON payloads programmatically without manual quoting or escaping. `json_Object` is the ergonomic shortcut for flat objects; `json_Encode` marshals any value (slice, map, etc.) to a JSON string.
|
||||
|
||||
| Function | Description | Example |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
|
||||
| `json_Object(pairs ...any)` | Build an object from interleaved key-value pairs and return it as a JSON string. Keys must be strings. | `{{ json_Object "name" "Alice" "age" 30 }}` |
|
||||
| `json_Encode(v any)` | Marshal any value (slice, map, etc.) to a JSON string. | `{{ json_Encode (slice_Str "a" "b") }}` → `["a","b"]` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```yaml
|
||||
# Flat object with fake data
|
||||
body: '{{ json_Object "name" (fakeit_FirstName) "email" (fakeit_Email) }}'
|
||||
|
||||
# Embed a solved captcha token
|
||||
body: '{{ json_Object "g-recaptcha-response" (twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com") }}'
|
||||
|
||||
# Encode a slice as a JSON array
|
||||
body: '{{ json_Encode (slice_Str "a" "b" "c") }}'
|
||||
|
||||
# Encode a string dictionary (map[string]string)
|
||||
body: '{{ json_Encode (dict_Str "key1" "value1" "key2" "value2") }}'
|
||||
```
|
||||
|
||||
> **Note:** Object keys are serialized in alphabetical order (Go's `encoding/json` default), not insertion order. For API payloads this is almost always fine because JSON key order is semantically irrelevant.
|
||||
|
||||
### Time Functions
|
||||
|
||||
| Function | Description | Example |
|
||||
@@ -149,19 +183,19 @@ body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
|
||||
|
||||
# Mixed text fields and files
|
||||
body: |
|
||||
{{ body_FormData
|
||||
"title" "My Report"
|
||||
"author" "John Doe"
|
||||
"cover" "@/path/to/cover.jpg"
|
||||
"document" "@/path/to/report.pdf"
|
||||
}}
|
||||
{{ body_FormData
|
||||
"title" "My Report"
|
||||
"author" "John Doe"
|
||||
"cover" "@/path/to/cover.jpg"
|
||||
"document" "@/path/to/report.pdf"
|
||||
}}
|
||||
|
||||
# Multiple files with same field name
|
||||
body: |
|
||||
{{ body_FormData
|
||||
"files" "@/path/to/file1.pdf"
|
||||
"files" "@/path/to/file2.pdf"
|
||||
}}
|
||||
{{ body_FormData
|
||||
"files" "@/path/to/file1.pdf"
|
||||
"files" "@/path/to/file2.pdf"
|
||||
}}
|
||||
|
||||
# Escape @ for literal value (sends "@username")
|
||||
body: '{{ body_FormData "twitter" "@@username" }}'
|
||||
@@ -192,10 +226,99 @@ body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "docume
|
||||
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}'
|
||||
|
||||
# Combined with values for reuse
|
||||
values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}"
|
||||
values: 'FILE_DATA={{ file_Base64 "/path/to/file.bin" }}'
|
||||
body: '{"data": "{{ .Values.FILE_DATA }}"}'
|
||||
```
|
||||
|
||||
## Captcha Functions
|
||||
|
||||
Captcha functions solve a captcha challenge through a third-party solving service and return the resulting token, which can then be embedded directly into a request. They are intended for load testing endpoints protected by reCAPTCHA, hCaptcha, or Cloudflare Turnstile.
|
||||
|
||||
The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `capsolver_*`. Each accepts the API key as the first argument so no global configuration is required. Bring your own key and use any of the supported services per template.
|
||||
|
||||
> **Important: performance and cost:**
|
||||
>
|
||||
> - **Each call is slow.** Solving typically takes ~5–60 seconds because the function blocks the template render until the third-party service returns a token. Internally the solver polls every 1s and gives up after 120s.
|
||||
> - **Each call costs money.** Every successful solve is billed by the captcha service (typically $0.001–$0.003 per solve). For high-volume tests, your captcha bill grows linearly with request count.
|
||||
|
||||
**Common parameters across all captcha functions:**
|
||||
|
||||
- `apiKey` - Your API key for the chosen captcha solving service
|
||||
- `siteKey` - The captcha sitekey extracted from the target page (e.g. the `data-sitekey` attribute on a reCAPTCHA, hCaptcha, or Turnstile element)
|
||||
- `pageURL` - The URL of the page where the captcha is hosted
|
||||
|
||||
### 2Captcha
|
||||
|
||||
Functions for the [2Captcha](https://2captcha.com) service. Note: 2Captcha **does not currently support hCaptcha** through their API.
|
||||
|
||||
| Function | Description |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| `twocaptcha_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge |
|
||||
| `twocaptcha_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. Pass `""` for `pageAction` to omit |
|
||||
| `twocaptcha_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument |
|
||||
|
||||
### Anti-Captcha
|
||||
|
||||
Functions for the [Anti-Captcha](https://anti-captcha.com) service. This is currently the only service that supports all four captcha types end-to-end.
|
||||
|
||||
| Function | Description |
|
||||
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `anticaptcha_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge |
|
||||
| `anticaptcha_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. `minScore` is hardcoded to `0.3` (Anti-Captcha rejects the request without it) |
|
||||
| `anticaptcha_HCaptcha(apiKey, siteKey, pageURL string)` | Solve an hCaptcha challenge |
|
||||
| `anticaptcha_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument |
|
||||
|
||||
### CapSolver
|
||||
|
||||
Functions for the [CapSolver](https://capsolver.com) service. Note: CapSolver no longer supports hCaptcha.
|
||||
|
||||
| Function | Description |
|
||||
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `capsolver_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge |
|
||||
| `capsolver_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. Pass `""` for `pageAction` to omit |
|
||||
| `capsolver_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```yaml
|
||||
# reCAPTCHA v2 in a JSON body via 2Captcha
|
||||
method: POST
|
||||
url: https://example.com/login
|
||||
body: |
|
||||
{
|
||||
"username": "test",
|
||||
"g-recaptcha-response": "{{ twocaptcha_RecaptchaV2 "YOUR_API_KEY" "6LfD3PIb..." "https://example.com/login" }}"
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Turnstile via Anti-Captcha with cData
|
||||
method: POST
|
||||
url: https://example.com/submit
|
||||
body: |
|
||||
{
|
||||
"cf-turnstile-response": "{{ anticaptcha_Turnstile "YOUR_API_KEY" "0x4AAAAAAA..." "https://example.com/submit" "session-cdata" }}"
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# hCaptcha via Anti-Captcha (the only service that still supports it)
|
||||
method: POST
|
||||
url: https://example.com/protected
|
||||
body: |
|
||||
{
|
||||
"h-captcha-response": "{{ anticaptcha_HCaptcha "YOUR_API_KEY" "338af34c-..." "https://example.com/protected" }}"
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Share a single solved token across body and headers via values
|
||||
values: 'TOKEN={{ capsolver_Turnstile "YOUR_API_KEY" "0x4AAAAAAA..." "https://example.com" }}'
|
||||
headers:
|
||||
X-Turnstile-Token: "{{ .Values.TOKEN }}"
|
||||
body: '{"token": "{{ .Values.TOKEN }}"}'
|
||||
```
|
||||
|
||||
## Fake Data Functions
|
||||
|
||||
These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library.
|
||||
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1784796856,
|
||||
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
description = "Sarin - high-performance HTTP load testing tool built with Go and fasthttp";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
|
||||
forAllSystems = f: lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
|
||||
rev = self.rev or self.dirtyRev or "unknown";
|
||||
|
||||
# self.lastModifiedDate is "YYYYMMDDHHMMSS"; reshape to RFC 3339 (UTC).
|
||||
raw = self.lastModifiedDate or "19700101000000";
|
||||
s = i: n: lib.substring i n raw;
|
||||
buildDate = "${s 0 4}-${s 4 2}-${s 6 2}T${s 8 2}:${s 10 2}:${s 12 2}Z";
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (pkgs: {
|
||||
default = pkgs.callPackage ./nix/package.nix {
|
||||
inherit rev buildDate;
|
||||
};
|
||||
});
|
||||
|
||||
# nix run github:aykhans/sarin -- <args>
|
||||
apps = forAllSystems (pkgs: {
|
||||
default = {
|
||||
type = "app";
|
||||
program = "${self.packages.${pkgs.system}.default}/bin/sarin";
|
||||
meta.description = "High-performance HTTP load testing tool";
|
||||
};
|
||||
});
|
||||
|
||||
devShells = forAllSystems (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
packages = [ pkgs.go_1_26 pkgs.golangci-lint pkgs.go-task ];
|
||||
};
|
||||
});
|
||||
|
||||
# For downstream flakes: inputs.sarin.overlays.default
|
||||
overlays.default = final: _prev: {
|
||||
sarin = final.callPackage ./nix/package.nix { };
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,59 +1,53 @@
|
||||
module go.aykhans.me/sarin
|
||||
|
||||
go 1.26.1
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.1
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
charm.land/bubbles/v2 v2.2.0
|
||||
charm.land/bubbletea/v2 v2.0.9
|
||||
charm.land/glamour/v2 v2.0.1
|
||||
charm.land/lipgloss/v2 v2.0.6
|
||||
github.com/brianvoe/gofakeit/v7 v7.16.0
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/valyala/fasthttp v1.69.0
|
||||
github.com/yuin/gopher-lua v1.1.1
|
||||
github.com/valyala/fasthttp v1.73.0
|
||||
github.com/yuin/gopher-lua v1.1.2
|
||||
go.aykhans.me/utils v1.0.7
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||
golang.org/x/net v0.52.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
golang.org/x/net v0.58.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.27 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
github.com/xo/terminfo v1.0.0 // indirect
|
||||
github.com/yuin/goldmark v1.8.5 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,127 +1,110 @@
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
charm.land/bubbles/v2 v2.2.0 h1:9GEMewcejrNtVIxZ9Y2wSsWJamsWNsXa8MpMLnH4yI4=
|
||||
charm.land/bubbles/v2 v2.2.0/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
||||
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
|
||||
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
|
||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
|
||||
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.1 h1:a7fe3fonbj0cW3wgl5VwIKfZtiH9C3cLnwcIXWT7sow=
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/brianvoe/gofakeit/v7 v7.16.0 h1:LXNcvT4Klw72/hqpLNNdEWFIcP7G0VFPNsqvEIGONBE=
|
||||
github.com/brianvoe/gofakeit/v7 v7.16.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f h1:kvAY8ffwhFuxWqtVI6+9E5vmgTApG96hswFLXJfsxHI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 h1:Z0hBPQ9hslsfpFRRdMn+4cjnb3LK6FQH58hQkXrXv0A=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4=
|
||||
github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6 h1:Oh2rRG1un7tLlC3/NJDzKppZ4CeZGkVFJCUOTRwLpfw=
|
||||
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
|
||||
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
|
||||
github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||
go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
|
||||
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+22
-7
@@ -27,7 +27,9 @@ Flags:
|
||||
-c, -concurrency uint Number of concurrent requests (default %d)
|
||||
-r, -requests uint Number of total requests
|
||||
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
||||
-q, -quiet bool Hide the progress bar and runtime logs (default %v)
|
||||
-l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s)
|
||||
-w, -log-file string Write runtime logs to this file instead of the terminal/stderr
|
||||
-p, -progress string Progress display (possible values: bar, none) (default '%v')
|
||||
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
|
||||
-z, -dry-run bool Run without sending requests (default %v)
|
||||
|
||||
@@ -88,7 +90,9 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
||||
concurrency uint
|
||||
requestCount uint64
|
||||
duration time.Duration
|
||||
quiet bool
|
||||
logLevel string
|
||||
logFile string
|
||||
progress string
|
||||
output string
|
||||
dryRun bool
|
||||
|
||||
@@ -127,8 +131,14 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
||||
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test")
|
||||
flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test")
|
||||
|
||||
flagSet.BoolVar(&quiet, "quiet", false, "Hide the progress bar and runtime logs")
|
||||
flagSet.BoolVar(&quiet, "q", false, "Hide the progress bar and runtime logs")
|
||||
flagSet.StringVar(&logLevel, "log-level", "", "Runtime log levels to emit, comma-separated (possible values: info, error)")
|
||||
flagSet.StringVar(&logLevel, "l", "", "Runtime log levels to emit, comma-separated (possible values: info, error)")
|
||||
|
||||
flagSet.StringVar(&logFile, "log-file", "", "Write runtime logs to this file instead of the terminal/stderr")
|
||||
flagSet.StringVar(&logFile, "w", "", "Write runtime logs to this file instead of the terminal/stderr")
|
||||
|
||||
flagSet.StringVar(&progress, "progress", "", "Progress display (possible values: bar, none)")
|
||||
flagSet.StringVar(&progress, "p", "", "Progress display (possible values: bar, none)")
|
||||
|
||||
flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)")
|
||||
flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)")
|
||||
@@ -205,8 +215,12 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
||||
config.Requests = new(requestCount)
|
||||
case "duration", "d":
|
||||
config.Duration = new(duration)
|
||||
case "quiet", "q":
|
||||
config.Quiet = new(quiet)
|
||||
case "log-level", "l":
|
||||
config.LogLevel = new(logLevel)
|
||||
case "log-file", "w":
|
||||
config.LogFile = new(logFile)
|
||||
case "progress", "p":
|
||||
config.Progress = new(ConfigProgressType(progress))
|
||||
case "output", "o":
|
||||
config.Output = new(ConfigOutputType(output))
|
||||
case "dry-run", "z":
|
||||
@@ -265,7 +279,8 @@ func (parser ConfigCLIParser) PrintHelp() {
|
||||
cliUsageText+"\n",
|
||||
Defaults.ShowConfig,
|
||||
Defaults.Concurrency,
|
||||
Defaults.Quiet,
|
||||
Defaults.LogLevel,
|
||||
Defaults.Progress,
|
||||
Defaults.Output,
|
||||
Defaults.DryRun,
|
||||
|
||||
|
||||
+148
-63
@@ -6,17 +6,18 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glamour/styles"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/term"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/glamour/v2"
|
||||
"charm.land/glamour/v2/styles"
|
||||
"charm.land/lipgloss/v2"
|
||||
"go.aykhans.me/sarin/internal/sarin"
|
||||
"go.aykhans.me/sarin/internal/script"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
"go.aykhans.me/sarin/internal/version"
|
||||
@@ -31,25 +32,28 @@ var Defaults = struct {
|
||||
RequestTimeout time.Duration
|
||||
Concurrency uint
|
||||
ShowConfig bool
|
||||
Quiet bool
|
||||
Progress ConfigProgressType
|
||||
Insecure bool
|
||||
Output ConfigOutputType
|
||||
DryRun bool
|
||||
LogLevel string
|
||||
}{
|
||||
UserAgent: "Sarin/" + version.Version,
|
||||
Method: "GET",
|
||||
RequestTimeout: time.Second * 10,
|
||||
Concurrency: 1,
|
||||
ShowConfig: false,
|
||||
Quiet: false,
|
||||
Progress: ConfigProgressTypeBar,
|
||||
Insecure: false,
|
||||
Output: ConfigOutputTypeTable,
|
||||
DryRun: false,
|
||||
LogLevel: "error",
|
||||
}
|
||||
|
||||
var (
|
||||
ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"}
|
||||
ValidRequestURLSchemes = []string{"http", "https"}
|
||||
ValidLogLevels = []string{"info", "error"}
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -70,27 +74,36 @@ var (
|
||||
ConfigOutputTypeNone ConfigOutputType = "none"
|
||||
)
|
||||
|
||||
type ConfigProgressType string
|
||||
|
||||
var (
|
||||
ConfigProgressTypeBar ConfigProgressType = "bar"
|
||||
ConfigProgressTypeNone ConfigProgressType = "none"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
||||
Files []types.ConfigFile `yaml:"files,omitempty"`
|
||||
Methods []string `yaml:"methods,omitempty"`
|
||||
URL *url.URL `yaml:"url,omitempty"`
|
||||
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
||||
Concurrency *uint `yaml:"concurrency,omitempty"`
|
||||
Requests *uint64 `yaml:"requests,omitempty"`
|
||||
Duration *time.Duration `yaml:"duration,omitempty"`
|
||||
Quiet *bool `yaml:"quiet,omitempty"`
|
||||
Output *ConfigOutputType `yaml:"output,omitempty"`
|
||||
Insecure *bool `yaml:"insecure,omitempty"`
|
||||
DryRun *bool `yaml:"dryRun,omitempty"`
|
||||
Params types.Params `yaml:"params,omitempty"`
|
||||
Headers types.Headers `yaml:"headers,omitempty"`
|
||||
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
||||
Bodies []string `yaml:"bodies,omitempty"`
|
||||
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
||||
Values []string `yaml:"values,omitempty"`
|
||||
Lua []string `yaml:"lua,omitempty"`
|
||||
Js []string `yaml:"js,omitempty"`
|
||||
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
||||
Files []types.ConfigFile `yaml:"files,omitempty"`
|
||||
Methods []string `yaml:"methods,omitempty"`
|
||||
URL *url.URL `yaml:"url,omitempty"`
|
||||
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
||||
Concurrency *uint `yaml:"concurrency,omitempty"`
|
||||
Requests *uint64 `yaml:"requests,omitempty"`
|
||||
Duration *time.Duration `yaml:"duration,omitempty"`
|
||||
Progress *ConfigProgressType `yaml:"progress,omitempty"`
|
||||
Output *ConfigOutputType `yaml:"output,omitempty"`
|
||||
Insecure *bool `yaml:"insecure,omitempty"`
|
||||
DryRun *bool `yaml:"dryRun,omitempty"`
|
||||
Params types.Params `yaml:"params,omitempty"`
|
||||
Headers types.Headers `yaml:"headers,omitempty"`
|
||||
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
||||
Bodies []string `yaml:"bodies,omitempty"`
|
||||
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
||||
Values []string `yaml:"values,omitempty"`
|
||||
Lua []string `yaml:"lua,omitempty"`
|
||||
Js []string `yaml:"js,omitempty"`
|
||||
LogLevel *string `yaml:"logLevel,omitempty"`
|
||||
LogFile *string `yaml:"logFile,omitempty"`
|
||||
}
|
||||
|
||||
func (config Config) MarshalYAML() (any, error) {
|
||||
@@ -173,8 +186,8 @@ func (config Config) MarshalYAML() (any, error) {
|
||||
if config.Duration != nil {
|
||||
addField(content, "duration", toNode(*config.Duration), "")
|
||||
}
|
||||
if config.Quiet != nil {
|
||||
addField(content, "quiet", toNode(*config.Quiet), "")
|
||||
if config.Progress != nil {
|
||||
addField(content, "progress", toNode(string(*config.Progress)), "")
|
||||
}
|
||||
if config.Output != nil {
|
||||
addField(content, "output", toNode(string(*config.Output)), "")
|
||||
@@ -221,6 +234,13 @@ func (config Config) MarshalYAML() (any, error) {
|
||||
addStringSlice(content, "values", config.Values, false)
|
||||
addStringSlice(content, "lua", config.Lua, false)
|
||||
addStringSlice(content, "js", config.Js, false)
|
||||
if config.LogLevel != nil {
|
||||
addField(content, "logLevel", toNode(*config.LogLevel), "")
|
||||
}
|
||||
|
||||
if config.LogFile != nil {
|
||||
addField(content, "logFile", toNode(*config.LogFile), "")
|
||||
}
|
||||
|
||||
return root, nil
|
||||
}
|
||||
@@ -228,12 +248,12 @@ func (config Config) MarshalYAML() (any, error) {
|
||||
func (config Config) Print() bool {
|
||||
configYAML, err := yaml.Marshal(config)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, StyleRed.Render("Error marshaling config to yaml: "+err.Error()))
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render("Error marshaling config to yaml: "+err.Error())))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Pipe mode: output raw content directly
|
||||
if !term.IsTerminal(os.Stdout.Fd()) {
|
||||
if !sarin.IsInteractiveTerminal(os.Stdout.Fd()) {
|
||||
fmt.Println(string(configYAML))
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -247,25 +267,23 @@ func (config Config) Print() bool {
|
||||
glamour.WithWordWrap(0),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
content, err := renderer.Render("```yaml\n" + string(configYAML) + "```")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(
|
||||
printConfigModel{content: strings.Trim(content, "\n"), rawContent: configYAML},
|
||||
tea.WithAltScreen(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
m, err := p.Run()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -295,8 +313,8 @@ func (config *Config) Merge(newConfig *Config) {
|
||||
if newConfig.ShowConfig != nil {
|
||||
config.ShowConfig = newConfig.ShowConfig
|
||||
}
|
||||
if newConfig.Quiet != nil {
|
||||
config.Quiet = newConfig.Quiet
|
||||
if newConfig.Progress != nil {
|
||||
config.Progress = newConfig.Progress
|
||||
}
|
||||
if newConfig.Output != nil {
|
||||
config.Output = newConfig.Output
|
||||
@@ -331,6 +349,12 @@ func (config *Config) Merge(newConfig *Config) {
|
||||
if len(newConfig.Js) != 0 {
|
||||
config.Js = append(config.Js, newConfig.Js...)
|
||||
}
|
||||
if newConfig.LogLevel != nil {
|
||||
config.LogLevel = newConfig.LogLevel
|
||||
}
|
||||
if newConfig.LogFile != nil {
|
||||
config.LogFile = newConfig.LogFile
|
||||
}
|
||||
}
|
||||
|
||||
func (config *Config) SetDefaults() {
|
||||
@@ -361,8 +385,8 @@ func (config *Config) SetDefaults() {
|
||||
if config.ShowConfig == nil {
|
||||
config.ShowConfig = new(Defaults.ShowConfig)
|
||||
}
|
||||
if config.Quiet == nil {
|
||||
config.Quiet = new(Defaults.Quiet)
|
||||
if config.Progress == nil {
|
||||
config.Progress = new(Defaults.Progress)
|
||||
}
|
||||
if config.Insecure == nil {
|
||||
config.Insecure = new(Defaults.Insecure)
|
||||
@@ -377,6 +401,14 @@ func (config *Config) SetDefaults() {
|
||||
if config.Output == nil {
|
||||
config.Output = new(Defaults.Output)
|
||||
}
|
||||
|
||||
if config.LogLevel == nil {
|
||||
config.LogLevel = new(Defaults.LogLevel)
|
||||
}
|
||||
|
||||
if config.LogFile == nil {
|
||||
config.LogFile = new("")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validates the config fields.
|
||||
@@ -426,8 +458,21 @@ func (config Config) Validate() error {
|
||||
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
|
||||
}
|
||||
|
||||
if config.Quiet == nil {
|
||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Quiet", "", errors.New("quiet field is required")))
|
||||
if config.Progress == nil {
|
||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Progress", "", errors.New("progress field is required")))
|
||||
} else {
|
||||
switch *config.Progress {
|
||||
case ConfigProgressTypeBar, ConfigProgressTypeNone:
|
||||
default:
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
types.NewFieldValidationError(
|
||||
"Progress",
|
||||
string(*config.Progress),
|
||||
fmt.Errorf("progress must be one of: %s, %s", ConfigProgressTypeBar, ConfigProgressTypeNone),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if config.Output == nil {
|
||||
@@ -460,6 +505,36 @@ func (config Config) Validate() error {
|
||||
validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required")))
|
||||
}
|
||||
|
||||
if config.LogLevel != nil {
|
||||
for i, level := range sarin.SplitLogLevels(*config.LogLevel) {
|
||||
if !slices.Contains(ValidLogLevels, level) {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
types.NewFieldValidationError(
|
||||
fmt.Sprintf("LogLevel[%d]", i),
|
||||
level,
|
||||
fmt.Errorf("log level must be one of: %s", strings.Join(ValidLogLevels, ", ")),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.LogFile != nil && *config.LogFile != "" {
|
||||
dir := filepath.Dir(*config.LogFile)
|
||||
if info, err := os.Stat(dir); err != nil {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent directory %q is not accessible", dir)),
|
||||
)
|
||||
} else if !info.IsDir() {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent path %q is not a directory", dir)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for i, proxy := range config.Proxies {
|
||||
if !slices.Contains(ValidProxySchemes, proxy.Scheme) {
|
||||
validationErrors = append(
|
||||
@@ -538,11 +613,11 @@ func ReadAllConfigs() *Config {
|
||||
_ = utilsErr.MustHandle(err,
|
||||
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
|
||||
cliParser.PrintHelp()
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(
|
||||
"\nUnexpected CLI arguments provided: ",
|
||||
)+strings.Join(err.Args, ", "),
|
||||
)
|
||||
))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
@@ -560,20 +635,20 @@ func ReadAllConfigs() *Config {
|
||||
_ = utilsErr.MustHandle(err,
|
||||
utilsErr.OnType(func(err types.ConfigFileReadError) error {
|
||||
cliParser.PrintHelp()
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(
|
||||
fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(),
|
||||
),
|
||||
)
|
||||
))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
utilsErr.OnType(func(err types.UnmarshalError) error {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(
|
||||
fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(),
|
||||
),
|
||||
)
|
||||
))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
@@ -676,13 +751,13 @@ func validateScriptSource(script string) error {
|
||||
func printParseErrors(parserName string, errors ...types.FieldParseError) {
|
||||
for _, fieldErr := range errors {
|
||||
if fieldErr.Value == "" {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s': ", parserName, fieldErr.Field))+fieldErr.Err.Error(),
|
||||
)
|
||||
))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s' (%s): ", parserName, fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -723,7 +798,7 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
@@ -746,13 +821,22 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m printConfigModel) View() string {
|
||||
func (m printConfigModel) View() tea.View {
|
||||
// AltScreen and MouseMode were program options in bubbletea v1; in v2 they
|
||||
// are view properties, so every return path has to declare them.
|
||||
newView := func(s string) tea.View {
|
||||
v := tea.NewView(s)
|
||||
v.AltScreen = true
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
return v
|
||||
}
|
||||
|
||||
if !m.ready {
|
||||
return "\n Initializing..."
|
||||
return newView("\n Initializing...")
|
||||
}
|
||||
|
||||
content := lipgloss.JoinHorizontal(lipgloss.Top, m.viewport.View(), m.scrollbar())
|
||||
return fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView())
|
||||
return newView(fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView()))
|
||||
}
|
||||
|
||||
func (m *printConfigModel) saveContent() (printConfigModel, tea.Cmd) {
|
||||
@@ -772,12 +856,13 @@ func (m *printConfigModel) handleResize(msg tea.WindowSizeMsg) {
|
||||
width := msg.Width - scrollbarWidth
|
||||
|
||||
if !m.ready {
|
||||
m.viewport = viewport.New(width, height)
|
||||
m.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height))
|
||||
m.viewport.SetHorizontalStep(0)
|
||||
m.viewport.SetContent(m.contentWithLineNumbers())
|
||||
m.ready = true
|
||||
} else {
|
||||
m.viewport.Width = width
|
||||
m.viewport.Height = height
|
||||
m.viewport.SetWidth(width)
|
||||
m.viewport.SetHeight(height)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,12 +877,12 @@ func (m printConfigModel) headerView() string {
|
||||
printConfigKeyStyle.Render("ESC") + printConfigDescStyle.Render(" exit")
|
||||
title = printConfigHelpStyle.Render(help)
|
||||
}
|
||||
line := strings.Repeat("─", max(0, m.viewport.Width+scrollbarWidth-lipgloss.Width(title)))
|
||||
line := strings.Repeat("─", max(0, m.viewport.Width()+scrollbarWidth-lipgloss.Width(title)))
|
||||
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
|
||||
}
|
||||
|
||||
func (m printConfigModel) footerView() string {
|
||||
return strings.Repeat("─", m.viewport.Width+scrollbarWidth)
|
||||
return strings.Repeat("─", m.viewport.Width()+scrollbarWidth)
|
||||
}
|
||||
|
||||
func (m printConfigModel) contentWithLineNumbers() string {
|
||||
@@ -819,7 +904,7 @@ func (m printConfigModel) contentWithLineNumbers() string {
|
||||
}
|
||||
|
||||
func (m printConfigModel) scrollbar() string {
|
||||
height := m.viewport.Height
|
||||
height := m.viewport.Height()
|
||||
trackHeight := height - scrollbarBottomSpace
|
||||
totalLines := m.viewport.TotalLineCount()
|
||||
|
||||
|
||||
+74
-78
@@ -49,74 +49,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
||||
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
|
||||
}
|
||||
|
||||
if quiet := parser.getEnv("QUIET"); quiet != "" {
|
||||
quietParsed, err := utilsParse.ParseString[bool](quiet)
|
||||
if err != nil {
|
||||
fieldParseErrors = append(
|
||||
fieldParseErrors,
|
||||
types.NewFieldParseError(
|
||||
parser.getFullEnvName("QUIET"),
|
||||
quiet,
|
||||
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
config.Quiet = &quietParsed
|
||||
}
|
||||
}
|
||||
|
||||
if output := parser.getEnv("OUTPUT"); output != "" {
|
||||
config.Output = new(ConfigOutputType(output))
|
||||
}
|
||||
|
||||
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
||||
insecureParsed, err := utilsParse.ParseString[bool](insecure)
|
||||
if err != nil {
|
||||
fieldParseErrors = append(
|
||||
fieldParseErrors,
|
||||
types.NewFieldParseError(
|
||||
parser.getFullEnvName("INSECURE"),
|
||||
insecure,
|
||||
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
config.Insecure = &insecureParsed
|
||||
}
|
||||
}
|
||||
|
||||
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
|
||||
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
|
||||
if err != nil {
|
||||
fieldParseErrors = append(
|
||||
fieldParseErrors,
|
||||
types.NewFieldParseError(
|
||||
parser.getFullEnvName("DRY_RUN"),
|
||||
dryRun,
|
||||
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
config.DryRun = &dryRunParsed
|
||||
}
|
||||
}
|
||||
|
||||
if method := parser.getEnv("METHOD"); method != "" {
|
||||
config.Methods = []string{method}
|
||||
}
|
||||
|
||||
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
|
||||
urlEnvParsed, err := url.Parse(urlEnv)
|
||||
if err != nil {
|
||||
fieldParseErrors = append(
|
||||
fieldParseErrors,
|
||||
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
|
||||
)
|
||||
} else {
|
||||
config.URL = urlEnvParsed
|
||||
}
|
||||
}
|
||||
|
||||
if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" {
|
||||
concurrencyParsed, err := utilsParse.ParseString[uint](concurrency)
|
||||
if err != nil {
|
||||
@@ -165,22 +97,58 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
|
||||
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
|
||||
if logLevel := parser.getEnv("LOG_LEVEL"); logLevel != "" {
|
||||
config.LogLevel = new(logLevel)
|
||||
}
|
||||
|
||||
if logFile := parser.getEnv("LOG_FILE"); logFile != "" {
|
||||
config.LogFile = new(logFile)
|
||||
}
|
||||
|
||||
if progress := parser.getEnv("PROGRESS"); progress != "" {
|
||||
config.Progress = new(ConfigProgressType(progress))
|
||||
}
|
||||
|
||||
if output := parser.getEnv("OUTPUT"); output != "" {
|
||||
config.Output = new(ConfigOutputType(output))
|
||||
}
|
||||
|
||||
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
|
||||
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
|
||||
if err != nil {
|
||||
fieldParseErrors = append(
|
||||
fieldParseErrors,
|
||||
types.NewFieldParseError(
|
||||
parser.getFullEnvName("TIMEOUT"),
|
||||
timeout,
|
||||
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
|
||||
parser.getFullEnvName("DRY_RUN"),
|
||||
dryRun,
|
||||
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
config.Timeout = &timeoutParsed
|
||||
config.DryRun = &dryRunParsed
|
||||
}
|
||||
}
|
||||
|
||||
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
|
||||
urlEnvParsed, err := url.Parse(urlEnv)
|
||||
if err != nil {
|
||||
fieldParseErrors = append(
|
||||
fieldParseErrors,
|
||||
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
|
||||
)
|
||||
} else {
|
||||
config.URL = urlEnvParsed
|
||||
}
|
||||
}
|
||||
|
||||
if method := parser.getEnv("METHOD"); method != "" {
|
||||
config.Methods = []string{method}
|
||||
}
|
||||
|
||||
if body := parser.getEnv("BODY"); body != "" {
|
||||
config.Bodies = []string{body}
|
||||
}
|
||||
|
||||
if param := parser.getEnv("PARAM"); param != "" {
|
||||
config.Params.Parse(param)
|
||||
}
|
||||
@@ -193,10 +161,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
||||
config.Cookies.Parse(cookie)
|
||||
}
|
||||
|
||||
if body := parser.getEnv("BODY"); body != "" {
|
||||
config.Bodies = []string{body}
|
||||
}
|
||||
|
||||
if proxy := parser.getEnv("PROXY"); proxy != "" {
|
||||
err := config.Proxies.Parse(proxy)
|
||||
if err != nil {
|
||||
@@ -215,6 +179,38 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
||||
config.Values = []string{values}
|
||||
}
|
||||
|
||||
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
|
||||
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
|
||||
if err != nil {
|
||||
fieldParseErrors = append(
|
||||
fieldParseErrors,
|
||||
types.NewFieldParseError(
|
||||
parser.getFullEnvName("TIMEOUT"),
|
||||
timeout,
|
||||
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
config.Timeout = &timeoutParsed
|
||||
}
|
||||
}
|
||||
|
||||
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
||||
insecureParsed, err := utilsParse.ParseString[bool](insecure)
|
||||
if err != nil {
|
||||
fieldParseErrors = append(
|
||||
fieldParseErrors,
|
||||
types.NewFieldParseError(
|
||||
parser.getFullEnvName("INSECURE"),
|
||||
insecure,
|
||||
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
config.Insecure = &insecureParsed
|
||||
}
|
||||
}
|
||||
|
||||
if lua := parser.getEnv("LUA"); lua != "" {
|
||||
config.Lua = []string{lua}
|
||||
}
|
||||
|
||||
+43
-33
@@ -192,24 +192,26 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
||||
}
|
||||
|
||||
type configYAML struct {
|
||||
ShowConfig *bool `yaml:"showConfig"`
|
||||
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
||||
Method stringOrSliceField `yaml:"method"`
|
||||
URL *string `yaml:"url"`
|
||||
Timeout *time.Duration `yaml:"timeout"`
|
||||
Concurrency *uint `yaml:"concurrency"`
|
||||
RequestCount *uint64 `yaml:"requests"`
|
||||
Duration *time.Duration `yaml:"duration"`
|
||||
Quiet *bool `yaml:"quiet"`
|
||||
LogLevel *string `yaml:"logLevel"`
|
||||
LogFile *string `yaml:"logFile"`
|
||||
Progress *string `yaml:"progress"`
|
||||
Output *string `yaml:"output"`
|
||||
Insecure *bool `yaml:"insecure"`
|
||||
ShowConfig *bool `yaml:"showConfig"`
|
||||
DryRun *bool `yaml:"dryRun"`
|
||||
URL *string `yaml:"url"`
|
||||
Method stringOrSliceField `yaml:"method"`
|
||||
Bodies stringOrSliceField `yaml:"body"`
|
||||
Params keyValuesField `yaml:"params"`
|
||||
Headers keyValuesField `yaml:"headers"`
|
||||
Cookies keyValuesField `yaml:"cookies"`
|
||||
Bodies stringOrSliceField `yaml:"body"`
|
||||
Proxies stringOrSliceField `yaml:"proxy"`
|
||||
Values stringOrSliceField `yaml:"values"`
|
||||
Timeout *time.Duration `yaml:"timeout"`
|
||||
Insecure *bool `yaml:"insecure"`
|
||||
Lua stringOrSliceField `yaml:"lua"`
|
||||
Js stringOrSliceField `yaml:"js"`
|
||||
}
|
||||
@@ -231,33 +233,7 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
||||
|
||||
var fieldParseErrors []types.FieldParseError
|
||||
|
||||
config.Methods = append(config.Methods, parsedData.Method...)
|
||||
config.Timeout = parsedData.Timeout
|
||||
config.Concurrency = parsedData.Concurrency
|
||||
config.Requests = parsedData.RequestCount
|
||||
config.Duration = parsedData.Duration
|
||||
config.ShowConfig = parsedData.ShowConfig
|
||||
config.Quiet = parsedData.Quiet
|
||||
|
||||
if parsedData.Output != nil {
|
||||
config.Output = new(ConfigOutputType(*parsedData.Output))
|
||||
}
|
||||
|
||||
config.Insecure = parsedData.Insecure
|
||||
config.DryRun = parsedData.DryRun
|
||||
for _, kv := range parsedData.Params {
|
||||
config.Params = append(config.Params, types.Param(kv))
|
||||
}
|
||||
for _, kv := range parsedData.Headers {
|
||||
config.Headers = append(config.Headers, types.Header(kv))
|
||||
}
|
||||
for _, kv := range parsedData.Cookies {
|
||||
config.Cookies = append(config.Cookies, types.Cookie(kv))
|
||||
}
|
||||
config.Bodies = append(config.Bodies, parsedData.Bodies...)
|
||||
config.Values = append(config.Values, parsedData.Values...)
|
||||
config.Lua = append(config.Lua, parsedData.Lua...)
|
||||
config.Js = append(config.Js, parsedData.Js...)
|
||||
|
||||
if len(parsedData.ConfigFiles) > 0 {
|
||||
for _, configFile := range parsedData.ConfigFiles {
|
||||
@@ -265,6 +241,22 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
config.Concurrency = parsedData.Concurrency
|
||||
config.Requests = parsedData.RequestCount
|
||||
config.Duration = parsedData.Duration
|
||||
config.LogLevel = parsedData.LogLevel
|
||||
config.LogFile = parsedData.LogFile
|
||||
|
||||
if parsedData.Progress != nil {
|
||||
config.Progress = new(ConfigProgressType(*parsedData.Progress))
|
||||
}
|
||||
|
||||
if parsedData.Output != nil {
|
||||
config.Output = new(ConfigOutputType(*parsedData.Output))
|
||||
}
|
||||
|
||||
config.DryRun = parsedData.DryRun
|
||||
|
||||
if parsedData.URL != nil {
|
||||
urlParsed, err := url.Parse(*parsedData.URL)
|
||||
if err != nil {
|
||||
@@ -274,6 +266,18 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
config.Methods = append(config.Methods, parsedData.Method...)
|
||||
config.Bodies = append(config.Bodies, parsedData.Bodies...)
|
||||
for _, kv := range parsedData.Params {
|
||||
config.Params = append(config.Params, types.Param(kv))
|
||||
}
|
||||
for _, kv := range parsedData.Headers {
|
||||
config.Headers = append(config.Headers, types.Header(kv))
|
||||
}
|
||||
for _, kv := range parsedData.Cookies {
|
||||
config.Cookies = append(config.Cookies, types.Cookie(kv))
|
||||
}
|
||||
|
||||
for i, proxy := range parsedData.Proxies {
|
||||
err := config.Proxies.Parse(proxy)
|
||||
if err != nil {
|
||||
@@ -284,6 +288,12 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
config.Values = append(config.Values, parsedData.Values...)
|
||||
config.Timeout = parsedData.Timeout
|
||||
config.Insecure = parsedData.Insecure
|
||||
config.Lua = append(config.Lua, parsedData.Lua...)
|
||||
config.Js = append(config.Js, parsedData.Js...)
|
||||
|
||||
if len(fieldParseErrors) > 0 {
|
||||
return nil, types.NewFieldParseErrors(fieldParseErrors)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
)
|
||||
|
||||
const (
|
||||
captchaPollInterval = 1 * time.Second
|
||||
captchaPollTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
var captchaHTTPClient = &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
// solveCaptcha creates a task on the given captcha service and polls until it is solved,
|
||||
// returning the extracted token from the solution object.
|
||||
//
|
||||
// baseURL is the service API base (e.g. "https://api.2captcha.com").
|
||||
// task is the task payload the service expects (type + service-specific fields).
|
||||
// solutionKey is the field name in the solution object that holds the token.
|
||||
// taskIDIsString controls whether taskId is sent back as a string (CapSolver UUIDs)
|
||||
// or a JSON number (2Captcha, Anti-Captcha).
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey string, taskIDIsString bool) (string, error) {
|
||||
if apiKey == "" {
|
||||
return "", types.ErrCaptchaKeyEmpty
|
||||
}
|
||||
|
||||
taskID, err := captchaCreateTask(baseURL, apiKey, task)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return captchaPollResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString)
|
||||
}
|
||||
|
||||
// captchaCreateTask submits a task to the captcha service and returns the assigned taskId.
|
||||
// The taskId is normalized to a string: numeric IDs are preserved via json.RawMessage,
|
||||
// and quoted string IDs (CapSolver UUIDs) have their surrounding quotes stripped.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, error) {
|
||||
body := map[string]any{
|
||||
"clientKey": apiKey,
|
||||
"task": task,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", types.NewCaptchaDecodeError("createTask", err)
|
||||
}
|
||||
|
||||
resp, err := captchaHTTPClient.Post(
|
||||
baseURL+"/createTask",
|
||||
"application/json",
|
||||
bytes.NewReader(data),
|
||||
)
|
||||
if err != nil {
|
||||
return "", types.NewCaptchaRequestError("createTask", err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
var result struct {
|
||||
ErrorID int `json:"errorId"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
ErrorDescription string `json:"errorDescription"`
|
||||
TaskID json.RawMessage `json:"taskId"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", types.NewCaptchaDecodeError("createTask", err)
|
||||
}
|
||||
|
||||
if result.ErrorID != 0 {
|
||||
return "", types.NewCaptchaAPIError("createTask", result.ErrorCode, result.ErrorDescription)
|
||||
}
|
||||
|
||||
// taskId may be a JSON number (2captcha, anti-captcha) or a quoted string (capsolver UUIDs).
|
||||
// Strip surrounding quotes if present so we always work with the underlying value.
|
||||
taskID := strings.Trim(string(result.TaskID), `"`)
|
||||
if taskID == "" {
|
||||
return "", types.NewCaptchaAPIError("createTask", "EMPTY_TASK_ID", "service returned a successful response with no taskId")
|
||||
}
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
// captchaPollResult polls the getTaskResult endpoint at captchaPollInterval until the task
|
||||
// is solved, an error is returned by the service, or the overall captchaPollTimeout is hit.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), captchaPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(captchaPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", types.NewCaptchaPollTimeoutError(taskID)
|
||||
case <-ticker.C:
|
||||
token, err := captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString)
|
||||
if errors.Is(err, types.ErrCaptchaProcessing) {
|
||||
continue
|
||||
}
|
||||
// Retry on transient HTTP errors (timeouts, connection resets, etc.)
|
||||
// instead of failing the entire solve. The poll loop timeout will
|
||||
// eventually catch permanently unreachable services.
|
||||
if _, ok := errors.AsType[types.CaptchaRequestError](err); ok {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// captchaGetTaskResult fetches a single task result from the captcha service.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaProcessing
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) {
|
||||
var bodyMap map[string]any
|
||||
if taskIDIsString {
|
||||
bodyMap = map[string]any{"clientKey": apiKey, "taskId": taskID}
|
||||
} else {
|
||||
bodyMap = map[string]any{"clientKey": apiKey, "taskId": json.Number(taskID)}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(bodyMap)
|
||||
if err != nil {
|
||||
return "", types.NewCaptchaDecodeError("getTaskResult", err)
|
||||
}
|
||||
|
||||
resp, err := captchaHTTPClient.Post(
|
||||
baseURL+"/getTaskResult",
|
||||
"application/json",
|
||||
bytes.NewReader(data),
|
||||
)
|
||||
if err != nil {
|
||||
return "", types.NewCaptchaRequestError("getTaskResult", err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
var result struct {
|
||||
ErrorID int `json:"errorId"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
ErrorDescription string `json:"errorDescription"`
|
||||
Status string `json:"status"`
|
||||
Solution map[string]any `json:"solution"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", types.NewCaptchaDecodeError("getTaskResult", err)
|
||||
}
|
||||
|
||||
if result.ErrorID != 0 {
|
||||
return "", types.NewCaptchaAPIError("getTaskResult", result.ErrorCode, result.ErrorDescription)
|
||||
}
|
||||
|
||||
if result.Status == "processing" || result.Status == "idle" {
|
||||
return "", types.ErrCaptchaProcessing
|
||||
}
|
||||
|
||||
token, ok := result.Solution[solutionKey]
|
||||
if !ok {
|
||||
return "", types.NewCaptchaSolutionKeyError(solutionKey)
|
||||
}
|
||||
tokenStr, ok := token.(string)
|
||||
if !ok {
|
||||
return "", types.NewCaptchaSolutionKeyError(solutionKey)
|
||||
}
|
||||
|
||||
return tokenStr, nil
|
||||
}
|
||||
|
||||
// ======================================== 2Captcha ========================================
|
||||
|
||||
const twoCaptchaBaseURL = "https://api.2captcha.com"
|
||||
|
||||
// twoCaptchaSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via 2Captcha.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
|
||||
return solveCaptcha(twoCaptchaBaseURL, apiKey, map[string]any{
|
||||
"type": "RecaptchaV2TaskProxyless",
|
||||
"websiteURL": websiteURL,
|
||||
"websiteKey": websiteKey,
|
||||
}, "gRecaptchaResponse", false)
|
||||
}
|
||||
|
||||
// twoCaptchaSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via 2Captcha.
|
||||
// pageAction may be empty.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
|
||||
task := map[string]any{
|
||||
"type": "RecaptchaV3TaskProxyless",
|
||||
"websiteURL": websiteURL,
|
||||
"websiteKey": websiteKey,
|
||||
}
|
||||
if pageAction != "" {
|
||||
task["pageAction"] = pageAction
|
||||
}
|
||||
return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false)
|
||||
}
|
||||
|
||||
// twoCaptchaSolveTurnstile solves a Cloudflare Turnstile challenge via 2Captcha.
|
||||
// cData may be empty.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
|
||||
task := map[string]any{
|
||||
"type": "TurnstileTaskProxyless",
|
||||
"websiteURL": websiteURL,
|
||||
"websiteKey": websiteKey,
|
||||
}
|
||||
if cData != "" {
|
||||
task["data"] = cData
|
||||
}
|
||||
return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "token", false)
|
||||
}
|
||||
|
||||
// ======================================== Anti-Captcha ========================================
|
||||
|
||||
const antiCaptchaBaseURL = "https://api.anti-captcha.com"
|
||||
|
||||
// antiCaptchaSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via Anti-Captcha.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
|
||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
|
||||
"type": "RecaptchaV2TaskProxyless",
|
||||
"websiteURL": websiteURL,
|
||||
"websiteKey": websiteKey,
|
||||
}, "gRecaptchaResponse", false)
|
||||
}
|
||||
|
||||
// antiCaptchaSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via Anti-Captcha.
|
||||
// pageAction may be empty. minScore is hardcoded to 0.3 (the loosest threshold) because
|
||||
// Anti-Captcha rejects the request without it.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
|
||||
task := map[string]any{
|
||||
"type": "RecaptchaV3TaskProxyless",
|
||||
"websiteURL": websiteURL,
|
||||
"websiteKey": websiteKey,
|
||||
"minScore": 0.3,
|
||||
}
|
||||
if pageAction != "" {
|
||||
task["pageAction"] = pageAction
|
||||
}
|
||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false)
|
||||
}
|
||||
|
||||
// antiCaptchaSolveHCaptcha solves an hCaptcha challenge via Anti-Captcha.
|
||||
// Anti-Captcha returns hCaptcha tokens under "gRecaptchaResponse" (not "token").
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, error) {
|
||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
|
||||
"type": "HCaptchaTaskProxyless",
|
||||
"websiteURL": websiteURL,
|
||||
"websiteKey": websiteKey,
|
||||
}, "gRecaptchaResponse", false)
|
||||
}
|
||||
|
||||
// antiCaptchaSolveTurnstile solves a Cloudflare Turnstile challenge via Anti-Captcha.
|
||||
// cData may be empty.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
|
||||
task := map[string]any{
|
||||
"type": "TurnstileTaskProxyless",
|
||||
"websiteURL": websiteURL,
|
||||
"websiteKey": websiteKey,
|
||||
}
|
||||
if cData != "" {
|
||||
task["cData"] = cData
|
||||
}
|
||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "token", false)
|
||||
}
|
||||
|
||||
// ======================================== CapSolver ========================================
|
||||
|
||||
const capSolverBaseURL = "https://api.capsolver.com"
|
||||
|
||||
// capSolverSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via CapSolver.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
|
||||
return solveCaptcha(capSolverBaseURL, apiKey, map[string]any{
|
||||
"type": "ReCaptchaV2TaskProxyLess",
|
||||
"websiteURL": websiteURL,
|
||||
"websiteKey": websiteKey,
|
||||
}, "gRecaptchaResponse", true)
|
||||
}
|
||||
|
||||
// capSolverSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via CapSolver.
|
||||
// pageAction may be empty.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
|
||||
task := map[string]any{
|
||||
"type": "ReCaptchaV3TaskProxyLess",
|
||||
"websiteURL": websiteURL,
|
||||
"websiteKey": websiteKey,
|
||||
}
|
||||
if pageAction != "" {
|
||||
task["pageAction"] = pageAction
|
||||
}
|
||||
return solveCaptcha(capSolverBaseURL, apiKey, task, "gRecaptchaResponse", true)
|
||||
}
|
||||
|
||||
// capSolverSolveTurnstile solves a Cloudflare Turnstile challenge via CapSolver.
|
||||
// cData may be empty. CapSolver nests cData under a "metadata" object.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
|
||||
task := map[string]any{
|
||||
"type": "AntiTurnstileTaskProxyLess",
|
||||
"websiteURL": websiteURL,
|
||||
"websiteKey": websiteKey,
|
||||
}
|
||||
if cData != "" {
|
||||
task["metadata"] = map[string]any{"cdata": cData}
|
||||
}
|
||||
return solveCaptcha(capSolverBaseURL, apiKey, task, "token", true)
|
||||
}
|
||||
@@ -12,3 +12,10 @@ func NewDefaultRandSource() rand.Source {
|
||||
uint64(now>>32),
|
||||
)
|
||||
}
|
||||
|
||||
func firstOrEmpty(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
+85
-33
@@ -56,9 +56,9 @@ func NewRequestGenerator(
|
||||
|
||||
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, lazyTemplateRoot)
|
||||
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, lazyTemplateRoot)
|
||||
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot)
|
||||
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot)
|
||||
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot)
|
||||
paramsGenerator, isParamsGeneratorDynamic, paramKeysAreStatic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot)
|
||||
headersGenerator, isHeadersGeneratorDynamic, headerKeysAreStatic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot)
|
||||
cookiesGenerator, isCookiesGeneratorDynamic, cookieKeysAreStatic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot)
|
||||
|
||||
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
|
||||
var bodyTemplateRoot *template.Template
|
||||
@@ -83,13 +83,26 @@ func NewRequestGenerator(
|
||||
Cookies: make(map[string][]string),
|
||||
}
|
||||
|
||||
// When a map's key set is fixed, every request refills exactly the same keys, so
|
||||
// the value slices can be truncated and reused instead of being reallocated after
|
||||
// a clear(). Scripts are excluded: they may swap the maps out or add keys of their
|
||||
// own, and they would observe a leftover empty slice where a key used to be absent.
|
||||
reuseParamSlices := paramKeysAreStatic && !hasScripts
|
||||
reuseHeaderSlices := headerKeysAreStatic && !hasScripts
|
||||
reuseCookieSlices := cookieKeysAreStatic && !hasScripts
|
||||
|
||||
var (
|
||||
data valuesData
|
||||
path string
|
||||
err error
|
||||
)
|
||||
return func(req *fasthttp.Request) error {
|
||||
resetRequestData(reqData)
|
||||
resetStringSliceMap(reqData.Headers, reuseHeaderSlices)
|
||||
resetStringSliceMap(reqData.Params, reuseParamSlices)
|
||||
resetStringSliceMap(reqData.Cookies, reuseCookieSlices)
|
||||
reqData.Method = ""
|
||||
reqData.Path = ""
|
||||
reqData.Body = ""
|
||||
|
||||
data, err = valuesGenerator()
|
||||
if err != nil {
|
||||
@@ -143,20 +156,24 @@ func NewRequestGenerator(
|
||||
hasScripts
|
||||
}
|
||||
|
||||
func resetRequestData(reqData *script.RequestData) {
|
||||
reqData.Method = ""
|
||||
reqData.Path = ""
|
||||
reqData.Body = ""
|
||||
clear(reqData.Headers)
|
||||
clear(reqData.Params)
|
||||
clear(reqData.Cookies)
|
||||
// resetStringSliceMap empties m for the next render. With reuse it truncates the
|
||||
// value slices in place so the following appends land in the existing backing
|
||||
// arrays, without it the map is cleared so no stale key can survive.
|
||||
func resetStringSliceMap(m map[string][]string, reuse bool) {
|
||||
if !reuse {
|
||||
clear(m)
|
||||
return
|
||||
}
|
||||
for k, v := range m {
|
||||
m[k] = v[:0]
|
||||
}
|
||||
}
|
||||
|
||||
func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Request, host, scheme string) {
|
||||
req.Header.SetHost(host)
|
||||
req.SetRequestURI(reqData.Path)
|
||||
req.Header.SetMethod(reqData.Method)
|
||||
req.SetBody([]byte(reqData.Body))
|
||||
req.SetBodyString(reqData.Body)
|
||||
|
||||
for k, values := range reqData.Headers {
|
||||
for _, v := range values {
|
||||
@@ -164,20 +181,28 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
|
||||
}
|
||||
}
|
||||
|
||||
for k, values := range reqData.Params {
|
||||
for _, v := range values {
|
||||
req.URI().QueryArgs().Add(k, v)
|
||||
if len(reqData.Params) > 0 {
|
||||
args := req.URI().QueryArgs()
|
||||
for k, values := range reqData.Params {
|
||||
for _, v := range values {
|
||||
args.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(reqData.Cookies) > 0 {
|
||||
cookieStrings := make([]string, 0, len(reqData.Cookies))
|
||||
var sb strings.Builder
|
||||
for k, values := range reqData.Cookies {
|
||||
for _, v := range values {
|
||||
cookieStrings = append(cookieStrings, k+"="+v)
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("; ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(v)
|
||||
}
|
||||
}
|
||||
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
|
||||
req.Header.Add("Cookie", sb.String())
|
||||
}
|
||||
|
||||
if scheme == "https" {
|
||||
@@ -221,8 +246,8 @@ func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, lazyRoot func()
|
||||
}, isDynamic
|
||||
}
|
||||
|
||||
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||
generators, isDynamic := buildKeyValueGenerators(localRand, params, lazyRoot)
|
||||
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
|
||||
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, params, lazyRoot)
|
||||
|
||||
var (
|
||||
key, value string
|
||||
@@ -243,11 +268,11 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot
|
||||
reqData.Params[key] = append(reqData.Params[key], value)
|
||||
}
|
||||
return nil
|
||||
}, isDynamic
|
||||
}, isDynamic, keysAreStatic
|
||||
}
|
||||
|
||||
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||
generators, isDynamic := buildKeyValueGenerators(localRand, headers, lazyRoot)
|
||||
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
|
||||
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, headers, lazyRoot)
|
||||
|
||||
var (
|
||||
key, value string
|
||||
@@ -268,11 +293,11 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRo
|
||||
reqData.Headers[key] = append(reqData.Headers[key], value)
|
||||
}
|
||||
return nil
|
||||
}, isDynamic
|
||||
}, isDynamic, keysAreStatic
|
||||
}
|
||||
|
||||
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
|
||||
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
|
||||
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
|
||||
|
||||
var (
|
||||
key, value string
|
||||
@@ -293,14 +318,26 @@ func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRo
|
||||
reqData.Cookies[key] = append(reqData.Cookies[key], value)
|
||||
}
|
||||
return nil
|
||||
}, isDynamic
|
||||
}, isDynamic, keysAreStatic
|
||||
}
|
||||
|
||||
func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template) func() (valuesData, error) {
|
||||
// No values configured: hand back one shared empty map instead of allocating a
|
||||
// fresh one for every request. Nothing ever writes to it.
|
||||
if len(values) == 0 {
|
||||
empty := valuesData{Values: map[string]string{}}
|
||||
return func() (valuesData, error) { return empty, nil }
|
||||
}
|
||||
|
||||
generators := make([]func(_ any) (string, error), len(values))
|
||||
|
||||
isDynamic := false
|
||||
for i, v := range values {
|
||||
generators[i], _ = createTemplateFunc(v, lazyRoot)
|
||||
var valueIsDynamic bool
|
||||
generators[i], valueIsDynamic = createTemplateFunc(v, lazyRoot)
|
||||
if valueIsDynamic {
|
||||
isDynamic = true
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -308,7 +345,7 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
|
||||
data map[string]string
|
||||
err error
|
||||
)
|
||||
return func() (valuesData, error) {
|
||||
generate := func() (valuesData, error) {
|
||||
result := make(map[string]string)
|
||||
for _, generator := range generators {
|
||||
rendered, err = generator(nil)
|
||||
@@ -326,6 +363,15 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
|
||||
|
||||
return valuesData{Values: result}, nil
|
||||
}
|
||||
|
||||
// Every value is a literal, so the parsed result is identical on every request:
|
||||
// render and parse once instead of re-running godotenv per request.
|
||||
if !isDynamic {
|
||||
staticResult, staticErr := generate()
|
||||
return func() (valuesData, error) { return staticResult, staticErr }
|
||||
}
|
||||
|
||||
return generate
|
||||
}
|
||||
|
||||
func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(data any) (string, error), bool) {
|
||||
@@ -335,15 +381,19 @@ func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(
|
||||
|
||||
tmpl, err := lazyRoot().New("").Parse(value)
|
||||
if err == nil && hasTemplateActions(tmpl) {
|
||||
var err error
|
||||
var (
|
||||
buf bytes.Buffer
|
||||
err error
|
||||
)
|
||||
return func(data any) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
buf.Reset()
|
||||
if err = tmpl.Execute(&buf, data); err != nil {
|
||||
return "", types.NewTemplateRenderError(err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}, true
|
||||
}
|
||||
|
||||
return func(_ any) (string, error) { return value, nil }, false
|
||||
}
|
||||
|
||||
@@ -360,8 +410,9 @@ func buildKeyValueGenerators[T keyValueItem](
|
||||
localRand *rand.Rand,
|
||||
items []T,
|
||||
lazyRoot func() *template.Template,
|
||||
) ([]keyValueGenerator, bool) {
|
||||
) ([]keyValueGenerator, bool, bool) {
|
||||
isDynamic := false
|
||||
keysAreStatic := true
|
||||
generators := make([]keyValueGenerator, len(items))
|
||||
|
||||
for generatorIndex, item := range items {
|
||||
@@ -372,6 +423,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
||||
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot)
|
||||
if keyIsDynamic {
|
||||
isDynamic = true
|
||||
keysAreStatic = false
|
||||
}
|
||||
|
||||
// Generate value functions
|
||||
@@ -394,7 +446,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
||||
}
|
||||
}
|
||||
|
||||
return generators, isDynamic
|
||||
return generators, isDynamic, keysAreStatic
|
||||
}
|
||||
|
||||
func buildStringSliceGenerator(
|
||||
|
||||
@@ -2,7 +2,6 @@ package sarin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"slices"
|
||||
@@ -10,8 +9,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -159,7 +158,7 @@ func (data *SarinResponseData) PrintTable() {
|
||||
return cellStyle
|
||||
})
|
||||
|
||||
fmt.Println(tbl)
|
||||
lipgloss.Println(tbl)
|
||||
}
|
||||
|
||||
func (data *SarinResponseData) PrintJSON() {
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/term"
|
||||
"github.com/valyala/fasthttp"
|
||||
"go.aykhans.me/sarin/internal/script"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
)
|
||||
|
||||
type runtimeLogLevel uint8
|
||||
|
||||
const (
|
||||
runtimeLogLevelInfo runtimeLogLevel = iota
|
||||
runtimeLogLevelError
|
||||
)
|
||||
|
||||
type runtimeLog struct {
|
||||
timestamp time.Time
|
||||
level runtimeLogLevel
|
||||
text string
|
||||
}
|
||||
|
||||
type runtimeLogger func(level runtimeLogLevel, text string)
|
||||
|
||||
// SplitLogLevels parses a comma-separated log-level string into a deduplicated
|
||||
// slice of level tokens (surrounding whitespace trimmed, empties dropped). Level
|
||||
// names are matched case-sensitively, like the other enum options.
|
||||
func SplitLogLevels(levels string) []string {
|
||||
var out []string
|
||||
seen := make(map[string]bool)
|
||||
for part := range strings.SplitSeq(levels, ",") {
|
||||
token := strings.TrimSpace(part)
|
||||
if token == "" || seen[token] {
|
||||
continue
|
||||
}
|
||||
seen[token] = true
|
||||
out = append(out, token)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsInteractiveTerminal reports whether fd is a terminal that can actually be
|
||||
// drawn on. A pty reports as a terminal even while its size is still zero, and
|
||||
// Bubble Tea paints into a width x height cell buffer, so a zero dimension
|
||||
// renders nothing at all.
|
||||
func IsInteractiveTerminal(fd uintptr) bool {
|
||||
if !term.IsTerminal(fd) {
|
||||
return false
|
||||
}
|
||||
width, height, err := term.GetSize(fd)
|
||||
return err == nil && width > 0 && height > 0
|
||||
}
|
||||
|
||||
// respLogger logs a single completed response.
|
||||
type respLogger func(duration time.Duration, resp *fasthttp.Response)
|
||||
|
||||
func noopLog(runtimeLogLevel, string) {}
|
||||
func noopRespLog(time.Duration, *fasthttp.Response) {}
|
||||
|
||||
// gateSendLog wraps emit with level filtering decided once from the enabled
|
||||
// levels. It stays general (any level filters correctly) while avoiding a
|
||||
// per-log check when both levels are on and any work at all when both are off.
|
||||
func gateSendLog(logInfo, logError bool, emit func(level runtimeLogLevel, text string)) runtimeLogger {
|
||||
switch {
|
||||
case logInfo && logError:
|
||||
return emit
|
||||
case logInfo:
|
||||
return func(level runtimeLogLevel, text string) {
|
||||
if level == runtimeLogLevelInfo {
|
||||
emit(level, text)
|
||||
}
|
||||
}
|
||||
case logError:
|
||||
return func(level runtimeLogLevel, text string) {
|
||||
if level == runtimeLogLevelError {
|
||||
emit(level, text)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return noopLog
|
||||
}
|
||||
}
|
||||
|
||||
// respBodySnippetLen bounds how many bytes of the body the compact (TUI)
|
||||
// rendering shows.
|
||||
const respBodySnippetLen = 100
|
||||
|
||||
// formatRuntimeLogLine renders a runtime log as a plain (unstyled) line for
|
||||
// stderr output. ANSI styling is left to the TUI.
|
||||
func formatRuntimeLogLine(timestamp time.Time, level runtimeLogLevel, text string) string {
|
||||
levelStr := "ERROR"
|
||||
if level == runtimeLogLevelInfo {
|
||||
levelStr = "INFO"
|
||||
}
|
||||
return "[" + timestamp.Format("15:04:05") + "] " + levelStr + ": " + text
|
||||
}
|
||||
|
||||
// respToLog renders a response as a compact one-line summary for the TUI log box
|
||||
// (the "[time] INFO:" prefix is added by the TUI).
|
||||
func respToLog(duration time.Duration, resp *fasthttp.Response) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(statusCodeToString(resp.StatusCode()))
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(Duration(duration).String())
|
||||
if snippet := bodySnippet(resp.Body(), respBodySnippetLen); snippet != "" {
|
||||
sb.WriteString(" | ")
|
||||
sb.WriteString(snippet)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type respLogEntry struct {
|
||||
Status int `json:"status"`
|
||||
Duration string `json:"duration"`
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// respToLogJSON renders a response as a single, self-contained JSON line so the
|
||||
// stderr stream stays valid NDJSON (pipeable to jq or any consumer).
|
||||
func respToLogJSON(duration time.Duration, resp *fasthttp.Response) string {
|
||||
entry := respLogEntry{
|
||||
Status: resp.StatusCode(),
|
||||
Duration: Duration(duration).String(),
|
||||
Headers: collectRespHeaders(resp),
|
||||
Body: string(resp.Body()),
|
||||
}
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return `{"error":"failed to marshal response log"}`
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func collectRespHeaders(resp *fasthttp.Response) map[string][]string {
|
||||
headers := make(map[string][]string)
|
||||
for key, value := range resp.Header.All() {
|
||||
k := string(key)
|
||||
headers[k] = append(headers[k], string(value))
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// bodySnippet returns the first maxLen bytes of body collapsed onto a single
|
||||
// line (control characters replaced with spaces), with an ellipsis when truncated.
|
||||
func bodySnippet(body []byte, maxLen int) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
truncated := false
|
||||
if len(body) > maxLen {
|
||||
body = body[:maxLen]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
s := strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, string(body))
|
||||
|
||||
if truncated {
|
||||
s += "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type sarin struct {
|
||||
workers uint
|
||||
requestURL *url.URL
|
||||
methods []string
|
||||
params types.Params
|
||||
headers types.Headers
|
||||
cookies types.Cookies
|
||||
bodies []string
|
||||
totalRequests *uint64
|
||||
totalDuration *time.Duration
|
||||
timeout time.Duration
|
||||
showProgress bool
|
||||
skipCertVerify bool
|
||||
values []string
|
||||
collectStats bool
|
||||
dryRun bool
|
||||
logInfo bool
|
||||
logError bool
|
||||
logFile string
|
||||
|
||||
hostClients []*fasthttp.HostClient
|
||||
responses *SarinResponseData
|
||||
fileCache *FileCache
|
||||
scriptChain *script.Chain
|
||||
}
|
||||
|
||||
// NewSarin creates a new sarin instance for load testing.
|
||||
// It can return the following errors:
|
||||
// - types.ProxyDialError
|
||||
// - types.ErrScriptEmpty
|
||||
// - types.ScriptLoadError
|
||||
func NewSarin(
|
||||
ctx context.Context,
|
||||
methods []string,
|
||||
requestURL *url.URL,
|
||||
timeout time.Duration,
|
||||
workers uint,
|
||||
totalRequests *uint64,
|
||||
totalDuration *time.Duration,
|
||||
showProgress bool,
|
||||
skipCertVerify bool,
|
||||
params types.Params,
|
||||
headers types.Headers,
|
||||
cookies types.Cookies,
|
||||
bodies []string,
|
||||
proxies types.Proxies,
|
||||
values []string,
|
||||
collectStats bool,
|
||||
dryRun bool,
|
||||
logLevel string,
|
||||
logFile string,
|
||||
luaScripts []string,
|
||||
jsScripts []string,
|
||||
) (*sarin, error) {
|
||||
if workers == 0 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
// Resolve which log levels are enabled once, up front.
|
||||
var logInfo, logError bool
|
||||
for _, level := range SplitLogLevels(logLevel) {
|
||||
switch level {
|
||||
case "info":
|
||||
logInfo = true
|
||||
case "error":
|
||||
logError = true
|
||||
}
|
||||
}
|
||||
|
||||
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load script sources
|
||||
luaSources, err := script.LoadSources(ctx, luaScripts, script.EngineTypeLua)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jsSources, err := script.LoadSources(ctx, jsScripts, script.EngineTypeJavaScript)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scriptChain := script.NewChain(luaSources, jsSources)
|
||||
|
||||
srn := &sarin{
|
||||
workers: workers,
|
||||
requestURL: requestURL,
|
||||
methods: methods,
|
||||
params: params,
|
||||
headers: headers,
|
||||
cookies: cookies,
|
||||
bodies: bodies,
|
||||
totalRequests: totalRequests,
|
||||
totalDuration: totalDuration,
|
||||
timeout: timeout,
|
||||
showProgress: showProgress,
|
||||
skipCertVerify: skipCertVerify,
|
||||
values: values,
|
||||
collectStats: collectStats,
|
||||
dryRun: dryRun,
|
||||
logInfo: logInfo,
|
||||
logError: logError,
|
||||
logFile: logFile,
|
||||
hostClients: hostClients,
|
||||
fileCache: NewFileCache(time.Second * 10),
|
||||
scriptChain: scriptChain,
|
||||
}
|
||||
|
||||
if collectStats {
|
||||
srn.responses = NewSarinResponseData(uint32(100))
|
||||
}
|
||||
|
||||
return srn, nil
|
||||
}
|
||||
|
||||
func (s sarin) GetResponses() *SarinResponseData {
|
||||
return s.responses
|
||||
}
|
||||
|
||||
func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
||||
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
||||
|
||||
var workersWG sync.WaitGroup
|
||||
jobsCh := make(chan struct{}, max(s.workers, 1))
|
||||
|
||||
var counter atomic.Uint64
|
||||
|
||||
totalRequests := uint64(0)
|
||||
if s.totalRequests != nil {
|
||||
totalRequests = *s.totalRequests
|
||||
}
|
||||
|
||||
onTerminal := IsInteractiveTerminal(os.Stdout.Fd())
|
||||
// The progress bar needs an interactive terminal to render.
|
||||
showProgressBar := s.showProgress && onTerminal
|
||||
// The bubbletea TUI hosts the bar and/or the live log box, so it runs whenever
|
||||
// either has something to show: the bar, or logs that would land in the box
|
||||
// (i.e. logs are enabled and not redirected to a file).
|
||||
runTUI := showProgressBar || (onTerminal && (s.logInfo || s.logError) && s.logFile == "")
|
||||
|
||||
// Open the log file up front, before registering any defer, so a bad path
|
||||
// exits cleanly.
|
||||
var logFile *os.File
|
||||
if s.logFile != "" {
|
||||
f, err := os.OpenFile(s.logFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "failed to open log file "+s.logFile+": "+err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close() //nolint:errcheck
|
||||
logFile = f
|
||||
}
|
||||
|
||||
var (
|
||||
streamCtx context.Context
|
||||
streamCancel context.CancelFunc
|
||||
streamCh chan struct{}
|
||||
tuiLogChannel chan runtimeLog
|
||||
)
|
||||
if runTUI {
|
||||
streamCtx, streamCancel = context.WithCancel(context.Background())
|
||||
defer streamCancel()
|
||||
streamCh = make(chan struct{})
|
||||
tuiLogChannel = make(chan runtimeLog, max(s.workers, 1))
|
||||
}
|
||||
|
||||
// Route logs: to the file if given, else to the TUI log box while it runs,
|
||||
// otherwise to stderr.
|
||||
var (
|
||||
sendLog runtimeLogger
|
||||
sendRespLog respLogger
|
||||
)
|
||||
switch {
|
||||
case logFile != nil:
|
||||
sendLog, sendRespLog = s.newWriterLog(logFile)
|
||||
case runTUI:
|
||||
sendLog, sendRespLog = s.newChannelLog(tuiLogChannel)
|
||||
default:
|
||||
sendLog, sendRespLog = s.newWriterLog(os.Stderr)
|
||||
}
|
||||
|
||||
// Start workers
|
||||
s.startWorkers(&workersWG, jobsCh, s.hostClients, &counter, sendLog, sendRespLog)
|
||||
|
||||
if runTUI {
|
||||
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
|
||||
go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar)
|
||||
}
|
||||
|
||||
// Setup duration-based cancellation
|
||||
s.setupDurationTimeout(ctx, jobsCancel)
|
||||
// Distribute jobs to workers.
|
||||
// This blocks until all jobs are sent or the context is canceled.
|
||||
s.sendJobs(jobsCtx, jobsCh)
|
||||
|
||||
// Close the jobs channel so workers stop after completing their current job
|
||||
close(jobsCh)
|
||||
// Wait until all workers stopped
|
||||
workersWG.Wait()
|
||||
if tuiLogChannel != nil {
|
||||
close(tuiLogChannel)
|
||||
}
|
||||
|
||||
if runTUI {
|
||||
// Stop the progress streaming
|
||||
streamCancel()
|
||||
// Wait until progress streaming has completely stopped
|
||||
<-streamCh
|
||||
}
|
||||
}
|
||||
|
||||
// newWriterLog builds the loggers that write formatted lines to w (a log file or
|
||||
// stderr). sendLog stays general (it filters by each log's level); sendRespLog
|
||||
// only ever emits info, so its decision is baked once into a no-op when off.
|
||||
func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) {
|
||||
// log.Logger serializes writes with its own mutex, so concurrent workers
|
||||
// won't interleave lines.
|
||||
logger := log.New(w, "", 0)
|
||||
|
||||
sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
|
||||
logger.Println(formatRuntimeLogLine(time.Now(), level, text))
|
||||
})
|
||||
|
||||
var sendRespLog respLogger = noopRespLog
|
||||
if s.logInfo {
|
||||
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
|
||||
logger.Println(formatRuntimeLogLine(time.Now(), runtimeLogLevelInfo, respToLogJSON(duration, resp)))
|
||||
}
|
||||
}
|
||||
|
||||
return sendLog, sendRespLog
|
||||
}
|
||||
|
||||
// newChannelLog builds the loggers that feed the TUI log box through ch, with the
|
||||
// same gating as newWriterLog.
|
||||
func (s sarin) newChannelLog(ch chan<- runtimeLog) (runtimeLogger, respLogger) {
|
||||
sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
|
||||
ch <- runtimeLog{timestamp: time.Now(), level: level, text: text}
|
||||
})
|
||||
|
||||
var sendRespLog respLogger = noopRespLog
|
||||
if s.logInfo {
|
||||
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
|
||||
ch <- runtimeLog{timestamp: time.Now(), level: runtimeLogLevelInfo, text: respToLog(duration, resp)}
|
||||
}
|
||||
}
|
||||
|
||||
return sendLog, sendRespLog
|
||||
}
|
||||
|
||||
// newHostClients initializes HTTP clients for the given configuration.
|
||||
// It can return the following errors:
|
||||
// - types.ProxyDialError
|
||||
func newHostClients(
|
||||
ctx context.Context,
|
||||
timeout time.Duration,
|
||||
proxies types.Proxies,
|
||||
workers uint,
|
||||
requestURL *url.URL,
|
||||
skipCertVerify bool,
|
||||
) ([]*fasthttp.HostClient, error) {
|
||||
proxiesRaw := make([]url.URL, len(proxies))
|
||||
for i, proxy := range proxies {
|
||||
proxiesRaw[i] = url.URL(proxy)
|
||||
}
|
||||
|
||||
return NewHostClients(
|
||||
ctx,
|
||||
timeout,
|
||||
proxiesRaw,
|
||||
workers,
|
||||
requestURL,
|
||||
skipCertVerify,
|
||||
)
|
||||
}
|
||||
|
||||
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
|
||||
for range max(s.workers, 1) {
|
||||
wg.Go(func() {
|
||||
s.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendLog, sendRespLog)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
|
||||
if s.totalDuration != nil {
|
||||
go func() {
|
||||
timer := time.NewTimer(*s.totalDuration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
// Context cancelled, cleanup
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
|
||||
if s.totalRequests != nil && *s.totalRequests > 0 {
|
||||
for range *s.totalRequests {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
jobs <- struct{}{}
|
||||
}
|
||||
} else {
|
||||
for ctx.Err() == nil {
|
||||
jobs <- struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,816 +0,0 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/term"
|
||||
"github.com/valyala/fasthttp"
|
||||
"go.aykhans.me/sarin/internal/script"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
)
|
||||
|
||||
type runtimeMessageLevel uint8
|
||||
|
||||
const (
|
||||
runtimeMessageLevelWarning runtimeMessageLevel = iota
|
||||
runtimeMessageLevelError
|
||||
)
|
||||
|
||||
type runtimeMessage struct {
|
||||
timestamp time.Time
|
||||
level runtimeMessageLevel
|
||||
text string
|
||||
}
|
||||
|
||||
type messageSender func(level runtimeMessageLevel, text string)
|
||||
|
||||
type sarin struct {
|
||||
workers uint
|
||||
requestURL *url.URL
|
||||
methods []string
|
||||
params types.Params
|
||||
headers types.Headers
|
||||
cookies types.Cookies
|
||||
bodies []string
|
||||
totalRequests *uint64
|
||||
totalDuration *time.Duration
|
||||
timeout time.Duration
|
||||
quiet bool
|
||||
skipCertVerify bool
|
||||
values []string
|
||||
collectStats bool
|
||||
dryRun bool
|
||||
|
||||
hostClients []*fasthttp.HostClient
|
||||
responses *SarinResponseData
|
||||
fileCache *FileCache
|
||||
scriptChain *script.Chain
|
||||
}
|
||||
|
||||
// NewSarin creates a new sarin instance for load testing.
|
||||
// It can return the following errors:
|
||||
// - types.ProxyDialError
|
||||
// - types.ErrScriptEmpty
|
||||
// - types.ScriptLoadError
|
||||
func NewSarin(
|
||||
ctx context.Context,
|
||||
methods []string,
|
||||
requestURL *url.URL,
|
||||
timeout time.Duration,
|
||||
workers uint,
|
||||
totalRequests *uint64,
|
||||
totalDuration *time.Duration,
|
||||
quiet bool,
|
||||
skipCertVerify bool,
|
||||
params types.Params,
|
||||
headers types.Headers,
|
||||
cookies types.Cookies,
|
||||
bodies []string,
|
||||
proxies types.Proxies,
|
||||
values []string,
|
||||
collectStats bool,
|
||||
dryRun bool,
|
||||
luaScripts []string,
|
||||
jsScripts []string,
|
||||
) (*sarin, error) {
|
||||
if workers == 0 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load script sources
|
||||
luaSources, err := script.LoadSources(ctx, luaScripts, script.EngineTypeLua)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jsSources, err := script.LoadSources(ctx, jsScripts, script.EngineTypeJavaScript)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scriptChain := script.NewChain(luaSources, jsSources)
|
||||
|
||||
srn := &sarin{
|
||||
workers: workers,
|
||||
requestURL: requestURL,
|
||||
methods: methods,
|
||||
params: params,
|
||||
headers: headers,
|
||||
cookies: cookies,
|
||||
bodies: bodies,
|
||||
totalRequests: totalRequests,
|
||||
totalDuration: totalDuration,
|
||||
timeout: timeout,
|
||||
quiet: quiet,
|
||||
skipCertVerify: skipCertVerify,
|
||||
values: values,
|
||||
collectStats: collectStats,
|
||||
dryRun: dryRun,
|
||||
hostClients: hostClients,
|
||||
fileCache: NewFileCache(time.Second * 10),
|
||||
scriptChain: scriptChain,
|
||||
}
|
||||
|
||||
if collectStats {
|
||||
srn.responses = NewSarinResponseData(uint32(100))
|
||||
}
|
||||
|
||||
return srn, nil
|
||||
}
|
||||
|
||||
func (q sarin) GetResponses() *SarinResponseData {
|
||||
return q.responses
|
||||
}
|
||||
|
||||
func (q sarin) Start(ctx context.Context) {
|
||||
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
||||
|
||||
var workersWG sync.WaitGroup
|
||||
jobsCh := make(chan struct{}, max(q.workers, 1))
|
||||
|
||||
var counter atomic.Uint64
|
||||
|
||||
totalRequests := uint64(0)
|
||||
if q.totalRequests != nil {
|
||||
totalRequests = *q.totalRequests
|
||||
}
|
||||
|
||||
var streamCtx context.Context
|
||||
var streamCancel context.CancelFunc
|
||||
var streamCh chan struct{}
|
||||
var messageChannel chan runtimeMessage
|
||||
var sendMessage messageSender
|
||||
|
||||
if !q.quiet && !term.IsTerminal(os.Stdout.Fd()) {
|
||||
q.quiet = true
|
||||
}
|
||||
|
||||
if q.quiet {
|
||||
sendMessage = func(level runtimeMessageLevel, text string) {}
|
||||
} else {
|
||||
streamCtx, streamCancel = context.WithCancel(context.Background())
|
||||
defer streamCancel()
|
||||
streamCh = make(chan struct{})
|
||||
messageChannel = make(chan runtimeMessage, max(q.workers, 1))
|
||||
sendMessage = func(level runtimeMessageLevel, text string) {
|
||||
messageChannel <- runtimeMessage{
|
||||
timestamp: time.Now(),
|
||||
level: level,
|
||||
text: text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start workers
|
||||
q.startWorkers(&workersWG, jobsCh, q.hostClients, &counter, sendMessage)
|
||||
|
||||
if !q.quiet {
|
||||
// Start streaming to terminal
|
||||
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
|
||||
go q.streamProgress(streamCtx, jobsCancel, streamCh, totalRequests, &counter, messageChannel)
|
||||
}
|
||||
|
||||
// Setup duration-based cancellation
|
||||
q.setupDurationTimeout(ctx, jobsCancel)
|
||||
// Distribute jobs to workers.
|
||||
// This blocks until all jobs are sent or the context is canceled.
|
||||
q.sendJobs(jobsCtx, jobsCh)
|
||||
|
||||
// Close the jobs channel so workers stop after completing their current job
|
||||
close(jobsCh)
|
||||
// Wait until all workers stopped
|
||||
workersWG.Wait()
|
||||
if messageChannel != nil {
|
||||
close(messageChannel)
|
||||
}
|
||||
|
||||
if !q.quiet {
|
||||
// Stop the progress streaming
|
||||
streamCancel()
|
||||
// Wait until progress streaming has completely stopped
|
||||
<-streamCh
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) Worker(
|
||||
jobs <-chan struct{},
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
req := fasthttp.AcquireRequest()
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
// Create script transformer for this worker (engines are not thread-safe)
|
||||
// Scripts are pre-validated in NewSarin, so this should not fail
|
||||
var scriptTransformer *script.Transformer
|
||||
if !q.scriptChain.IsEmpty() {
|
||||
var err error
|
||||
scriptTransformer, err = q.scriptChain.NewTransformer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer scriptTransformer.Close()
|
||||
}
|
||||
|
||||
requestGenerator, isDynamic := NewRequestGenerator(
|
||||
q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache, scriptTransformer,
|
||||
)
|
||||
|
||||
if q.dryRun {
|
||||
switch {
|
||||
case q.collectStats && isDynamic:
|
||||
q.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
|
||||
case q.collectStats && !isDynamic:
|
||||
q.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
|
||||
case !q.collectStats && isDynamic:
|
||||
q.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
|
||||
default:
|
||||
q.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
case q.collectStats && isDynamic:
|
||||
q.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
||||
case q.collectStats && !isDynamic:
|
||||
q.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
||||
case !q.collectStats && isDynamic:
|
||||
q.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
||||
default:
|
||||
q.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
resp.Reset()
|
||||
|
||||
if err := requestGenerator(req); err != nil {
|
||||
q.responses.Add(err.Error(), 0)
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
||||
if err != nil {
|
||||
q.responses.Add(err.Error(), time.Since(startTime))
|
||||
} else {
|
||||
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
|
||||
}
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerStatsWithStatic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
// Static request generation failed - record all jobs as errors
|
||||
for range jobs {
|
||||
q.responses.Add(err.Error(), 0)
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
resp.Reset()
|
||||
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
||||
if err != nil {
|
||||
q.responses.Add(err.Error(), time.Since(startTime))
|
||||
} else {
|
||||
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
|
||||
}
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerNoStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
resp.Reset()
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerNoStatsWithStatic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
|
||||
// Static request generation failed - just count the jobs without sending
|
||||
for range jobs {
|
||||
counter.Add(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
resp.Reset()
|
||||
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
const dryRunResponseKey = "dry-run"
|
||||
|
||||
// statusCodeStrings contains pre-computed string representations for HTTP status codes 100-599.
|
||||
var statusCodeStrings = func() map[int]string {
|
||||
m := make(map[int]string, 500)
|
||||
for i := 100; i < 600; i++ {
|
||||
m[i] = strconv.Itoa(i)
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// statusCodeToString returns a string representation of the HTTP status code.
|
||||
// Uses a pre-computed map for codes 100-599, falls back to strconv.Itoa for others.
|
||||
func statusCodeToString(code int) string {
|
||||
if s, ok := statusCodeStrings[code]; ok {
|
||||
return s
|
||||
}
|
||||
return strconv.Itoa(code)
|
||||
}
|
||||
|
||||
func (q sarin) workerDryRunStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
startTime := time.Now()
|
||||
if err := requestGenerator(req); err != nil {
|
||||
q.responses.Add(err.Error(), time.Since(startTime))
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
q.responses.Add(dryRunResponseKey, time.Since(startTime))
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerDryRunStatsWithStatic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
// Static request generation failed - record all jobs as errors
|
||||
for range jobs {
|
||||
q.responses.Add(err.Error(), 0)
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
q.responses.Add(dryRunResponseKey, 0)
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerDryRunNoStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
}
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerDryRunNoStatsWithStatic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// newHostClients initializes HTTP clients for the given configuration.
|
||||
// It can return the following errors:
|
||||
// - types.ProxyDialError
|
||||
func newHostClients(
|
||||
ctx context.Context,
|
||||
timeout time.Duration,
|
||||
proxies types.Proxies,
|
||||
workers uint,
|
||||
requestURL *url.URL,
|
||||
skipCertVerify bool,
|
||||
) ([]*fasthttp.HostClient, error) {
|
||||
proxiesRaw := make([]url.URL, len(proxies))
|
||||
for i, proxy := range proxies {
|
||||
proxiesRaw[i] = url.URL(proxy)
|
||||
}
|
||||
|
||||
return NewHostClients(
|
||||
ctx,
|
||||
timeout,
|
||||
proxiesRaw,
|
||||
workers,
|
||||
requestURL,
|
||||
skipCertVerify,
|
||||
)
|
||||
}
|
||||
|
||||
func (q sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendMessage messageSender) {
|
||||
for range max(q.workers, 1) {
|
||||
wg.Go(func() {
|
||||
q.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
|
||||
if q.totalDuration != nil {
|
||||
go func() {
|
||||
timer := time.NewTimer(*q.totalDuration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
// Context cancelled, cleanup
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
|
||||
if q.totalRequests != nil && *q.totalRequests > 0 {
|
||||
for range *q.totalRequests {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
jobs <- struct{}{}
|
||||
}
|
||||
} else {
|
||||
for ctx.Err() == nil {
|
||||
jobs <- struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
var (
|
||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
|
||||
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
|
||||
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD93D")).Bold(true)
|
||||
messageChannelStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder(), false, false, false, true).
|
||||
BorderForeground(lipgloss.Color("#757575")).
|
||||
PaddingLeft(1).
|
||||
Margin(1, 0, 0, 0).
|
||||
Foreground(lipgloss.Color("#888888"))
|
||||
)
|
||||
|
||||
type progressModel struct {
|
||||
progress progress.Model
|
||||
startTime time.Time
|
||||
messages []string
|
||||
counter *atomic.Uint64
|
||||
current uint64
|
||||
maxValue uint64
|
||||
ctx context.Context //nolint:containedctx
|
||||
cancel context.CancelFunc
|
||||
cancelling bool
|
||||
}
|
||||
|
||||
func (m progressModel) Init() tea.Cmd {
|
||||
return tea.Batch(progressTickCmd())
|
||||
}
|
||||
|
||||
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
m.cancelling = true
|
||||
m.cancel()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.progress.Width = max(10, msg.Width-1)
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case runtimeMessage:
|
||||
var msgBuilder strings.Builder
|
||||
msgBuilder.WriteString("[")
|
||||
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
|
||||
msgBuilder.WriteString("] ")
|
||||
switch msg.level {
|
||||
case runtimeMessageLevelError:
|
||||
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
|
||||
case runtimeMessageLevelWarning:
|
||||
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
|
||||
}
|
||||
msgBuilder.WriteString(msg.text)
|
||||
m.messages = append(m.messages[1:], msgBuilder.String())
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, progressTickCmd()
|
||||
|
||||
default:
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m progressModel) View() string {
|
||||
var messagesBuilder strings.Builder
|
||||
for i, msg := range m.messages {
|
||||
if len(msg) > 0 {
|
||||
messagesBuilder.WriteString(msg)
|
||||
if i < len(m.messages)-1 {
|
||||
messagesBuilder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var finalBuilder strings.Builder
|
||||
if messagesBuilder.Len() > 0 {
|
||||
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
|
||||
finalBuilder.WriteString("\n")
|
||||
}
|
||||
|
||||
m.current = m.counter.Load()
|
||||
finalBuilder.WriteString("\n ")
|
||||
finalBuilder.WriteString(strconv.FormatUint(m.current, 10))
|
||||
finalBuilder.WriteString("/")
|
||||
finalBuilder.WriteString(strconv.FormatUint(m.maxValue, 10))
|
||||
finalBuilder.WriteString(" - ")
|
||||
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||
finalBuilder.WriteString("\n ")
|
||||
finalBuilder.WriteString(m.progress.ViewAs(float64(m.current) / float64(m.maxValue)))
|
||||
finalBuilder.WriteString("\n\n ")
|
||||
if m.cancelling {
|
||||
finalBuilder.WriteString(helpStyle.Render("Stopping..."))
|
||||
} else {
|
||||
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
|
||||
}
|
||||
return finalBuilder.String()
|
||||
}
|
||||
|
||||
func progressTickCmd() tea.Cmd {
|
||||
return tea.Tick(time.Millisecond*250, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
var infiniteProgressStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00D4FF"))
|
||||
|
||||
type infiniteProgressModel struct {
|
||||
spinner spinner.Model
|
||||
startTime time.Time
|
||||
counter *atomic.Uint64
|
||||
messages []string
|
||||
ctx context.Context //nolint:containedctx
|
||||
quit bool
|
||||
cancel context.CancelFunc
|
||||
cancelling bool
|
||||
}
|
||||
|
||||
func (m infiniteProgressModel) Init() tea.Cmd {
|
||||
return m.spinner.Tick
|
||||
}
|
||||
|
||||
func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
m.cancelling = true
|
||||
m.cancel()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case runtimeMessage:
|
||||
var msgBuilder strings.Builder
|
||||
msgBuilder.WriteString("[")
|
||||
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
|
||||
msgBuilder.WriteString("] ")
|
||||
switch msg.level {
|
||||
case runtimeMessageLevelError:
|
||||
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
|
||||
case runtimeMessageLevelWarning:
|
||||
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
|
||||
}
|
||||
msgBuilder.WriteString(msg.text)
|
||||
m.messages = append(m.messages[1:], msgBuilder.String())
|
||||
if m.ctx.Err() != nil {
|
||||
m.quit = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
|
||||
default:
|
||||
if m.ctx.Err() != nil {
|
||||
m.quit = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
func (m infiniteProgressModel) View() string {
|
||||
var messagesBuilder strings.Builder
|
||||
for i, msg := range m.messages {
|
||||
if len(msg) > 0 {
|
||||
messagesBuilder.WriteString(msg)
|
||||
if i < len(m.messages)-1 {
|
||||
messagesBuilder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var finalBuilder strings.Builder
|
||||
if messagesBuilder.Len() > 0 {
|
||||
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
|
||||
finalBuilder.WriteString("\n")
|
||||
}
|
||||
|
||||
if m.quit {
|
||||
finalBuilder.WriteString("\n ")
|
||||
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
||||
finalBuilder.WriteString(" ")
|
||||
finalBuilder.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
|
||||
finalBuilder.WriteString(" ")
|
||||
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||
finalBuilder.WriteString("\n\n")
|
||||
} else {
|
||||
finalBuilder.WriteString("\n ")
|
||||
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
||||
finalBuilder.WriteString(" ")
|
||||
finalBuilder.WriteString(m.spinner.View())
|
||||
finalBuilder.WriteString(" ")
|
||||
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||
finalBuilder.WriteString("\n\n ")
|
||||
if m.cancelling {
|
||||
finalBuilder.WriteString(helpStyle.Render("Stopping..."))
|
||||
} else {
|
||||
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
|
||||
}
|
||||
}
|
||||
return finalBuilder.String()
|
||||
}
|
||||
|
||||
func (q sarin) streamProgress(
|
||||
ctx context.Context,
|
||||
cancel context.CancelFunc,
|
||||
done chan<- struct{},
|
||||
total uint64,
|
||||
counter *atomic.Uint64,
|
||||
messageChannel <-chan runtimeMessage,
|
||||
) {
|
||||
var program *tea.Program
|
||||
if total > 0 {
|
||||
model := progressModel{
|
||||
progress: progress.New(progress.WithGradient("#151594", "#00D4FF")),
|
||||
startTime: time.Now(),
|
||||
messages: make([]string, 8),
|
||||
counter: counter,
|
||||
current: 0,
|
||||
maxValue: total,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
program = tea.NewProgram(model)
|
||||
} else {
|
||||
model := infiniteProgressModel{
|
||||
spinner: spinner.New(
|
||||
spinner.WithSpinner(
|
||||
spinner.Spinner{
|
||||
Frames: []string{
|
||||
"●∙∙∙∙",
|
||||
"∙●∙∙∙",
|
||||
"∙∙●∙∙",
|
||||
"∙∙∙●∙",
|
||||
"∙∙∙∙●",
|
||||
"∙∙∙●∙",
|
||||
"∙∙●∙∙",
|
||||
"∙●∙∙∙",
|
||||
},
|
||||
FPS: time.Second / 8, //nolint:mnd
|
||||
},
|
||||
),
|
||||
spinner.WithStyle(infiniteProgressStyle),
|
||||
),
|
||||
startTime: time.Now(),
|
||||
counter: counter,
|
||||
messages: make([]string, 8),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
quit: false,
|
||||
}
|
||||
|
||||
program = tea.NewProgram(model)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for msg := range messageChannel {
|
||||
program.Send(msg)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := program.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
done <- struct{}{}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
const forceExitCode = 130
|
||||
|
||||
// StopController coordinates a two-stage shutdown.
|
||||
//
|
||||
// The first Stop call cancels the supplied context so workers and the job
|
||||
// loop can drain. The second Stop call restores the terminal (if a bubbletea
|
||||
// program has been attached) and calls os.Exit(forceExitCode), bypassing any
|
||||
// in-flight captcha polls, Lua/JS scripts, or HTTP requests that would
|
||||
// otherwise keep the process alive.
|
||||
type StopController struct {
|
||||
count atomic.Int32
|
||||
cancel func()
|
||||
mu sync.Mutex
|
||||
program *tea.Program
|
||||
}
|
||||
|
||||
func NewStopController(cancel func()) *StopController {
|
||||
return &StopController{cancel: cancel}
|
||||
}
|
||||
|
||||
// AttachProgram registers the active bubbletea program so the terminal state
|
||||
// can be restored before os.Exit on the forced shutdown path. Pass nil to
|
||||
// detach once the program has finished.
|
||||
func (s *StopController) AttachProgram(program *tea.Program) {
|
||||
s.mu.Lock()
|
||||
s.program = program
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *StopController) Stop() {
|
||||
switch s.count.Add(1) {
|
||||
case 1:
|
||||
s.cancel()
|
||||
case 2:
|
||||
s.mu.Lock()
|
||||
p := s.program
|
||||
s.mu.Unlock()
|
||||
if p != nil {
|
||||
_ = p.ReleaseTerminal()
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "killing...")
|
||||
os.Exit(forceExitCode)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
@@ -85,6 +86,38 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"slice_Uint": func(values ...uint) []uint { return values },
|
||||
"slice_Join": strings.Join,
|
||||
|
||||
// JSON
|
||||
// json_Encode marshals any value to a JSON string.
|
||||
// Usage: {{ json_Encode (dict_Str "key" "value") }}
|
||||
"json_Encode": func(v any) (string, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", types.NewJSONEncodeError(err)
|
||||
}
|
||||
return string(data), nil
|
||||
},
|
||||
// json_Object builds a JSON object from interleaved key-value pairs and returns it
|
||||
// as a JSON string. Keys must be strings; values may be any JSON-encodable type.
|
||||
// Usage: {{ json_Object "name" "Alice" "age" 30 }}
|
||||
"json_Object": func(pairs ...any) (string, error) {
|
||||
if len(pairs)%2 != 0 {
|
||||
return "", types.ErrJSONObjectOddArgs
|
||||
}
|
||||
obj := make(map[string]any, len(pairs)/2)
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
key, ok := pairs[i].(string)
|
||||
if !ok {
|
||||
return "", types.NewJSONObjectKeyError(i, pairs[i])
|
||||
}
|
||||
obj[key] = pairs[i+1]
|
||||
}
|
||||
data, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return "", types.NewJSONEncodeError(err)
|
||||
}
|
||||
return string(data), nil
|
||||
},
|
||||
|
||||
// Time
|
||||
"time_NowUnix": func() int64 { return time.Now().Unix() },
|
||||
"time_NowUnixMilli": func() int64 { return time.Now().UnixMilli() },
|
||||
@@ -574,8 +607,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
|
||||
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
|
||||
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
|
||||
// "fakeit_ErrorInput": func() string { return fakeit.ErrorInput().Error() },
|
||||
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() },
|
||||
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() },
|
||||
|
||||
// Fakeit / School
|
||||
"fakeit_School": fakeit.School,
|
||||
@@ -585,6 +617,55 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"fakeit_SongName": fakeit.SongName,
|
||||
"fakeit_SongArtist": fakeit.SongArtist,
|
||||
"fakeit_SongGenre": fakeit.SongGenre,
|
||||
|
||||
// Captcha / 2Captcha
|
||||
// Usage: {{ twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||
"twocaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
|
||||
return twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
|
||||
},
|
||||
// Usage: {{ twocaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
|
||||
"twocaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
|
||||
return twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
|
||||
},
|
||||
// Usage: {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||
// {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
|
||||
"twocaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
|
||||
return twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
|
||||
},
|
||||
|
||||
// Captcha / Anti-Captcha
|
||||
// Usage: {{ anticaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||
"anticaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
|
||||
return antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
|
||||
},
|
||||
// Usage: {{ anticaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
|
||||
"anticaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
|
||||
return antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
|
||||
},
|
||||
// Usage: {{ anticaptcha_HCaptcha "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||
"anticaptcha_HCaptcha": func(apiKey, websiteKey, websiteURL string) (string, error) {
|
||||
return antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey)
|
||||
},
|
||||
// Usage: {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||
// {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
|
||||
"anticaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
|
||||
return antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
|
||||
},
|
||||
|
||||
// Captcha / CapSolver
|
||||
// Usage: {{ capsolver_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||
"capsolver_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
|
||||
return capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
|
||||
},
|
||||
// Usage: {{ capsolver_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
|
||||
"capsolver_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
|
||||
return capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
|
||||
},
|
||||
// Usage: {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||
// {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
|
||||
"capsolver_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
|
||||
return capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/progress"
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
// logBatch carries the log lines accumulated since the last repaint.
|
||||
type logBatch []runtimeLog
|
||||
|
||||
const (
|
||||
// logBoxLines is how many lines the log box shows at once.
|
||||
logBoxLines = 8
|
||||
// logBatchInterval is how often accumulated logs are handed to the program.
|
||||
logBatchInterval = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
|
||||
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
|
||||
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5BC0FC")).Bold(true)
|
||||
|
||||
errorLabel = errorStyle.Render("ERROR: ")
|
||||
infoLabel = infoStyle.Render("INFO: ")
|
||||
|
||||
logChannelStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder(), false, false, false, true).
|
||||
BorderForeground(lipgloss.Color("#757575")).
|
||||
PaddingLeft(1).
|
||||
Margin(1, 0, 0, 0).
|
||||
Foreground(lipgloss.Color("#888888"))
|
||||
)
|
||||
|
||||
// renderRuntimeLog builds a single styled log line for the TUI log box.
|
||||
func renderRuntimeLog(log runtimeLog) string {
|
||||
label := errorLabel
|
||||
if log.level == runtimeLogLevelInfo {
|
||||
label = infoLabel
|
||||
}
|
||||
return "[" + log.timestamp.Format("15:04:05") + "] " + label + log.text
|
||||
}
|
||||
|
||||
// renderLogBox renders the styled log box, or "" when there are no lines.
|
||||
func renderLogBox(logs []string) string {
|
||||
var b strings.Builder
|
||||
for i, line := range logs {
|
||||
if len(line) > 0 {
|
||||
b.WriteString(line)
|
||||
if i < len(logs)-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
return logChannelStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func helpLine(cancelling bool) string {
|
||||
if cancelling {
|
||||
return helpStyle.Render("Stopping... (Ctrl+C again to force)")
|
||||
}
|
||||
return helpStyle.Render("Press Ctrl+C to quit")
|
||||
}
|
||||
|
||||
type progressModel struct {
|
||||
progress progress.Model
|
||||
startTime time.Time
|
||||
logs []string
|
||||
counter *atomic.Uint64
|
||||
maxValue uint64
|
||||
showBar bool
|
||||
ctx context.Context //nolint:containedctx
|
||||
stop func()
|
||||
cancelling bool
|
||||
}
|
||||
|
||||
func (m progressModel) Init() tea.Cmd {
|
||||
return tea.Batch(progressTickCmd())
|
||||
}
|
||||
|
||||
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
m.cancelling = true
|
||||
m.stop()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.progress.SetWidth(max(10, msg.Width-1))
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case logBatch:
|
||||
for _, entry := range msg {
|
||||
m.logs = append(m.logs[1:], renderRuntimeLog(entry))
|
||||
}
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, progressTickCmd()
|
||||
|
||||
default:
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m progressModel) View() tea.View {
|
||||
var b strings.Builder
|
||||
if box := renderLogBox(m.logs); box != "" {
|
||||
b.WriteString(box)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if m.showBar {
|
||||
current := m.counter.Load()
|
||||
b.WriteString("\n ")
|
||||
b.WriteString(strconv.FormatUint(current, 10))
|
||||
b.WriteString("/")
|
||||
b.WriteString(strconv.FormatUint(m.maxValue, 10))
|
||||
b.WriteString(" - ")
|
||||
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||
b.WriteString("\n ")
|
||||
b.WriteString(m.progress.ViewAs(float64(current) / float64(m.maxValue)))
|
||||
}
|
||||
|
||||
b.WriteString("\n\n ")
|
||||
b.WriteString(helpLine(m.cancelling))
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
|
||||
func progressTickCmd() tea.Cmd {
|
||||
return tea.Tick(time.Millisecond*250, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
var infiniteProgressStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00D4FF"))
|
||||
|
||||
type infiniteProgressModel struct {
|
||||
spinner spinner.Model
|
||||
startTime time.Time
|
||||
counter *atomic.Uint64
|
||||
logs []string
|
||||
showBar bool
|
||||
ctx context.Context //nolint:containedctx
|
||||
quit bool
|
||||
stop func()
|
||||
cancelling bool
|
||||
}
|
||||
|
||||
func (m infiniteProgressModel) Init() tea.Cmd {
|
||||
return m.spinner.Tick
|
||||
}
|
||||
|
||||
func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
m.cancelling = true
|
||||
m.stop()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case logBatch:
|
||||
for _, entry := range msg {
|
||||
m.logs = append(m.logs[1:], renderRuntimeLog(entry))
|
||||
}
|
||||
if m.ctx.Err() != nil {
|
||||
m.quit = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
|
||||
default:
|
||||
if m.ctx.Err() != nil {
|
||||
m.quit = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
func (m infiniteProgressModel) View() tea.View {
|
||||
var b strings.Builder
|
||||
if box := renderLogBox(m.logs); box != "" {
|
||||
b.WriteString(box)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Without a spinner, the view is just the log box (plus help until quit).
|
||||
if !m.showBar {
|
||||
if !m.quit {
|
||||
b.WriteString("\n\n ")
|
||||
b.WriteString(helpLine(m.cancelling))
|
||||
}
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
|
||||
if m.quit {
|
||||
b.WriteString("\n ")
|
||||
b.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||
b.WriteString("\n\n")
|
||||
} else {
|
||||
b.WriteString("\n ")
|
||||
b.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(m.spinner.View())
|
||||
b.WriteString(" ")
|
||||
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||
b.WriteString("\n\n ")
|
||||
b.WriteString(helpLine(m.cancelling))
|
||||
}
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
|
||||
func (s sarin) streamProgress(
|
||||
ctx context.Context,
|
||||
stopCtrl *StopController,
|
||||
done chan<- struct{},
|
||||
total uint64,
|
||||
counter *atomic.Uint64,
|
||||
logChannel <-chan runtimeLog,
|
||||
showBar bool,
|
||||
) {
|
||||
var program *tea.Program
|
||||
if total > 0 {
|
||||
model := progressModel{
|
||||
progress: progress.New(
|
||||
progress.WithColors(lipgloss.Color("#151594"), lipgloss.Color("#00D4FF")),
|
||||
progress.WithFillCharacters(progress.DefaultFullCharFullBlock, progress.DefaultEmptyCharBlock),
|
||||
),
|
||||
startTime: time.Now(),
|
||||
logs: make([]string, logBoxLines),
|
||||
counter: counter,
|
||||
maxValue: total,
|
||||
showBar: showBar,
|
||||
ctx: ctx,
|
||||
stop: stopCtrl.Stop,
|
||||
}
|
||||
|
||||
program = tea.NewProgram(model)
|
||||
} else {
|
||||
model := infiniteProgressModel{
|
||||
spinner: spinner.New(
|
||||
spinner.WithSpinner(
|
||||
spinner.Spinner{
|
||||
Frames: []string{
|
||||
"●∙∙∙∙",
|
||||
"∙●∙∙∙",
|
||||
"∙∙●∙∙",
|
||||
"∙∙∙●∙",
|
||||
"∙∙∙∙●",
|
||||
"∙∙∙●∙",
|
||||
"∙∙●∙∙",
|
||||
"∙●∙∙∙",
|
||||
},
|
||||
FPS: time.Second / 8, //nolint:mnd
|
||||
},
|
||||
),
|
||||
spinner.WithStyle(infiniteProgressStyle),
|
||||
),
|
||||
startTime: time.Now(),
|
||||
counter: counter,
|
||||
logs: make([]string, logBoxLines),
|
||||
showBar: showBar,
|
||||
ctx: ctx,
|
||||
stop: stopCtrl.Stop,
|
||||
quit: false,
|
||||
}
|
||||
|
||||
program = tea.NewProgram(model)
|
||||
}
|
||||
|
||||
stopCtrl.AttachProgram(program)
|
||||
defer stopCtrl.AttachProgram(nil)
|
||||
|
||||
// Bubble Tea repaints once per message, so forwarding every log made the run
|
||||
// wait on the terminal. Send only what the box shows, on a fixed cadence.
|
||||
go func() {
|
||||
ticker := time.NewTicker(logBatchInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
pending := make(logBatch, 0, logBoxLines)
|
||||
flush := func() {
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
program.Send(slices.Clone(pending))
|
||||
pending = pending[:0]
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case entry, ok := <-logChannel:
|
||||
if !ok {
|
||||
flush()
|
||||
return
|
||||
}
|
||||
if len(pending) == logBoxLines {
|
||||
pending = append(pending[:0], pending[1:]...)
|
||||
}
|
||||
pending = append(pending, entry)
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := program.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
done <- struct{}{}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
"go.aykhans.me/sarin/internal/script"
|
||||
)
|
||||
|
||||
const dryRunResponseKey = "dry-run"
|
||||
|
||||
// statusCodeStrings contains pre-computed string representations for HTTP status codes 100-599.
|
||||
var statusCodeStrings = func() [500]string {
|
||||
var codes [500]string
|
||||
for i := range codes {
|
||||
codes[i] = strconv.Itoa(i + 100)
|
||||
}
|
||||
return codes
|
||||
}()
|
||||
|
||||
// statusCodeToString returns a string representation of the HTTP status code.
|
||||
// Uses a pre-computed table for codes 100-599, falls back to strconv.Itoa for others.
|
||||
func statusCodeToString(code int) string {
|
||||
if i := code - 100; i >= 0 && i < len(statusCodeStrings) {
|
||||
return statusCodeStrings[i]
|
||||
}
|
||||
return strconv.Itoa(code)
|
||||
}
|
||||
|
||||
func (s sarin) Worker(
|
||||
jobs <-chan struct{},
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
) {
|
||||
req := fasthttp.AcquireRequest()
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
// Create script transformer for this worker (engines are not thread-safe)
|
||||
// Scripts are pre-validated in NewSarin, so this should not fail
|
||||
var scriptTransformer *script.Transformer
|
||||
if !s.scriptChain.IsEmpty() {
|
||||
var err error
|
||||
scriptTransformer, err = s.scriptChain.NewTransformer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer scriptTransformer.Close()
|
||||
}
|
||||
|
||||
requestGenerator, isDynamic := NewRequestGenerator(
|
||||
s.methods, s.requestURL, s.params, s.headers, s.cookies, s.bodies, s.values, s.fileCache, scriptTransformer,
|
||||
)
|
||||
|
||||
if s.dryRun {
|
||||
switch {
|
||||
case s.collectStats && isDynamic:
|
||||
s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
|
||||
case s.collectStats && !isDynamic:
|
||||
s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
||||
case !s.collectStats && isDynamic:
|
||||
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
|
||||
default:
|
||||
s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
case s.collectStats && isDynamic:
|
||||
s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
||||
case s.collectStats && !isDynamic:
|
||||
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
||||
case !s.collectStats && isDynamic:
|
||||
s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
||||
default:
|
||||
s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) workerStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
|
||||
if err := requestGenerator(req); err != nil {
|
||||
s.responses.Add(err.Error(), 0)
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||
respDuration := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
s.responses.Add(err.Error(), respDuration)
|
||||
} else {
|
||||
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
||||
sendRespLog(respDuration, resp)
|
||||
}
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) workerStatsWithStatic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
// Static request generation failed - record all jobs as errors
|
||||
for range jobs {
|
||||
s.responses.Add(err.Error(), 0)
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||
respDuration := time.Since(startTime)
|
||||
if err != nil {
|
||||
s.responses.Add(err.Error(), respDuration)
|
||||
} else {
|
||||
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
||||
sendRespLog(respDuration, resp)
|
||||
}
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) workerNoStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||
if err == nil {
|
||||
sendRespLog(time.Since(startTime), resp)
|
||||
}
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) workerNoStatsWithStatic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
|
||||
// Static request generation failed - just count the jobs without sending
|
||||
for range jobs {
|
||||
counter.Add(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||
if err == nil {
|
||||
sendRespLog(time.Since(startTime), resp)
|
||||
}
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) workerDryRunStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
startTime := time.Now()
|
||||
if err := requestGenerator(req); err != nil {
|
||||
s.responses.Add(err.Error(), time.Since(startTime))
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
s.responses.Add(dryRunResponseKey, time.Since(startTime))
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) workerDryRunStatsWithStatic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
// Static request generation failed - record all jobs as errors
|
||||
for range jobs {
|
||||
s.responses.Add(err.Error(), 0)
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
s.responses.Add(dryRunResponseKey, 0)
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) workerDryRunNoStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
}
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) workerDryRunNoStatsWithStatic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
@@ -208,8 +208,41 @@ func (e URLParseError) Unwrap() error {
|
||||
var (
|
||||
ErrFileCacheNotInitialized = errors.New("file cache is not initialized")
|
||||
ErrFormDataOddArgs = errors.New("body_FormData requires an even number of arguments (key-value pairs)")
|
||||
ErrJSONObjectOddArgs = errors.New("json_Object requires an even number of arguments (key-value pairs)")
|
||||
)
|
||||
|
||||
type JSONObjectKeyError struct {
|
||||
Index int
|
||||
Value any
|
||||
}
|
||||
|
||||
func NewJSONObjectKeyError(index int, value any) JSONObjectKeyError {
|
||||
return JSONObjectKeyError{Index: index, Value: value}
|
||||
}
|
||||
|
||||
func (e JSONObjectKeyError) Error() string {
|
||||
return fmt.Sprintf("json_Object key at index %d must be a string, got %T", e.Index, e.Value)
|
||||
}
|
||||
|
||||
type JSONEncodeError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func NewJSONEncodeError(err error) JSONEncodeError {
|
||||
if err == nil {
|
||||
err = errNoError
|
||||
}
|
||||
return JSONEncodeError{Err: err}
|
||||
}
|
||||
|
||||
func (e JSONEncodeError) Error() string {
|
||||
return "json_Encode failed: " + e.Err.Error()
|
||||
}
|
||||
|
||||
func (e JSONEncodeError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type TemplateParseError struct {
|
||||
Err error
|
||||
}
|
||||
@@ -442,3 +475,91 @@ func NewScriptUnknownEngineError(engineType string) ScriptUnknownEngineError {
|
||||
func (e ScriptUnknownEngineError) Error() string {
|
||||
return "unknown engine type: " + e.EngineType
|
||||
}
|
||||
|
||||
// ======================================== Captcha ========================================
|
||||
|
||||
var (
|
||||
ErrCaptchaKeyEmpty = errors.New("captcha API key cannot be empty")
|
||||
// ErrCaptchaProcessing is an internal sentinel returned by the captcha solver polling
|
||||
// code to signal that a task is not yet solved and polling should continue.
|
||||
// It should never be surfaced to callers outside of the captcha poll loop.
|
||||
ErrCaptchaProcessing = errors.New("captcha task still processing")
|
||||
)
|
||||
|
||||
type CaptchaAPIError struct {
|
||||
Endpoint string
|
||||
Code string
|
||||
Description string
|
||||
}
|
||||
|
||||
func NewCaptchaAPIError(endpoint, code, description string) CaptchaAPIError {
|
||||
return CaptchaAPIError{Endpoint: endpoint, Code: code, Description: description}
|
||||
}
|
||||
|
||||
func (e CaptchaAPIError) Error() string {
|
||||
return fmt.Sprintf("captcha %s error: %s (%s)", e.Endpoint, e.Code, e.Description)
|
||||
}
|
||||
|
||||
type CaptchaRequestError struct {
|
||||
Endpoint string
|
||||
Err error
|
||||
}
|
||||
|
||||
func NewCaptchaRequestError(endpoint string, err error) CaptchaRequestError {
|
||||
if err == nil {
|
||||
err = errNoError
|
||||
}
|
||||
return CaptchaRequestError{Endpoint: endpoint, Err: err}
|
||||
}
|
||||
|
||||
func (e CaptchaRequestError) Error() string {
|
||||
return fmt.Sprintf("captcha %s request failed: %v", e.Endpoint, e.Err)
|
||||
}
|
||||
|
||||
func (e CaptchaRequestError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type CaptchaDecodeError struct {
|
||||
Endpoint string
|
||||
Err error
|
||||
}
|
||||
|
||||
func NewCaptchaDecodeError(endpoint string, err error) CaptchaDecodeError {
|
||||
if err == nil {
|
||||
err = errNoError
|
||||
}
|
||||
return CaptchaDecodeError{Endpoint: endpoint, Err: err}
|
||||
}
|
||||
|
||||
func (e CaptchaDecodeError) Error() string {
|
||||
return fmt.Sprintf("captcha %s decode failed: %v", e.Endpoint, e.Err)
|
||||
}
|
||||
|
||||
func (e CaptchaDecodeError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type CaptchaPollTimeoutError struct {
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func NewCaptchaPollTimeoutError(taskID string) CaptchaPollTimeoutError {
|
||||
return CaptchaPollTimeoutError{TaskID: taskID}
|
||||
}
|
||||
|
||||
func (e CaptchaPollTimeoutError) Error() string {
|
||||
return fmt.Sprintf("captcha solving timed out (taskId: %s)", e.TaskID)
|
||||
}
|
||||
|
||||
type CaptchaSolutionKeyError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func NewCaptchaSolutionKeyError(key string) CaptchaSolutionKeyError {
|
||||
return CaptchaSolutionKeyError{Key: key}
|
||||
}
|
||||
|
||||
func (e CaptchaSolutionKeyError) Error() string {
|
||||
return fmt.Sprintf("captcha solution missing expected key %q", e.Key)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{ lib
|
||||
, buildGoModule
|
||||
, go_1_26
|
||||
, rev ? "unknown"
|
||||
, buildDate ? "unknown"
|
||||
}:
|
||||
|
||||
(buildGoModule.override { go = go_1_26; }) (finalAttrs: {
|
||||
pname = "sarin";
|
||||
version = "1.4.2"; # bump per release
|
||||
|
||||
src = lib.cleanSource ../.;
|
||||
|
||||
vendorHash = "sha256-bxIJoAsrzXMeTyjIGKL0GgVddJFpieuMRsDihWw+EkM=";
|
||||
|
||||
subPackages = [ "cmd/cli" ];
|
||||
|
||||
env.CGO_ENABLED = 0; # fully static binary
|
||||
|
||||
ldflags = [
|
||||
"-s"
|
||||
"-w"
|
||||
"-X=go.aykhans.me/sarin/internal/version.Version=v${finalAttrs.version}"
|
||||
"-X=go.aykhans.me/sarin/internal/version.GitCommit=${rev}"
|
||||
"-X=go.aykhans.me/sarin/internal/version.BuildDate=${buildDate}"
|
||||
];
|
||||
|
||||
preBuild = ''
|
||||
ldflags+=("-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)'")
|
||||
'';
|
||||
|
||||
# cmd/cli produces a binary named "cli"; rename it to "sarin".
|
||||
postInstall = ''
|
||||
mv $out/bin/cli $out/bin/sarin
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "High-performance HTTP load testing tool built with Go and fasthttp";
|
||||
homepage = "https://github.com/aykhans/sarin";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "sarin";
|
||||
maintainers = [ ];
|
||||
};
|
||||
})
|
||||
Reference in New Issue
Block a user