mirror of
https://github.com/aykhans/sarin.git
synced 2026-04-18 13:49:37 +00:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f24aea819f | |||
| e0db4df17a | |||
| 475dda98ff | |||
| dcb0e3171f | |||
| 2eebac68c9 | |||
| e62fd33f9c | |||
| e9b9b8890c | |||
| 8577c771e4 | |||
| c839b71c9e | |||
| cea692cf1b | |||
| 88f5171132 | |||
| 16b0081d3e | |||
| 1bd58a02b7 | |||
| 006029aad1 | |||
| 0e0ef72778 | |||
|
|
8d10198f02 | ||
| cf3c8f4cde | |||
|
|
65ef05f960 | ||
| 14280f4e43 | |||
|
|
c95b06b1ad | ||
| 88d6a0132e | |||
| e33c549f6d | |||
| 4a4feb4570 | |||
| 304fb160f8 | |||
| 44c35e6681 | |||
| 9215fd8767 | |||
|
|
8879a59159 | ||
| 705f6263fe | |||
| 9c5b998cda | |||
| 026d05f1bf | |||
| 844f139a10 | |||
|
|
d767ac6f37 | ||
| c299fda79d | |||
| 1f06b43b06 | |||
| e031c8e7a5 | |||
|
|
de24f9d4a4 | ||
| d197e90103 | |||
| ae054bb3d6 | |||
| 61af28a3d3 | |||
| 665be5d98a | |||
| d346067e8a | |||
| a3e20cd3d3 | |||
| 6d921cf8e3 | |||
|
|
d8b0a1e6a3 | ||
| b21d97192c | |||
| f0606a0f82 | |||
| 3be8ff218c | |||
| 7cb49195f8 | |||
|
|
a154215495 | ||
| c1584eb47b | |||
| 6a713ef241 | |||
| 6dafc082ed | |||
| e83eacf380 | |||
| c2ba1844ab | |||
|
|
054e5fd253 | ||
| 533ced4b54 | |||
| c3ea3a34ad | |||
|
|
c02a079d2a | ||
|
|
f78942bfb6 |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,2 +1 @@
|
|||||||
buy_me_a_coffee: aykhan
|
buy_me_a_coffee: aykhan
|
||||||
custom: https://commerce.coinbase.com/checkout/0f33d2fb-54a6-44f5-8783-006ebf70d1a0
|
|
||||||
|
|||||||
8
.github/workflows/lint.yaml
vendored
8
.github/workflows/lint.yaml
vendored
@@ -16,8 +16,12 @@ jobs:
|
|||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: 1.25.5
|
go-version: 1.26.2
|
||||||
|
- name: go fix
|
||||||
|
run: |
|
||||||
|
go fix ./...
|
||||||
|
git diff --exit-code
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.7.2
|
version: v2.11.4
|
||||||
|
|||||||
14
.github/workflows/release.yaml
vendored
14
.github/workflows/release.yaml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
|
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
|
||||||
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||||
echo "GO_VERSION=1.25.5" >> $GITHUB_ENV
|
echo "GO_VERSION=1.26.2" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
if: github.event_name == 'release' || inputs.build_binaries
|
if: github.event_name == 'release' || inputs.build_binaries
|
||||||
@@ -53,12 +53,12 @@ jobs:
|
|||||||
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)' \
|
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)' \
|
||||||
-s -w"
|
-s -w"
|
||||||
|
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./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 GOEXPERIMENT=greenteagc GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./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 GOEXPERIMENT=greenteagc GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./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 GOEXPERIMENT=greenteagc GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-arm64.exe ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-arm64.exe ./cmd/cli/main.go
|
||||||
|
|
||||||
- name: Upload Release Assets
|
- name: Upload Release Assets
|
||||||
if: github.event_name == 'release' || inputs.build_binaries
|
if: github.event_name == 'release' || inputs.build_binaries
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go: "1.25"
|
go: "1.26"
|
||||||
concurrency: 12
|
concurrency: 12
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
ARG GO_VERSION=1.25.5
|
ARG GO_VERSION=1.26.2
|
||||||
|
|
||||||
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
|
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ RUN --mount=type=bind,source=./go.mod,target=./go.mod \
|
|||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
RUN --mount=type=bind,source=./,target=./ \
|
RUN --mount=type=bind,source=./,target=./ \
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build \
|
CGO_ENABLED=0 go build \
|
||||||
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=${VERSION}' \
|
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=${VERSION}' \
|
||||||
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${GIT_COMMIT}' \
|
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${GIT_COMMIT}' \
|
||||||
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
|
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
|
## 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://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||

|

|
||||||
@@ -16,15 +20,17 @@
|
|||||||
|
|
||||||
## Overview
|
## 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 |
|
| ✅ Supported | ❌ Not Supported |
|
||||||
| ---------------------------------------------------------- | --------------------------------- |
|
| ---------------------------------------------------------- | ------------------------------- |
|
||||||
| High-performance with low memory footprint | Detailed response body analysis |
|
| High-performance with low memory footprint | Detailed response body analysis |
|
||||||
| Long-running duration/count based tests | Extensive response statistics |
|
| Long-running duration/count based tests | Extensive response statistics |
|
||||||
| Dynamic requests via 320+ template functions | Web UI or complex TUI |
|
| Dynamic requests via 340+ template functions | Web UI or complex TUI |
|
||||||
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | Scripting or multi-step scenarios |
|
| Request scripting with Lua and JavaScript | Distributed load testing |
|
||||||
| Flexible config (CLI, ENV, YAML) | HTTP/2, HTTP/3, WebSocket, gRPC |
|
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
|
||||||
|
| Captcha solving<br>(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem |
|
||||||
|
| Flexible config (CLI, ENV, YAML) | |
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -52,12 +58,12 @@ Download the latest binaries from the [releases](https://github.com/aykhans/sari
|
|||||||
|
|
||||||
### Building from Source
|
### Building from Source
|
||||||
|
|
||||||
Requires [Go 1.25+](https://golang.org/dl/).
|
Requires [Go 1.26+](https://golang.org/dl/).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/aykhans/sarin.git && cd sarin
|
git clone https://github.com/aykhans/sarin.git && cd sarin
|
||||||
|
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build \
|
CGO_ENABLED=0 go build \
|
||||||
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=dev' \
|
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=dev' \
|
||||||
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)' \
|
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)' \
|
||||||
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
|
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
|
||||||
@@ -100,7 +106,7 @@ For detailed documentation on all configuration options (URL, method, timeout, c
|
|||||||
|
|
||||||
## Templating
|
## 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:**
|
**Example:**
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ version: "3"
|
|||||||
|
|
||||||
vars:
|
vars:
|
||||||
BIN_DIR: ./bin
|
BIN_DIR: ./bin
|
||||||
GOLANGCI_LINT_VERSION: v2.7.2
|
GOLANGCI_LINT_VERSION: v2.11.4
|
||||||
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
@@ -11,16 +11,22 @@ tasks:
|
|||||||
desc: Run fmt, tidy, and lint.
|
desc: Run fmt, tidy, and lint.
|
||||||
cmds:
|
cmds:
|
||||||
- task: fmt
|
- task: fmt
|
||||||
|
- task: fix
|
||||||
- task: tidy
|
- task: tidy
|
||||||
- task: lint
|
- task: lint
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
desc: Run linters
|
desc: Run format
|
||||||
deps:
|
deps:
|
||||||
- install-golangci-lint
|
- install-golangci-lint
|
||||||
cmds:
|
cmds:
|
||||||
- "{{.GOLANGCI}} fmt"
|
- "{{.GOLANGCI}} fmt"
|
||||||
|
|
||||||
|
fix:
|
||||||
|
desc: Run go fix
|
||||||
|
cmds:
|
||||||
|
- go fix ./...
|
||||||
|
|
||||||
tidy:
|
tidy:
|
||||||
desc: Run go mod tidy.
|
desc: Run go mod tidy.
|
||||||
cmds:
|
cmds:
|
||||||
@@ -52,7 +58,7 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- rm -f {{.OUTPUT}}
|
- rm -f {{.OUTPUT}}
|
||||||
- >-
|
- >-
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build
|
CGO_ENABLED=0 go build
|
||||||
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=$(git describe --tags --always)'
|
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=$(git describe --tags --always)'
|
||||||
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)'
|
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)'
|
||||||
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)'
|
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)'
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
go listenForTermination(func() { cancel() })
|
stopCtrl := sarin.NewStopController(cancel)
|
||||||
|
go listenForTermination(stopCtrl.Stop)
|
||||||
|
|
||||||
combinedConfig := config.ReadAllConfigs()
|
combinedConfig := config.ReadAllConfigs()
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ func main() {
|
|||||||
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
|
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
|
||||||
*combinedConfig.Output != config.ConfigOutputTypeNone,
|
*combinedConfig.Output != config.ConfigOutputTypeNone,
|
||||||
*combinedConfig.DryRun,
|
*combinedConfig.DryRun,
|
||||||
|
combinedConfig.Lua, combinedConfig.Js,
|
||||||
)
|
)
|
||||||
_ = utilsErr.MustHandle(err,
|
_ = utilsErr.MustHandle(err,
|
||||||
utilsErr.OnType(func(err types.ProxyDialError) error {
|
utilsErr.OnType(func(err types.ProxyDialError) error {
|
||||||
@@ -60,9 +62,19 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
|
utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error {
|
||||||
|
fmt.Fprintln(os.Stderr, 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())
|
||||||
|
os.Exit(1)
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
srn.Start(ctx)
|
srn.Start(ctx, stopCtrl)
|
||||||
|
|
||||||
switch *combinedConfig.Output {
|
switch *combinedConfig.Output {
|
||||||
case config.ConfigOutputTypeNone:
|
case config.ConfigOutputTypeNone:
|
||||||
@@ -76,9 +88,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func listenForTermination(do func()) {
|
func listenForTermination(stop func()) {
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 4)
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-sigChan
|
for range sigChan {
|
||||||
do()
|
stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Configuration
|
# 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:
|
When the same option is specified in multiple sources, the following priority order applies:
|
||||||
|
|
||||||
@@ -36,6 +36,8 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
|
|||||||
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
|
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
|
||||||
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
|
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
|
||||||
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
|
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
|
||||||
|
| [Lua](#lua) | `lua`<br>(string / []string) | `-lua`<br>(string / []string) | `SARIN_LUA`<br>(string) | - | Lua script(s) |
|
||||||
|
| [Js](#js) | `js`<br>(string / []string) | `-js`<br>(string / []string) | `SARIN_JS`<br>(string) | - | JavaScript script(s) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,6 +105,12 @@ SARIN_CONFIG_FILE=/config1.yaml sarin -f /config2.yaml -f https://example.com/co
|
|||||||
|
|
||||||
If all four files define `url`, the value from `config3.yaml` wins.
|
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
|
||||||
|
|
||||||
## URL
|
## URL
|
||||||
|
|
||||||
Target URL. Must be HTTP or HTTPS. The URL path supports [templating](templating.md), allowing dynamic path generation per request.
|
Target URL. Must be HTTP or HTTPS. The URL path supports [templating](templating.md), allowing dynamic path generation per request.
|
||||||
@@ -225,26 +233,33 @@ SARIN_BODY='{"product": "car"}'
|
|||||||
|
|
||||||
## Params
|
## Params
|
||||||
|
|
||||||
URL query parameters. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
|
URL query parameters. Supports [templating](templating.md).
|
||||||
|
|
||||||
|
When the same key appears as **separate entries** (in CLI or config file), all values are sent in every request. When multiple values are specified as an **array on a single key** (config file only), Sarin cycles through them.
|
||||||
|
|
||||||
**YAML example:**
|
**YAML example:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
params:
|
params:
|
||||||
key1: value1
|
key1: value1
|
||||||
key2: [value2, value3]
|
key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# OR
|
# OR
|
||||||
|
|
||||||
params:
|
params:
|
||||||
- key1: value1
|
- key1: value1
|
||||||
- key2: [value2, value3]
|
- 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
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
-param "key1=value1" -param "key2=value2" -param "key2=value3"
|
-param "key1=value1" -param "key2=value2" -param "key2=value3" # sends both value2 and value3
|
||||||
```
|
```
|
||||||
|
|
||||||
**ENV example:**
|
**ENV example:**
|
||||||
@@ -255,26 +270,33 @@ SARIN_PARAM="key1=value1"
|
|||||||
|
|
||||||
## Headers
|
## Headers
|
||||||
|
|
||||||
HTTP headers. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
|
HTTP headers. Supports [templating](templating.md).
|
||||||
|
|
||||||
|
When the same key appears as **separate entries** (in CLI or config file), all values are sent in every request. When multiple values are specified as an **array on a single key** (config file only), Sarin cycles through them.
|
||||||
|
|
||||||
**YAML example:**
|
**YAML example:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
headers:
|
headers:
|
||||||
key1: value1
|
key1: value1
|
||||||
key2: [value2, value3]
|
key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# OR
|
# OR
|
||||||
|
|
||||||
headers:
|
headers:
|
||||||
- key1: value1
|
- key1: value1
|
||||||
- key2: [value2, value3]
|
- 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
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
-header "key1: value1" -header "key2: value2" -header "key2: value3"
|
-header "key1: value1" -header "key2: value2" -header "key2: value3" # sends both value2 and value3
|
||||||
```
|
```
|
||||||
|
|
||||||
**ENV example:**
|
**ENV example:**
|
||||||
@@ -285,26 +307,33 @@ SARIN_HEADER="key1: value1"
|
|||||||
|
|
||||||
## Cookies
|
## Cookies
|
||||||
|
|
||||||
HTTP cookies. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
|
HTTP cookies. Supports [templating](templating.md).
|
||||||
|
|
||||||
|
When the same key appears as **separate entries** (in CLI or config file), all values are sent in every request. When multiple values are specified as an **array on a single key** (config file only), Sarin cycles through them.
|
||||||
|
|
||||||
**YAML example:**
|
**YAML example:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cookies:
|
cookies:
|
||||||
key1: value1
|
key1: value1
|
||||||
key2: [value2, value3]
|
key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# OR
|
# OR
|
||||||
|
|
||||||
cookies:
|
cookies:
|
||||||
- key1: value1
|
- key1: value1
|
||||||
- key2: [value2, value3]
|
- 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
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
-cookie "key1=value1" -cookie "key2=value2" -cookie "key2=value3"
|
-cookie "key1=value1" -cookie "key2=value2" -cookie "key2=value3" # sends both value2 and value3
|
||||||
```
|
```
|
||||||
|
|
||||||
**ENV example:**
|
**ENV example:**
|
||||||
@@ -374,3 +403,133 @@ values: |
|
|||||||
```sh
|
```sh
|
||||||
SARIN_VALUES="key1=value1"
|
SARIN_VALUES="key1=value1"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Lua
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Script sources:**
|
||||||
|
|
||||||
|
Scripts can be provided as:
|
||||||
|
|
||||||
|
- **Inline script:** Direct script code
|
||||||
|
- **File reference:** `@/path/to/script.lua` or `@./relative/path.lua`
|
||||||
|
- **URL reference:** `@http://...` or `@https://...`
|
||||||
|
- **Escaped `@`:** `@@...` for inline scripts that start with a literal `@`
|
||||||
|
|
||||||
|
**The `transform` function:**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
function transform(req)
|
||||||
|
-- req.method (string) - HTTP method (e.g. "GET", "POST")
|
||||||
|
-- req.path (string) - URL path (e.g. "/api/users")
|
||||||
|
-- req.body (string) - Request body
|
||||||
|
-- req.headers (table of string/arrays) - HTTP headers (e.g. {["X-Key"] = "value"})
|
||||||
|
-- req.params (table of string/arrays) - Query parameters (e.g. {["id"] = "123"})
|
||||||
|
-- req.cookies (table of string/arrays) - Cookies (e.g. {["session"] = "abc"})
|
||||||
|
|
||||||
|
req.headers["X-Custom"] = "my-value"
|
||||||
|
return req
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`).
|
||||||
|
|
||||||
|
**YAML example:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
lua: |
|
||||||
|
function transform(req)
|
||||||
|
req.headers["X-Custom"] = "my-value"
|
||||||
|
return req
|
||||||
|
end
|
||||||
|
|
||||||
|
# OR
|
||||||
|
|
||||||
|
lua:
|
||||||
|
- "@/path/to/script1.lua"
|
||||||
|
- "@/path/to/script2.lua"
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI example:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
-lua 'function transform(req) req.headers["X-Custom"] = "my-value" return req end'
|
||||||
|
|
||||||
|
# OR
|
||||||
|
|
||||||
|
-lua @/path/to/script1.lua -lua @/path/to/script2.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
**ENV example:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SARIN_LUA='function transform(req) req.headers["X-Custom"] = "my-value" return req end'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Js
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Script sources:**
|
||||||
|
|
||||||
|
Scripts can be provided as:
|
||||||
|
|
||||||
|
- **Inline script:** Direct script code
|
||||||
|
- **File reference:** `@/path/to/script.js` or `@./relative/path.js`
|
||||||
|
- **URL reference:** `@http://...` or `@https://...`
|
||||||
|
- **Escaped `@`:** `@@...` for inline scripts that start with a literal `@`
|
||||||
|
|
||||||
|
**The `transform` function:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function transform(req) {
|
||||||
|
// req.method (string) - HTTP method (e.g. "GET", "POST")
|
||||||
|
// req.path (string) - URL path (e.g. "/api/users")
|
||||||
|
// req.body (string) - Request body
|
||||||
|
// req.headers (object of string/arrays) - HTTP headers (e.g. {"X-Key": "value"})
|
||||||
|
// req.params (object of string/arrays) - Query parameters (e.g. {"id": "123"})
|
||||||
|
// req.cookies (object of string/arrays) - Cookies (e.g. {"session": "abc"})
|
||||||
|
|
||||||
|
req.headers["X-Custom"] = "my-value";
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`).
|
||||||
|
|
||||||
|
**YAML example:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
js: |
|
||||||
|
function transform(req) {
|
||||||
|
req.headers["X-Custom"] = "my-value";
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
# OR
|
||||||
|
|
||||||
|
js:
|
||||||
|
- "@/path/to/script1.js"
|
||||||
|
- "@/path/to/script2.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI example:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
-js 'function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
|
||||||
|
|
||||||
|
# OR
|
||||||
|
|
||||||
|
-js @/path/to/script1.js -js @/path/to/script2.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**ENV example:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
|
||||||
|
```
|
||||||
|
|||||||
181
docs/examples.md
181
docs/examples.md
@@ -8,6 +8,7 @@ This guide provides practical examples for common Sarin use cases.
|
|||||||
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
|
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
|
||||||
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
|
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
|
||||||
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
|
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
|
||||||
|
- [Solving Captchas](#solving-captchas)
|
||||||
- [Request Bodies](#request-bodies)
|
- [Request Bodies](#request-bodies)
|
||||||
- [File Uploads](#file-uploads)
|
- [File Uploads](#file-uploads)
|
||||||
- [Using Proxies](#using-proxies)
|
- [Using Proxies](#using-proxies)
|
||||||
@@ -15,6 +16,7 @@ This guide provides practical examples for common Sarin use cases.
|
|||||||
- [Docker Usage](#docker-usage)
|
- [Docker Usage](#docker-usage)
|
||||||
- [Dry Run Mode](#dry-run-mode)
|
- [Dry Run Mode](#dry-run-mode)
|
||||||
- [Show Configuration](#show-configuration)
|
- [Show Configuration](#show-configuration)
|
||||||
|
- [Scripting](#scripting)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -133,20 +135,34 @@ headers:
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
**Random headers from multiple values:**
|
**Multiple values for the same header (all sent in every request):**
|
||||||
|
|
||||||
> **Note:** When multiple values are provided for the same header, Sarin starts at a random index and cycles through all values in order. Once the cycle completes, it picks a new random starting point. This ensures all values are used while maintaining some randomness.
|
> **Note:** When the same key appears as separate entries (in CLI or config file), all values are sent in every request.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sarin -U http://example.com -r 1000 -c 10 \
|
sarin -U http://example.com -r 1000 -c 10 \
|
||||||
-H "X-Region: us-east" \
|
-H "X-Region: us-east" \
|
||||||
-H "X-Region: us-west" \
|
-H "X-Region: us-west"
|
||||||
-H "X-Region: eu-central"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>YAML equivalent</summary>
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
headers:
|
||||||
|
- X-Region: us-east
|
||||||
|
- X-Region: us-west
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Cycling headers from multiple values (config file only):**
|
||||||
|
|
||||||
|
> **Note:** When multiple values are specified as an array on a single key, Sarin starts at a random index and cycles through all values in order. Once the cycle completes, it picks a new random starting point.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
url: http://example.com
|
url: http://example.com
|
||||||
requests: 1000
|
requests: 1000
|
||||||
@@ -158,8 +174,6 @@ headers:
|
|||||||
- eu-central
|
- eu-central
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
**Query parameters:**
|
**Query parameters:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -186,7 +200,7 @@ params:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
sarin -U http://example.com/users -r 1000 -c 10 \
|
sarin -U http://example.com/users -r 1000 -c 10 \
|
||||||
-P "id={{ fakeit_IntRange 1 1000 }}" \
|
-P "id={{ fakeit_Number 1 1000 }}" \
|
||||||
-P "fields=name,email"
|
-P "fields=name,email"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -198,7 +212,7 @@ url: http://example.com/users
|
|||||||
requests: 1000
|
requests: 1000
|
||||||
concurrency: 10
|
concurrency: 10
|
||||||
params:
|
params:
|
||||||
id: "{{ fakeit_IntRange 1 1000 }}"
|
id: "{{ fakeit_Number 1 1000 }}"
|
||||||
fields: "name,email"
|
fields: "name,email"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -358,7 +372,29 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act
|
|||||||
|
|
||||||
</details>
|
</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
|
## Request Bodies
|
||||||
|
|
||||||
@@ -823,19 +859,19 @@ quiet: true
|
|||||||
**Basic Docker usage:**
|
**Basic Docker usage:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm aykhans/sarin -U http://example.com -r 1000 -c 10
|
docker run -it --rm aykhans/sarin -U http://example.com -r 1000 -c 10
|
||||||
```
|
```
|
||||||
|
|
||||||
**With local config file:**
|
**With local config file:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm -v $(pwd)/config.yaml:/config.yaml aykhans/sarin -f /config.yaml
|
docker run -it --rm -v $(pwd)/config.yaml:/config.yaml aykhans/sarin -f /config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
**With remote config file:**
|
**With remote config file:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm aykhans/sarin -f https://example.com/config.yaml
|
docker run -it --rm aykhans/sarin -f https://example.com/config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
**Interactive mode with TTY:**
|
**Interactive mode with TTY:**
|
||||||
@@ -894,3 +930,124 @@ headers:
|
|||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## Scripting
|
||||||
|
|
||||||
|
Transform requests using Lua or JavaScript scripts. Scripts run after template rendering, before the request is sent.
|
||||||
|
|
||||||
|
**Add a custom header with Lua:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api -r 1000 -c 10 \
|
||||||
|
-lua 'function transform(req) req.headers["X-Custom"] = "my-value" return req end'
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
lua: |
|
||||||
|
function transform(req)
|
||||||
|
req.headers["X-Custom"] = "my-value"
|
||||||
|
return req
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Modify request body with JavaScript:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api/data -r 1000 -c 10 \
|
||||||
|
-M POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-B '{"name": "test"}' \
|
||||||
|
-js 'function transform(req) { var body = JSON.parse(req.body); body.timestamp = Date.now(); req.body = JSON.stringify(body); return req; }'
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api/data
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
method: POST
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json
|
||||||
|
body: '{"name": "test"}'
|
||||||
|
js: |
|
||||||
|
function transform(req) {
|
||||||
|
var body = JSON.parse(req.body);
|
||||||
|
body.timestamp = Date.now();
|
||||||
|
req.body = JSON.stringify(body);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Load script from a file:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api -r 1000 -c 10 \
|
||||||
|
-lua @./scripts/transform.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
lua: "@./scripts/transform.lua"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Load script from a URL:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api -r 1000 -c 10 \
|
||||||
|
-js @https://example.com/scripts/transform.js
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
js: "@https://example.com/scripts/transform.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Chain multiple scripts (Lua runs first, then JavaScript):**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api -r 1000 -c 10 \
|
||||||
|
-lua @./scripts/auth.lua \
|
||||||
|
-lua @./scripts/headers.lua \
|
||||||
|
-js @./scripts/body.js
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
lua:
|
||||||
|
- "@./scripts/auth.lua"
|
||||||
|
- "@./scripts/headers.lua"
|
||||||
|
js: "@./scripts/body.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
@@ -4,14 +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:** 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
|
## Table of Contents
|
||||||
|
|
||||||
- [Using Values](#using-values)
|
- [Using Values](#using-values)
|
||||||
- [General Functions](#general-functions)
|
- [General Functions](#general-functions)
|
||||||
- [String Functions](#string-functions)
|
- [String Functions](#string-functions)
|
||||||
- [Collection Functions](#collection-functions)
|
- [Collection Functions](#collection-functions)
|
||||||
|
- [JSON Functions](#json-functions)
|
||||||
|
- [Time Functions](#time-functions)
|
||||||
|
- [Crypto Functions](#crypto-functions)
|
||||||
- [Body Functions](#body-functions)
|
- [Body Functions](#body-functions)
|
||||||
- [File Functions](#file-functions)
|
- [File Functions](#file-functions)
|
||||||
|
- [Captcha Functions](#captcha-functions)
|
||||||
|
- [2Captcha](#2captcha)
|
||||||
|
- [Anti-Captcha](#anti-captcha)
|
||||||
|
- [CapSolver](#capsolver)
|
||||||
- [Fake Data Functions](#fake-data-functions)
|
- [Fake Data Functions](#fake-data-functions)
|
||||||
- [File](#file)
|
- [File](#file)
|
||||||
- [ID](#id)
|
- [ID](#id)
|
||||||
@@ -98,16 +107,61 @@ sarin -U http://example.com/users \
|
|||||||
| `strings_Truncate(s string, n int)` | Truncate to `n` characters with ellipsis | `{{ strings_Truncate "hello world" 5 }}` → `hello...` |
|
| `strings_Truncate(s string, n int)` | Truncate to `n` characters with ellipsis | `{{ strings_Truncate "hello world" 5 }}` → `hello...` |
|
||||||
| `strings_TrimPrefix(s string, prefix string)` | Remove prefix from string | `{{ strings_TrimPrefix "hello" "he" }}` → `llo` |
|
| `strings_TrimPrefix(s string, prefix string)` | Remove prefix from string | `{{ strings_TrimPrefix "hello" "he" }}` → `llo` |
|
||||||
| `strings_TrimSuffix(s string, suffix string)` | Remove suffix from string | `{{ strings_TrimSuffix "hello" "lo" }}` → `hel` |
|
| `strings_TrimSuffix(s string, suffix string)` | Remove suffix from string | `{{ strings_TrimSuffix "hello" "lo" }}` → `hel` |
|
||||||
| `strings_Join(sep string, values ...string)` | Join strings with separator | `{{ strings_Join "-" "a" "b" "c" }}` → `a-b-c` |
|
|
||||||
|
|
||||||
### Collection Functions
|
### Collection Functions
|
||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| ----------------------------- | --------------------------------------------- | -------------------------------------------- |
|
| ---------------------------------------- | --------------------------------------------- | -------------------------------------------------------- |
|
||||||
| `dict_Str(pairs ...string)` | Create string dictionary from key-value pairs | `{{ dict_Str "key1" "val1" "key2" "val2" }}` |
|
| `dict_Str(pairs ...string)` | Create string dictionary from key-value pairs | `{{ dict_Str "key1" "val1" "key2" "val2" }}` |
|
||||||
| `slice_Str(values ...string)` | Create string slice | `{{ slice_Str "a" "b" "c" }}` |
|
| `slice_Str(values ...string)` | Create string slice | `{{ slice_Str "a" "b" "c" }}` |
|
||||||
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` |
|
| `slice_Join(slice []string, sep string)` | Join string slice with separator | `{{ slice_Join (slice_Str "a" "b" "c") "-" }}` → `a-b-c` |
|
||||||
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` |
|
| `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 |
|
||||||
|
| ------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| `time_NowUnix` | Current Unix timestamp (seconds) | `{{ time_NowUnix }}` → `1735689600` |
|
||||||
|
| `time_NowUnixMilli` | Current Unix timestamp (milliseconds) | `{{ time_NowUnixMilli }}` → `1735689600123` |
|
||||||
|
| `time_NowRFC3339` | Current time in RFC3339 format | `{{ time_NowRFC3339 }}` → `"2026-02-26T21:00:00Z"` |
|
||||||
|
| `time_Format(layout, t)` | Format a `time.Time` value with a Go layout | `{{ time_Format "2006-01-02" (strings_ToDate "2024-05-10") }}` → `"2024-05-10"` |
|
||||||
|
|
||||||
|
### Crypto Functions
|
||||||
|
|
||||||
|
| Function | Description | Example |
|
||||||
|
| ------------------------------------ | ------------------------------------------ | -------------------------------------------- |
|
||||||
|
| `crypto_SHA256(s string)` | SHA-256 hash (hex-encoded) | `{{ crypto_SHA256 "hello" }}` |
|
||||||
|
| `crypto_MD5(s string)` | MD5 hash (hex-encoded) | `{{ crypto_MD5 "hello" }}` |
|
||||||
|
| `crypto_HMACSHA256(key, msg string)` | HMAC-SHA256 signature (hex-encoded) | `{{ crypto_HMACSHA256 "secret" "payload" }}` |
|
||||||
|
| `crypto_Base64URL(s string)` | Base64 URL-safe encoding (without padding) | `{{ crypto_Base64URL "hello world" }}` |
|
||||||
|
|
||||||
### Body Functions
|
### Body Functions
|
||||||
|
|
||||||
@@ -153,11 +207,18 @@ body: '{{ body_FormData "twitter" "@@username" }}'
|
|||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
| ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||||
|
| `file_Read(source string)` | Read a file (local path or URL) and return raw content as string. Files are cached after first read. | `{{ file_Read "/path/to/file.txt" }}` |
|
||||||
| `file_Base64(source string)` | Read a file (local path or URL) and return its Base64 encoded content. Files are cached after first read. | `{{ file_Base64 "/path/to/file.pdf" }}` |
|
| `file_Base64(source string)` | Read a file (local path or URL) and return its Base64 encoded content. Files are cached after first read. | `{{ file_Base64 "/path/to/file.pdf" }}` |
|
||||||
|
|
||||||
**`file_Base64` Details:**
|
**`file_Read` and `file_Base64` Details:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
# Local file as plain text
|
||||||
|
body: '{{ file_Read "/path/to/template.json" }}'
|
||||||
|
|
||||||
|
# Remote text file
|
||||||
|
body: '{{ file_Read "https://example.com/payload.txt" }}'
|
||||||
|
|
||||||
# Local file as Base64 in JSON body
|
# Local file as Base64 in JSON body
|
||||||
body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "document.pdf"}'
|
body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "document.pdf"}'
|
||||||
|
|
||||||
@@ -169,6 +230,95 @@ values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}"
|
|||||||
body: '{"data": "{{ .Values.FILE_DATA }}"}'
|
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
|
## Fake Data Functions
|
||||||
|
|
||||||
These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library.
|
These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library.
|
||||||
@@ -239,24 +389,24 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
|
|
||||||
### Address
|
### Address
|
||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| --------------------------------------------------- | ---------------------------- | --------------------------------------------------- |
|
| --------------------------------------------------- | ---------------------------- | ---------------------------------------------------- |
|
||||||
| `fakeit_City` | City name | `"Marcelside"` |
|
| `fakeit_City` | City name | `"Marcelside"` |
|
||||||
| `fakeit_Country` | Country name | `"United States of America"` |
|
| `fakeit_Country` | Country name | `"United States of America"` |
|
||||||
| `fakeit_CountryAbr` | Country abbreviation | `"US"` |
|
| `fakeit_CountryAbr` | Country abbreviation | `"US"` |
|
||||||
| `fakeit_State` | State name | `"Illinois"` |
|
| `fakeit_State` | State name | `"Illinois"` |
|
||||||
| `fakeit_StateAbr` | State abbreviation | `"IL"` |
|
| `fakeit_StateAbr` | State abbreviation | `"IL"` |
|
||||||
| `fakeit_Street` | Full street | `"364 East Rapidsborough"` |
|
| `fakeit_Street` | Full street | `"364 East Rapidsborough"` |
|
||||||
| `fakeit_StreetName` | Street name | `"View"` |
|
| `fakeit_StreetName` | Street name | `"View"` |
|
||||||
| `fakeit_StreetNumber` | Street number | `"13645"` |
|
| `fakeit_StreetNumber` | Street number | `"13645"` |
|
||||||
| `fakeit_StreetPrefix` | Street prefix | `"East"` |
|
| `fakeit_StreetPrefix` | Street prefix | `"East"` |
|
||||||
| `fakeit_StreetSuffix` | Street suffix | `"Ave"` |
|
| `fakeit_StreetSuffix` | Street suffix | `"Ave"` |
|
||||||
| `fakeit_Unit` | Unit | `"Apt 123"` |
|
| `fakeit_Unit` | Unit | `"Apt 123"` |
|
||||||
| `fakeit_Zip` | ZIP code | `"13645"` |
|
| `fakeit_Zip` | ZIP code | `"13645"` |
|
||||||
| `fakeit_Latitude` | Random latitude | `-73.534056` |
|
| `fakeit_Latitude` | Random latitude | `-73.534056` |
|
||||||
| `fakeit_Longitude` | Random longitude | `-147.068112` |
|
| `fakeit_Longitude` | Random longitude | `-147.068112` |
|
||||||
| `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}` → `22.921026` |
|
| `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}` → `22.921026` |
|
||||||
| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}` → `-8.170450` |
|
| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}` → `122.471830` |
|
||||||
|
|
||||||
### Game
|
### Game
|
||||||
|
|
||||||
@@ -343,16 +493,16 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
|
|
||||||
### Text
|
### Text
|
||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- |
|
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------- |
|
||||||
| `fakeit_Sentence` | Random sentence | `{{ fakeit_Sentence }}` |
|
| `fakeit_Sentence(wordCount ...int)` | Random sentence (optional word count) | `{{ fakeit_Sentence }}` or `{{ fakeit_Sentence 10 }}` |
|
||||||
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
|
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
|
||||||
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` |
|
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` |
|
||||||
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
|
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
|
||||||
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
|
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
|
||||||
| `fakeit_Question` | Random question | `"What is your name?"` |
|
| `fakeit_Question` | Random question | `"What is your name?"` |
|
||||||
| `fakeit_Quote` | Random quote | `"Life is what happens..."` |
|
| `fakeit_Quote` | Random quote | `"Life is what happens..."` |
|
||||||
| `fakeit_Phrase` | Random phrase | `"a piece of cake"` |
|
| `fakeit_Phrase` | Random phrase | `"a piece of cake"` |
|
||||||
|
|
||||||
### Foods
|
### Foods
|
||||||
|
|
||||||
|
|||||||
49
go.mod
49
go.mod
@@ -1,43 +1,46 @@
|
|||||||
module go.aykhans.me/sarin
|
module go.aykhans.me/sarin
|
||||||
|
|
||||||
go 1.25.5
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.0
|
github.com/brianvoe/gofakeit/v7 v7.14.1
|
||||||
github.com/charmbracelet/bubbles v0.21.0
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/glamour v0.10.0
|
github.com/charmbracelet/glamour v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||||
github.com/charmbracelet/x/term v0.2.2
|
github.com/charmbracelet/x/term v0.2.2
|
||||||
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/valyala/fasthttp v1.69.0
|
github.com/valyala/fasthttp v1.70.0
|
||||||
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
go.aykhans.me/utils v1.0.7
|
go.aykhans.me/utils v1.0.7
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
go.yaml.in/yaml/v4 v4.0.0-rc.4
|
||||||
golang.org/x/net v0.49.0
|
golang.org/x/net v0.53.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
|
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // 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/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260329003944-7eda8903d971 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.6.2 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||||
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/klauspost/compress v1.18.2 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.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/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
@@ -47,9 +50,9 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.7.16 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/term v0.39.0 // indirect
|
golang.org/x/term v0.42.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.36.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
102
go.sum
102
go.sum
@@ -1,70 +1,76 @@
|
|||||||
|
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=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
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/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.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||||
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
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/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.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
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-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
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/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
|
github.com/brianvoe/gofakeit/v7 v7.14.1 h1:a7fe3fonbj0cW3wgl5VwIKfZtiH9C3cLnwcIXWT7sow=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
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 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
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.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
||||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
||||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
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/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 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
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 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/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-20260329003944-7eda8903d971 h1:wae/9jUCdhUiuyCcOzZZ+vJEB7uJx+IvtTnpCqcW1ZQ=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260329003944-7eda8903d971/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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
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/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 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
||||||
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
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/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||||
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
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.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
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/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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
@@ -85,31 +91,35 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
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.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
|
||||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
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.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
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/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||||
|
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 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
|
||||||
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
|
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.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
|
||||||
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.4/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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -10,27 +10,13 @@ import (
|
|||||||
|
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
versionpkg "go.aykhans.me/sarin/internal/version"
|
versionpkg "go.aykhans.me/sarin/internal/version"
|
||||||
"go.aykhans.me/utils/common"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const cliUsageText = `Usage:
|
const cliUsageText = `Usage:
|
||||||
sarin [flags]
|
sarin [flags]
|
||||||
|
|
||||||
Simple usage:
|
Simple usage:
|
||||||
sarin -U https://example.com -d 1m
|
sarin -U https://example.com -r 1
|
||||||
|
|
||||||
Usage with all flags:
|
|
||||||
sarin -s -q -z -o json -f ./config.yaml -c 50 -r 100_000 -d 2m30s \
|
|
||||||
-U https://example.com \
|
|
||||||
-M POST \
|
|
||||||
-V "sharedUUID={{ fakeit_UUID }}" \
|
|
||||||
-B '{"product": "car"}' \
|
|
||||||
-P "id={{ .Values.sharedUUID }}" \
|
|
||||||
-H "User-Agent: {{ fakeit_UserAgent }}" -H "Accept: */*" \
|
|
||||||
-C "token={{ .Values.sharedUUID }}" \
|
|
||||||
-X "http://proxy.example.com" \
|
|
||||||
-T 3s \
|
|
||||||
-I
|
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
General Config:
|
General Config:
|
||||||
@@ -55,7 +41,9 @@ Flags:
|
|||||||
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
|
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
|
||||||
-V, -values []string List of values for templating (e.g. "key1=value1")
|
-V, -values []string List of values for templating (e.g. "key1=value1")
|
||||||
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
|
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
|
||||||
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)`
|
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)
|
||||||
|
-lua []string Lua script for request transformation (inline or @file/@url)
|
||||||
|
-js []string JavaScript script for request transformation (inline or @file/@url)`
|
||||||
|
|
||||||
var _ IParser = ConfigCLIParser{}
|
var _ IParser = ConfigCLIParser{}
|
||||||
|
|
||||||
@@ -83,7 +71,6 @@ func (arg *stringSliceArg) Set(value string) error {
|
|||||||
|
|
||||||
// Parse parses command-line arguments into a Config object.
|
// Parse parses command-line arguments into a Config object.
|
||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ErrCLINoArgs
|
|
||||||
// - types.CLIUnexpectedArgsError
|
// - types.CLIUnexpectedArgsError
|
||||||
// - types.FieldParseErrors
|
// - types.FieldParseErrors
|
||||||
func (parser ConfigCLIParser) Parse() (*Config, error) {
|
func (parser ConfigCLIParser) Parse() (*Config, error) {
|
||||||
@@ -106,16 +93,18 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
dryRun bool
|
dryRun bool
|
||||||
|
|
||||||
// Request config
|
// Request config
|
||||||
urlInput string
|
urlInput string
|
||||||
methods = stringSliceArg{}
|
methods = stringSliceArg{}
|
||||||
bodies = stringSliceArg{}
|
bodies = stringSliceArg{}
|
||||||
params = stringSliceArg{}
|
params = stringSliceArg{}
|
||||||
headers = stringSliceArg{}
|
headers = stringSliceArg{}
|
||||||
cookies = stringSliceArg{}
|
cookies = stringSliceArg{}
|
||||||
proxies = stringSliceArg{}
|
proxies = stringSliceArg{}
|
||||||
values = stringSliceArg{}
|
values = stringSliceArg{}
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
insecure bool
|
insecure bool
|
||||||
|
luaScripts = stringSliceArg{}
|
||||||
|
jsScripts = stringSliceArg{}
|
||||||
)
|
)
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -177,6 +166,10 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
|
|
||||||
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
|
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
|
||||||
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
|
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
|
||||||
|
|
||||||
|
flagSet.Var(&luaScripts, "lua", "Lua script for request transformation (inline or @file/@url)")
|
||||||
|
|
||||||
|
flagSet.Var(&jsScripts, "js", "JavaScript script for request transformation (inline or @file/@url)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the specific arguments provided to the parser, skipping the program name.
|
// Parse the specific arguments provided to the parser, skipping the program name.
|
||||||
@@ -184,12 +177,6 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if no flags were set and no non-flag arguments were provided.
|
|
||||||
// This covers cases where `sarin` is run without any meaningful arguments.
|
|
||||||
if flagSet.NFlag() == 0 && len(flagSet.Args()) == 0 {
|
|
||||||
return nil, types.ErrCLINoArgs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for any unexpected non-flag arguments remaining after parsing.
|
// Check for any unexpected non-flag arguments remaining after parsing.
|
||||||
if args := flagSet.Args(); len(args) > 0 {
|
if args := flagSet.Args(); len(args) > 0 {
|
||||||
return nil, types.NewCLIUnexpectedArgsError(args)
|
return nil, types.NewCLIUnexpectedArgsError(args)
|
||||||
@@ -207,23 +194,23 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
switch flagVar.Name {
|
switch flagVar.Name {
|
||||||
// General config
|
// General config
|
||||||
case "show-config", "s":
|
case "show-config", "s":
|
||||||
config.ShowConfig = common.ToPtr(showConfig)
|
config.ShowConfig = new(showConfig)
|
||||||
case "config-file", "f":
|
case "config-file", "f":
|
||||||
for _, configFile := range configFiles {
|
for _, configFile := range configFiles {
|
||||||
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
|
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
|
||||||
}
|
}
|
||||||
case "concurrency", "c":
|
case "concurrency", "c":
|
||||||
config.Concurrency = common.ToPtr(concurrency)
|
config.Concurrency = new(concurrency)
|
||||||
case "requests", "r":
|
case "requests", "r":
|
||||||
config.Requests = common.ToPtr(requestCount)
|
config.Requests = new(requestCount)
|
||||||
case "duration", "d":
|
case "duration", "d":
|
||||||
config.Duration = common.ToPtr(duration)
|
config.Duration = new(duration)
|
||||||
case "quiet", "q":
|
case "quiet", "q":
|
||||||
config.Quiet = common.ToPtr(quiet)
|
config.Quiet = new(quiet)
|
||||||
case "output", "o":
|
case "output", "o":
|
||||||
config.Output = common.ToPtr(ConfigOutputType(output))
|
config.Output = new(ConfigOutputType(output))
|
||||||
case "dry-run", "z":
|
case "dry-run", "z":
|
||||||
config.DryRun = common.ToPtr(dryRun)
|
config.DryRun = new(dryRun)
|
||||||
|
|
||||||
// Request config
|
// Request config
|
||||||
case "url", "U":
|
case "url", "U":
|
||||||
@@ -256,9 +243,13 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
case "values", "V":
|
case "values", "V":
|
||||||
config.Values = append(config.Values, values...)
|
config.Values = append(config.Values, values...)
|
||||||
case "timeout", "T":
|
case "timeout", "T":
|
||||||
config.Timeout = common.ToPtr(timeout)
|
config.Timeout = new(timeout)
|
||||||
case "insecure", "I":
|
case "insecure", "I":
|
||||||
config.Insecure = common.ToPtr(insecure)
|
config.Insecure = new(insecure)
|
||||||
|
case "lua":
|
||||||
|
config.Lua = append(config.Lua, luaScripts...)
|
||||||
|
case "js":
|
||||||
|
config.Js = append(config.Js, jsScripts...)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"github.com/charmbracelet/glamour/styles"
|
"github.com/charmbracelet/glamour/styles"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/charmbracelet/x/term"
|
"github.com/charmbracelet/x/term"
|
||||||
|
"go.aykhans.me/sarin/internal/script"
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
"go.aykhans.me/sarin/internal/version"
|
"go.aykhans.me/sarin/internal/version"
|
||||||
"go.aykhans.me/utils/common"
|
"go.aykhans.me/utils/common"
|
||||||
@@ -87,10 +89,8 @@ type Config struct {
|
|||||||
Bodies []string `yaml:"bodies,omitempty"`
|
Bodies []string `yaml:"bodies,omitempty"`
|
||||||
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
||||||
Values []string `yaml:"values,omitempty"`
|
Values []string `yaml:"values,omitempty"`
|
||||||
}
|
Lua []string `yaml:"lua,omitempty"`
|
||||||
|
Js []string `yaml:"js,omitempty"`
|
||||||
func NewConfig() *Config {
|
|
||||||
return &Config{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config Config) MarshalYAML() (any, error) {
|
func (config Config) MarshalYAML() (any, error) {
|
||||||
@@ -219,6 +219,8 @@ func (config Config) MarshalYAML() (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addStringSlice(content, "values", config.Values, false)
|
addStringSlice(content, "values", config.Values, false)
|
||||||
|
addStringSlice(content, "lua", config.Lua, false)
|
||||||
|
addStringSlice(content, "js", config.Js, false)
|
||||||
|
|
||||||
return root, nil
|
return root, nil
|
||||||
}
|
}
|
||||||
@@ -273,7 +275,7 @@ func (config Config) Print() bool {
|
|||||||
func (config *Config) Merge(newConfig *Config) {
|
func (config *Config) Merge(newConfig *Config) {
|
||||||
config.Files = append(config.Files, newConfig.Files...)
|
config.Files = append(config.Files, newConfig.Files...)
|
||||||
if len(newConfig.Methods) > 0 {
|
if len(newConfig.Methods) > 0 {
|
||||||
config.Methods = append(config.Methods, newConfig.Methods...)
|
config.Methods = newConfig.Methods
|
||||||
}
|
}
|
||||||
if newConfig.URL != nil {
|
if newConfig.URL != nil {
|
||||||
config.URL = newConfig.URL
|
config.URL = newConfig.URL
|
||||||
@@ -315,7 +317,7 @@ func (config *Config) Merge(newConfig *Config) {
|
|||||||
config.Cookies = append(config.Cookies, newConfig.Cookies...)
|
config.Cookies = append(config.Cookies, newConfig.Cookies...)
|
||||||
}
|
}
|
||||||
if len(newConfig.Bodies) != 0 {
|
if len(newConfig.Bodies) != 0 {
|
||||||
config.Bodies = append(config.Bodies, newConfig.Bodies...)
|
config.Bodies = newConfig.Bodies
|
||||||
}
|
}
|
||||||
if len(newConfig.Proxies) != 0 {
|
if len(newConfig.Proxies) != 0 {
|
||||||
config.Proxies.Append(newConfig.Proxies...)
|
config.Proxies.Append(newConfig.Proxies...)
|
||||||
@@ -323,6 +325,12 @@ func (config *Config) Merge(newConfig *Config) {
|
|||||||
if len(newConfig.Values) != 0 {
|
if len(newConfig.Values) != 0 {
|
||||||
config.Values = append(config.Values, newConfig.Values...)
|
config.Values = append(config.Values, newConfig.Values...)
|
||||||
}
|
}
|
||||||
|
if len(newConfig.Lua) != 0 {
|
||||||
|
config.Lua = append(config.Lua, newConfig.Lua...)
|
||||||
|
}
|
||||||
|
if len(newConfig.Js) != 0 {
|
||||||
|
config.Js = append(config.Js, newConfig.Js...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config *Config) SetDefaults() {
|
func (config *Config) SetDefaults() {
|
||||||
@@ -348,26 +356,26 @@ func (config *Config) SetDefaults() {
|
|||||||
config.Timeout = &Defaults.RequestTimeout
|
config.Timeout = &Defaults.RequestTimeout
|
||||||
}
|
}
|
||||||
if config.Concurrency == nil {
|
if config.Concurrency == nil {
|
||||||
config.Concurrency = common.ToPtr(Defaults.Concurrency)
|
config.Concurrency = new(Defaults.Concurrency)
|
||||||
}
|
}
|
||||||
if config.ShowConfig == nil {
|
if config.ShowConfig == nil {
|
||||||
config.ShowConfig = common.ToPtr(Defaults.ShowConfig)
|
config.ShowConfig = new(Defaults.ShowConfig)
|
||||||
}
|
}
|
||||||
if config.Quiet == nil {
|
if config.Quiet == nil {
|
||||||
config.Quiet = common.ToPtr(Defaults.Quiet)
|
config.Quiet = new(Defaults.Quiet)
|
||||||
}
|
}
|
||||||
if config.Insecure == nil {
|
if config.Insecure == nil {
|
||||||
config.Insecure = common.ToPtr(Defaults.Insecure)
|
config.Insecure = new(Defaults.Insecure)
|
||||||
}
|
}
|
||||||
if config.DryRun == nil {
|
if config.DryRun == nil {
|
||||||
config.DryRun = common.ToPtr(Defaults.DryRun)
|
config.DryRun = new(Defaults.DryRun)
|
||||||
}
|
}
|
||||||
if !config.Headers.Has("User-Agent") {
|
if !config.Headers.Has("User-Agent") {
|
||||||
config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
|
config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Output == nil {
|
if config.Output == nil {
|
||||||
config.Output = common.ToPtr(Defaults.Output)
|
config.Output = new(Defaults.Output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +418,7 @@ func (config Config) Validate() error {
|
|||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Duration", "0", errors.New("duration must be greater than 0")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("Duration", "0", errors.New("duration must be greater than 0")))
|
||||||
}
|
}
|
||||||
|
|
||||||
if *config.Timeout < 1 {
|
if config.Timeout == nil || *config.Timeout < 1 {
|
||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,6 +473,44 @@ func (config Config) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a context with timeout for script validation (loading from URLs)
|
||||||
|
scriptCtx, scriptCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer scriptCancel()
|
||||||
|
|
||||||
|
for i, scriptSrc := range config.Lua {
|
||||||
|
if err := validateScriptSource(scriptSrc); err != nil {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError(fmt.Sprintf("Lua[%d]", i), scriptSrc, err),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Validate script syntax
|
||||||
|
if err := script.ValidateScript(scriptCtx, scriptSrc, script.EngineTypeLua); err != nil {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError(fmt.Sprintf("Lua[%d]", i), scriptSrc, err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, scriptSrc := range config.Js {
|
||||||
|
if err := validateScriptSource(scriptSrc); err != nil {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError(fmt.Sprintf("Js[%d]", i), scriptSrc, err),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Validate script syntax
|
||||||
|
if err := script.ValidateScript(scriptCtx, scriptSrc, script.EngineTypeJavaScript); err != nil {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError(fmt.Sprintf("Js[%d]", i), scriptSrc, err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
templateErrors := ValidateTemplates(&config)
|
templateErrors := ValidateTemplates(&config)
|
||||||
validationErrors = append(validationErrors, templateErrors...)
|
validationErrors = append(validationErrors, templateErrors...)
|
||||||
|
|
||||||
@@ -490,12 +536,6 @@ func ReadAllConfigs() *Config {
|
|||||||
cliParser := NewConfigCLIParser(os.Args)
|
cliParser := NewConfigCLIParser(os.Args)
|
||||||
cliConf, err := cliParser.Parse()
|
cliConf, err := cliParser.Parse()
|
||||||
_ = utilsErr.MustHandle(err,
|
_ = utilsErr.MustHandle(err,
|
||||||
utilsErr.OnSentinel(types.ErrCLINoArgs, func(err error) error {
|
|
||||||
cliParser.PrintHelp()
|
|
||||||
fmt.Fprintln(os.Stderr, StyleYellow.Render("\nNo arguments provided."))
|
|
||||||
os.Exit(1)
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
|
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
|
||||||
cliParser.PrintHelp()
|
cliParser.PrintHelp()
|
||||||
fmt.Fprintln(os.Stderr,
|
fmt.Fprintln(os.Stderr,
|
||||||
@@ -582,6 +622,57 @@ func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error)
|
|||||||
return fileConfig, nil
|
return fileConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateScriptSource validates a script source string.
|
||||||
|
// Scripts can be:
|
||||||
|
// - Inline script: any string not starting with "@"
|
||||||
|
// - Escaped "@": strings starting with "@@" (literal "@" at start)
|
||||||
|
// - File reference: "@/path/to/file" or "@./relative/path"
|
||||||
|
// - URL reference: "@http://..." or "@https://..."
|
||||||
|
//
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ErrScriptSourceEmpty
|
||||||
|
// - types.ErrScriptURLNoHost
|
||||||
|
// - types.URLParseError
|
||||||
|
func validateScriptSource(script string) error {
|
||||||
|
// Empty script is invalid
|
||||||
|
if script == "" {
|
||||||
|
return types.ErrScriptEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a file/URL reference - it's an inline script
|
||||||
|
if !strings.HasPrefix(script, "@") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escaped @ - it's an inline script starting with literal @
|
||||||
|
if strings.HasPrefix(script, "@@") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's a file or URL reference - validate the source
|
||||||
|
source := script[1:] // Remove the @ prefix
|
||||||
|
|
||||||
|
if source == "" {
|
||||||
|
return types.ErrScriptSourceEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a http(s) URL
|
||||||
|
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
|
||||||
|
parsedURL, err := url.Parse(source)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewURLParseError(source, err)
|
||||||
|
}
|
||||||
|
if parsedURL.Host == "" {
|
||||||
|
return types.ErrScriptURLNoHost
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's a file path - basic validation (not empty, checked above)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func printParseErrors(parserName string, errors ...types.FieldParseError) {
|
func printParseErrors(parserName string, errors ...types.FieldParseError) {
|
||||||
for _, fieldErr := range errors {
|
for _, fieldErr := range errors {
|
||||||
if fieldErr.Value == "" {
|
if fieldErr.Value == "" {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
"go.aykhans.me/utils/common"
|
|
||||||
utilsParse "go.aykhans.me/utils/parser"
|
utilsParse "go.aykhans.me/utils/parser"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,7 +66,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if output := parser.getEnv("OUTPUT"); output != "" {
|
if output := parser.getEnv("OUTPUT"); output != "" {
|
||||||
config.Output = common.ToPtr(ConfigOutputType(output))
|
config.Output = new(ConfigOutputType(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
||||||
@@ -158,7 +157,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
types.NewFieldParseError(
|
types.NewFieldParseError(
|
||||||
parser.getFullEnvName("DURATION"),
|
parser.getFullEnvName("DURATION"),
|
||||||
duration,
|
duration,
|
||||||
errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
|
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -174,7 +173,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
types.NewFieldParseError(
|
types.NewFieldParseError(
|
||||||
parser.getFullEnvName("TIMEOUT"),
|
parser.getFullEnvName("TIMEOUT"),
|
||||||
timeout,
|
timeout,
|
||||||
errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
|
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -216,6 +215,14 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
config.Values = []string{values}
|
config.Values = []string{values}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if lua := parser.getEnv("LUA"); lua != "" {
|
||||||
|
config.Lua = []string{lua}
|
||||||
|
}
|
||||||
|
|
||||||
|
if js := parser.getEnv("JS"); js != "" {
|
||||||
|
config.Js = []string{js}
|
||||||
|
}
|
||||||
|
|
||||||
if len(fieldParseErrors) > 0 {
|
if len(fieldParseErrors) > 0 {
|
||||||
return nil, types.NewFieldParseErrors(fieldParseErrors)
|
return nil, types.NewFieldParseErrors(fieldParseErrors)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
"go.aykhans.me/utils/common"
|
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +48,10 @@ func (parser ConfigFileParser) Parse() (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetchFile retrieves file contents from a local path or HTTP/HTTPS URL.
|
// fetchFile retrieves file contents from a local path or HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
func fetchFile(ctx context.Context, src string) ([]byte, error) {
|
func fetchFile(ctx context.Context, src string) ([]byte, error) {
|
||||||
if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
|
if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
|
||||||
return fetchHTTP(ctx, src)
|
return fetchHTTP(ctx, src)
|
||||||
@@ -57,25 +60,28 @@ func fetchFile(ctx context.Context, src string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetchHTTP downloads file contents from an HTTP/HTTPS URL.
|
// fetchHTTP downloads file contents from an HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch file: %w", err)
|
return nil, types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close() //nolint:errcheck
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("failed to fetch file: HTTP %d %s", resp.StatusCode, resp.Status)
|
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
return nil, types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
@@ -83,19 +89,21 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
|||||||
|
|
||||||
// fetchLocal reads file contents from the local filesystem.
|
// fetchLocal reads file contents from the local filesystem.
|
||||||
// It resolves relative paths from the current working directory.
|
// It resolves relative paths from the current working directory.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
func fetchLocal(src string) ([]byte, error) {
|
func fetchLocal(src string) ([]byte, error) {
|
||||||
path := src
|
path := src
|
||||||
if !filepath.IsAbs(src) {
|
if !filepath.IsAbs(src) {
|
||||||
pwd, err := os.Getwd()
|
pwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get working directory: %w", err)
|
return nil, types.NewFileReadError(src, err)
|
||||||
}
|
}
|
||||||
path = filepath.Join(pwd, src)
|
path = filepath.Join(pwd, src)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec
|
data, err := os.ReadFile(path) //nolint:gosec
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
return nil, types.NewFileReadError(path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
@@ -202,6 +210,8 @@ type configYAML struct {
|
|||||||
Bodies stringOrSliceField `yaml:"body"`
|
Bodies stringOrSliceField `yaml:"body"`
|
||||||
Proxies stringOrSliceField `yaml:"proxy"`
|
Proxies stringOrSliceField `yaml:"proxy"`
|
||||||
Values stringOrSliceField `yaml:"values"`
|
Values stringOrSliceField `yaml:"values"`
|
||||||
|
Lua stringOrSliceField `yaml:"lua"`
|
||||||
|
Js stringOrSliceField `yaml:"js"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseYAML parses YAML config file arguments into a Config object.
|
// ParseYAML parses YAML config file arguments into a Config object.
|
||||||
@@ -230,7 +240,7 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
config.Quiet = parsedData.Quiet
|
config.Quiet = parsedData.Quiet
|
||||||
|
|
||||||
if parsedData.Output != nil {
|
if parsedData.Output != nil {
|
||||||
config.Output = common.ToPtr(ConfigOutputType(*parsedData.Output))
|
config.Output = new(ConfigOutputType(*parsedData.Output))
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Insecure = parsedData.Insecure
|
config.Insecure = parsedData.Insecure
|
||||||
@@ -246,6 +256,8 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
}
|
}
|
||||||
config.Bodies = append(config.Bodies, parsedData.Bodies...)
|
config.Bodies = append(config.Bodies, parsedData.Bodies...)
|
||||||
config.Values = append(config.Values, parsedData.Values...)
|
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 {
|
if len(parsedData.ConfigFiles) > 0 {
|
||||||
for _, configFile := range parsedData.ConfigFiles {
|
for _, configFile := range parsedData.ConfigFiles {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.TemplateParseError
|
||||||
func validateTemplateString(value string, funcMap template.FuncMap) error {
|
func validateTemplateString(value string, funcMap template.FuncMap) error {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -15,7 +17,7 @@ func validateTemplateString(value string, funcMap template.FuncMap) error {
|
|||||||
|
|
||||||
_, err := template.New("").Funcs(funcMap).Parse(value)
|
_, err := template.New("").Funcs(funcMap).Parse(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("template parse error: %w", err)
|
return types.NewTemplateParseError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
415
internal/sarin/captcha.go
Normal file
415
internal/sarin/captcha.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -95,6 +94,9 @@ func NewHostClients(
|
|||||||
return []*fasthttp.HostClient{client}, nil
|
return []*fasthttp.HostClient{client}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewProxyDialFunc creates a dial function for the given proxy URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ProxyUnsupportedSchemeError
|
||||||
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
||||||
var (
|
var (
|
||||||
dialer fasthttp.DialFunc
|
dialer fasthttp.DialFunc
|
||||||
@@ -117,16 +119,14 @@ func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Durat
|
|||||||
case "https":
|
case "https":
|
||||||
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
|
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
|
||||||
default:
|
default:
|
||||||
return nil, errors.New("unsupported proxy scheme")
|
return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme)
|
||||||
}
|
|
||||||
|
|
||||||
if dialer == nil {
|
|
||||||
return nil, errors.New("internal error: proxy dialer is nil")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return dialer, nil
|
return dialer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The returned dial function can return the following errors:
|
||||||
|
// - types.ProxyDialError
|
||||||
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
|
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
|
||||||
netDialer := &net.Dialer{}
|
netDialer := &net.Dialer{}
|
||||||
|
|
||||||
@@ -147,12 +147,18 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxyStr := proxyURL.String()
|
||||||
|
|
||||||
// Assert to ContextDialer for timeout support
|
// Assert to ContextDialer for timeout support
|
||||||
contextDialer, ok := socksDialer.(proxy.ContextDialer)
|
contextDialer, ok := socksDialer.(proxy.ContextDialer)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Fallback without timeout (should not happen with net.Dialer)
|
// Fallback without timeout (should not happen with net.Dialer)
|
||||||
return func(addr string) (net.Conn, error) {
|
return func(addr string) (net.Conn, error) {
|
||||||
return socksDialer.Dial("tcp", addr)
|
conn, err := socksDialer.Dial("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,18 +169,17 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
if resolveLocally {
|
if resolveLocally {
|
||||||
host, port, err := net.SplitHostPort(addr)
|
host, port, err := net.SplitHostPort(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap DNS resolution to half the timeout to reserve time for dial
|
|
||||||
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
|
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
|
||||||
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
||||||
dnsCancel()
|
dnsCancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
return nil, errors.New("no IP addresses found for host: " + host)
|
return nil, types.NewProxyDialError(proxyStr, types.NewProxyResolveError(host))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the first resolved IP
|
// Use the first resolved IP
|
||||||
@@ -184,16 +189,22 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
// Use remaining time for dial
|
// Use remaining time for dial
|
||||||
remaining := time.Until(deadline)
|
remaining := time.Until(deadline)
|
||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
return nil, context.DeadlineExceeded
|
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
||||||
}
|
}
|
||||||
|
|
||||||
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
||||||
defer dialCancel()
|
defer dialCancel()
|
||||||
|
|
||||||
return contextDialer.DialContext(dialCtx, "tcp", addr)
|
conn, err := contextDialer.DialContext(dialCtx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The returned dial function can return the following errors:
|
||||||
|
// - types.ProxyDialError
|
||||||
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
|
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
|
||||||
proxyAddr := proxyURL.Host
|
proxyAddr := proxyURL.Host
|
||||||
if proxyURL.Port() == "" {
|
if proxyURL.Port() == "" {
|
||||||
@@ -209,33 +220,35 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
proxyAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
proxyAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxyStr := proxyURL.String()
|
||||||
|
|
||||||
return func(addr string) (net.Conn, error) {
|
return func(addr string) (net.Conn, error) {
|
||||||
// Establish TCP connection to proxy with timeout
|
// Establish TCP connection to proxy with timeout
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining := timeout - time.Since(start)
|
remaining := timeout - time.Since(start)
|
||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
conn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, context.DeadlineExceeded
|
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set deadline for the TLS handshake and CONNECT request
|
// Set deadline for the TLS handshake and CONNECT request
|
||||||
if err := conn.SetDeadline(time.Now().Add(remaining)); err != nil {
|
if err := conn.SetDeadline(time.Now().Add(remaining)); err != nil {
|
||||||
conn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade to TLS
|
// Upgrade to TLS
|
||||||
tlsConn := tls.Client(conn, &tls.Config{ //nolint:gosec
|
tlsConn := tls.Client(conn, &tls.Config{
|
||||||
ServerName: proxyURL.Hostname(),
|
ServerName: proxyURL.Hostname(),
|
||||||
})
|
})
|
||||||
if err := tlsConn.Handshake(); err != nil {
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and send CONNECT request
|
// Build and send CONNECT request
|
||||||
@@ -251,7 +264,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
|
|
||||||
if err := connectReq.Write(tlsConn); err != nil {
|
if err := connectReq.Write(tlsConn); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read response using buffered reader, but return wrapped connection
|
// Read response using buffered reader, but return wrapped connection
|
||||||
@@ -260,19 +273,19 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
resp, err := http.ReadResponse(bufReader, connectReq)
|
resp, err := http.ReadResponse(bufReader, connectReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
resp.Body.Close() //nolint:errcheck,gosec
|
resp.Body.Close() //nolint:errcheck,gosec
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, errors.New("proxy CONNECT failed: " + resp.Status)
|
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear deadline for the tunneled connection
|
// Clear deadline for the tunneled connection
|
||||||
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
|
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return wrapped connection that uses the buffered reader
|
// Return wrapped connection that uses the buffered reader
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package sarin
|
package sarin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -10,6 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CachedFile holds the cached content and metadata of a file.
|
// CachedFile holds the cached content and metadata of a file.
|
||||||
@@ -31,6 +32,10 @@ func NewFileCache(requestTimeout time.Duration) *FileCache {
|
|||||||
|
|
||||||
// GetOrLoad retrieves a file from cache or loads it using the provided source.
|
// GetOrLoad retrieves a file from cache or loads it using the provided source.
|
||||||
// The source can be a local file path or an HTTP/HTTPS URL.
|
// The source can be a local file path or an HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
|
func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
|
||||||
if val, ok := fc.cache.Load(source); ok {
|
if val, ok := fc.cache.Load(source); ok {
|
||||||
return val.(*CachedFile), nil
|
return val.(*CachedFile), nil
|
||||||
@@ -59,14 +64,21 @@ func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
|
|||||||
return actual.(*CachedFile), nil
|
return actual.(*CachedFile), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readLocalFile reads a file from the local filesystem and returns its content and filename.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
func (fc *FileCache) readLocalFile(filePath string) ([]byte, string, error) {
|
func (fc *FileCache) readLocalFile(filePath string) ([]byte, string, error) {
|
||||||
content, err := os.ReadFile(filePath) //nolint:gosec
|
content, err := os.ReadFile(filePath) //nolint:gosec
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to read file %s: %w", filePath, err)
|
return nil, "", types.NewFileReadError(filePath, err)
|
||||||
}
|
}
|
||||||
return content, filepath.Base(filePath), nil
|
return content, filepath.Base(filePath), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchURL downloads file contents from an HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
|
func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: fc.requestTimeout,
|
Timeout: fc.requestTimeout,
|
||||||
@@ -74,17 +86,17 @@ func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
|
|||||||
|
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to fetch URL %s: %w", url, err)
|
return nil, "", types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close() //nolint:errcheck
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, "", fmt.Errorf("failed to fetch URL %s: HTTP %d", url, resp.StatusCode)
|
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := io.ReadAll(resp.Body)
|
content, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to read response body from %s: %w", url, err)
|
return nil, "", types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract filename from URL path
|
// Extract filename from URL path
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import (
|
|||||||
func NewDefaultRandSource() rand.Source {
|
func NewDefaultRandSource() rand.Source {
|
||||||
now := time.Now().UnixNano()
|
now := time.Now().UnixNano()
|
||||||
return rand.NewPCG(
|
return rand.NewPCG(
|
||||||
uint64(now), //nolint:gosec // G115: Safe conversion; UnixNano timestamp used as random seed, bit pattern is intentional
|
uint64(now),
|
||||||
uint64(now>>32), //nolint:gosec // G115: Safe conversion; right-shifted timestamp for seed entropy, overflow is acceptable
|
uint64(now>>32),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstOrEmpty(values []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return values[0]
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package sarin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
|
||||||
"maps"
|
"maps"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -11,13 +10,14 @@ import (
|
|||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
|
"go.aykhans.me/sarin/internal/script"
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
utilsSlice "go.aykhans.me/utils/slice"
|
utilsSlice "go.aykhans.me/utils/slice"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RequestGenerator func(*fasthttp.Request) error
|
type RequestGenerator func(*fasthttp.Request) error
|
||||||
|
|
||||||
type RequestGeneratorWithData func(*fasthttp.Request, any) error
|
type requestDataGenerator func(*script.RequestData, any) error
|
||||||
|
|
||||||
type valuesData struct {
|
type valuesData struct {
|
||||||
Values map[string]string
|
Values map[string]string
|
||||||
@@ -26,6 +26,9 @@ type valuesData struct {
|
|||||||
// NewRequestGenerator creates a new RequestGenerator function that generates HTTP requests
|
// NewRequestGenerator creates a new RequestGenerator function that generates HTTP requests
|
||||||
// with the specified configuration. The returned RequestGenerator is NOT safe for concurrent
|
// with the specified configuration. The returned RequestGenerator is NOT safe for concurrent
|
||||||
// use by multiple goroutines.
|
// use by multiple goroutines.
|
||||||
|
//
|
||||||
|
// Note: Scripts must be validated before calling this function (e.g., in NewSarin).
|
||||||
|
// The caller is responsible for managing the scriptTransformer lifecycle.
|
||||||
func NewRequestGenerator(
|
func NewRequestGenerator(
|
||||||
methods []string,
|
methods []string,
|
||||||
requestURL *url.URL,
|
requestURL *url.URL,
|
||||||
@@ -35,23 +38,50 @@ func NewRequestGenerator(
|
|||||||
bodies []string,
|
bodies []string,
|
||||||
values []string,
|
values []string,
|
||||||
fileCache *FileCache,
|
fileCache *FileCache,
|
||||||
|
scriptTransformer *script.Transformer,
|
||||||
) (RequestGenerator, bool) {
|
) (RequestGenerator, bool) {
|
||||||
randSource := NewDefaultRandSource()
|
randSource := NewDefaultRandSource()
|
||||||
//nolint:gosec // G404: Using non-cryptographic rand for load testing, not security
|
//nolint:gosec // G404: Using non-cryptographic rand for load testing, not security
|
||||||
localRand := rand.New(randSource)
|
localRand := rand.New(randSource)
|
||||||
templateFuncMap := NewDefaultTemplateFuncMap(randSource, fileCache)
|
|
||||||
|
|
||||||
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap)
|
// Funcs() is only called if a value actually contains template syntax.
|
||||||
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
|
// The root template is shared across all createTemplateFunc calls so Funcs() is called at most once.
|
||||||
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, templateFuncMap)
|
var templateRoot *template.Template
|
||||||
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, templateFuncMap)
|
lazyTemplateRoot := func() *template.Template {
|
||||||
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap)
|
if templateRoot == nil {
|
||||||
|
templateRoot = template.New("").Funcs(NewDefaultTemplateFuncMap(randSource, fileCache))
|
||||||
|
}
|
||||||
|
return templateRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
|
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
|
||||||
bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache)
|
var bodyTemplateRoot *template.Template
|
||||||
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap)
|
lazyBodyTemplateRoot := func() *template.Template {
|
||||||
|
if bodyTemplateRoot == nil {
|
||||||
|
bodyTemplateRoot = template.New("").Funcs(NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache))
|
||||||
|
}
|
||||||
|
return bodyTemplateRoot
|
||||||
|
}
|
||||||
|
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, lazyBodyTemplateRoot)
|
||||||
|
|
||||||
valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap)
|
valuesGenerator := NewValuesGeneratorFunc(values, lazyTemplateRoot)
|
||||||
|
|
||||||
|
hasScripts := scriptTransformer != nil && !scriptTransformer.IsEmpty()
|
||||||
|
|
||||||
|
host := requestURL.Host
|
||||||
|
scheme := requestURL.Scheme
|
||||||
|
|
||||||
|
reqData := &script.RequestData{
|
||||||
|
Headers: make(map[string][]string),
|
||||||
|
Params: make(map[string][]string),
|
||||||
|
Cookies: make(map[string][]string),
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
data valuesData
|
data valuesData
|
||||||
@@ -59,7 +89,7 @@ func NewRequestGenerator(
|
|||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request) error {
|
return func(req *fasthttp.Request) error {
|
||||||
req.Header.SetHost(requestURL.Host)
|
resetRequestData(reqData)
|
||||||
|
|
||||||
data, err = valuesGenerator()
|
data, err = valuesGenerator()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,87 +100,135 @@ func NewRequestGenerator(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.SetRequestURI(path)
|
reqData.Path = path
|
||||||
|
|
||||||
if err = methodGenerator(req, data); err != nil {
|
if err = methodGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyTemplateFuncMapData.ClearFormDataContenType()
|
bodyTemplateFuncMapData.ClearFormDataContentType()
|
||||||
if err = bodyGenerator(req, data); err != nil {
|
if err = bodyGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = headersGenerator(req, data); err != nil {
|
if err = headersGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if bodyTemplateFuncMapData.GetFormDataContenType() != "" {
|
if bodyTemplateFuncMapData.GetFormDataContentType() != "" {
|
||||||
req.Header.Add("Content-Type", bodyTemplateFuncMapData.GetFormDataContenType())
|
reqData.Headers["Content-Type"] = append(reqData.Headers["Content-Type"], bodyTemplateFuncMapData.GetFormDataContentType())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = paramsGenerator(req, data); err != nil {
|
if err = paramsGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = cookiesGenerator(req, data); err != nil {
|
if err = cookiesGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if requestURL.Scheme == "https" {
|
if hasScripts {
|
||||||
req.URI().SetScheme("https")
|
if err = scriptTransformer.Transform(reqData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyRequestDataToFastHTTP(reqData, req, host, scheme)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}, isPathGeneratorDynamic ||
|
}, isPathGeneratorDynamic ||
|
||||||
isMethodGeneratorDynamic ||
|
isMethodGeneratorDynamic ||
|
||||||
isParamsGeneratorDynamic ||
|
isParamsGeneratorDynamic ||
|
||||||
isHeadersGeneratorDynamic ||
|
isHeadersGeneratorDynamic ||
|
||||||
isCookiesGeneratorDynamic ||
|
isCookiesGeneratorDynamic ||
|
||||||
isBodyGeneratorDynamic
|
isBodyGeneratorDynamic ||
|
||||||
|
hasScripts
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func resetRequestData(reqData *script.RequestData) {
|
||||||
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, templateFunctions)
|
reqData.Method = ""
|
||||||
|
reqData.Path = ""
|
||||||
|
reqData.Body = ""
|
||||||
|
clear(reqData.Headers)
|
||||||
|
clear(reqData.Params)
|
||||||
|
clear(reqData.Cookies)
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
for k, values := range reqData.Headers {
|
||||||
|
for _, v := range values {
|
||||||
|
req.Header.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, values := range reqData.Params {
|
||||||
|
for _, v := range values {
|
||||||
|
req.URI().QueryArgs().Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reqData.Cookies) > 0 {
|
||||||
|
cookieStrings := make([]string, 0, len(reqData.Cookies))
|
||||||
|
for k, values := range reqData.Cookies {
|
||||||
|
for _, v := range values {
|
||||||
|
cookieStrings = append(cookieStrings, k+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if scheme == "https" {
|
||||||
|
req.URI().SetScheme("https")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||||
|
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
method string
|
method string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request, data any) error {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
method, err = methodGenerator()(data)
|
method, err = methodGenerator()(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.SetMethod(method)
|
reqData.Method = method
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||||
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, templateFunctions)
|
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, lazyRoot)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
body string
|
body string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request, data any) error {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
body, err = bodyGenerator()(data)
|
body, err = bodyGenerator()(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.SetBody([]byte(body))
|
reqData.Body = body
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, params, templateFunctions)
|
generators, isDynamic := buildKeyValueGenerators(localRand, params, lazyRoot)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request, data any) error {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
for _, gen := range generators {
|
for _, gen := range generators {
|
||||||
key, err = gen.Key(data)
|
key, err = gen.Key(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -162,20 +240,20 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateF
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.URI().QueryArgs().Add(key, value)
|
reqData.Params[key] = append(reqData.Params[key], value)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, headers, templateFunctions)
|
generators, isDynamic := buildKeyValueGenerators(localRand, headers, lazyRoot)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request, data any) error {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
for _, gen := range generators {
|
for _, gen := range generators {
|
||||||
key, err = gen.Key(data)
|
key, err = gen.Key(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -187,50 +265,42 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templa
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Add(key, value)
|
reqData.Headers[key] = append(reqData.Headers[key], value)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, templateFunctions)
|
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
if len(generators) > 0 {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
return func(req *fasthttp.Request, data any) error {
|
for _, gen := range generators {
|
||||||
cookieStrings := make([]string, 0, len(generators))
|
key, err = gen.Key(data)
|
||||||
for _, gen := range generators {
|
if err != nil {
|
||||||
key, err = gen.Key(data)
|
return err
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err = gen.Value()(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieStrings = append(cookieStrings, key+"="+value)
|
|
||||||
}
|
}
|
||||||
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
|
|
||||||
return nil
|
|
||||||
}, isDynamic
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(req *fasthttp.Request, data any) error {
|
value, err = gen.Value()(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reqData.Cookies[key] = append(reqData.Cookies[key], value)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewValuesGeneratorFunc(values []string, templateFunctions template.FuncMap) func() (valuesData, error) {
|
func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template) func() (valuesData, error) {
|
||||||
generators := make([]func(_ any) (string, error), len(values))
|
generators := make([]func(_ any) (string, error), len(values))
|
||||||
|
|
||||||
for i, v := range values {
|
for i, v := range values {
|
||||||
generators[i], _ = createTemplateFunc(v, templateFunctions)
|
generators[i], _ = createTemplateFunc(v, lazyRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -243,12 +313,12 @@ func NewValuesGeneratorFunc(values []string, templateFunctions template.FuncMap)
|
|||||||
for _, generator := range generators {
|
for _, generator := range generators {
|
||||||
rendered, err = generator(nil)
|
rendered, err = generator(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return valuesData{}, fmt.Errorf("values rendering: %w", err)
|
return valuesData{}, types.NewTemplateRenderError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err = godotenv.Unmarshal(rendered)
|
data, err = godotenv.Unmarshal(rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return valuesData{}, fmt.Errorf("values rendering: %w", err)
|
return valuesData{}, types.NewTemplateRenderError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
maps.Copy(result, data)
|
maps.Copy(result, data)
|
||||||
@@ -258,14 +328,18 @@ func NewValuesGeneratorFunc(values []string, templateFunctions template.FuncMap)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTemplateFunc(value string, templateFunctions template.FuncMap) (func(data any) (string, error), bool) {
|
func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(data any) (string, error), bool) {
|
||||||
tmpl, err := template.New("").Funcs(templateFunctions).Parse(value)
|
if !strings.Contains(value, "{{") {
|
||||||
|
return func(_ any) (string, error) { return value, nil }, false
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := lazyRoot().New("").Parse(value)
|
||||||
if err == nil && hasTemplateActions(tmpl) {
|
if err == nil && hasTemplateActions(tmpl) {
|
||||||
var err error
|
var err error
|
||||||
return func(data any) (string, error) {
|
return func(data any) (string, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err = tmpl.Execute(&buf, data); err != nil {
|
if err = tmpl.Execute(&buf, data); err != nil {
|
||||||
return "", fmt.Errorf("template rendering: %w", err)
|
return "", types.NewTemplateRenderError(err)
|
||||||
}
|
}
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}, true
|
}, true
|
||||||
@@ -285,7 +359,7 @@ type keyValueItem interface {
|
|||||||
func buildKeyValueGenerators[T keyValueItem](
|
func buildKeyValueGenerators[T keyValueItem](
|
||||||
localRand *rand.Rand,
|
localRand *rand.Rand,
|
||||||
items []T,
|
items []T,
|
||||||
templateFunctions template.FuncMap,
|
lazyRoot func() *template.Template,
|
||||||
) ([]keyValueGenerator, bool) {
|
) ([]keyValueGenerator, bool) {
|
||||||
isDynamic := false
|
isDynamic := false
|
||||||
generators := make([]keyValueGenerator, len(items))
|
generators := make([]keyValueGenerator, len(items))
|
||||||
@@ -295,7 +369,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
|||||||
keyValue := types.KeyValue[string, []string](item)
|
keyValue := types.KeyValue[string, []string](item)
|
||||||
|
|
||||||
// Generate key function
|
// Generate key function
|
||||||
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, templateFunctions)
|
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot)
|
||||||
if keyIsDynamic {
|
if keyIsDynamic {
|
||||||
isDynamic = true
|
isDynamic = true
|
||||||
}
|
}
|
||||||
@@ -303,7 +377,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
|||||||
// Generate value functions
|
// Generate value functions
|
||||||
valueFuncs := make([]func(data any) (string, error), len(keyValue.Value))
|
valueFuncs := make([]func(data any) (string, error), len(keyValue.Value))
|
||||||
for j, v := range keyValue.Value {
|
for j, v := range keyValue.Value {
|
||||||
valueFunc, valueIsDynamic := createTemplateFunc(v, templateFunctions)
|
valueFunc, valueIsDynamic := createTemplateFunc(v, lazyRoot)
|
||||||
if valueIsDynamic {
|
if valueIsDynamic {
|
||||||
isDynamic = true
|
isDynamic = true
|
||||||
}
|
}
|
||||||
@@ -326,7 +400,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
|||||||
func buildStringSliceGenerator(
|
func buildStringSliceGenerator(
|
||||||
localRand *rand.Rand,
|
localRand *rand.Rand,
|
||||||
values []string,
|
values []string,
|
||||||
templateFunctions template.FuncMap,
|
lazyRoot func() *template.Template,
|
||||||
) (func() func(data any) (string, error), bool) {
|
) (func() func(data any) (string, error), bool) {
|
||||||
// Return a function that returns an empty string generator if values is empty
|
// Return a function that returns an empty string generator if values is empty
|
||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
@@ -338,7 +412,7 @@ func buildStringSliceGenerator(
|
|||||||
valueFuncs := make([]func(data any) (string, error), len(values))
|
valueFuncs := make([]func(data any) (string, error), len(values))
|
||||||
|
|
||||||
for i, value := range values {
|
for i, value := range values {
|
||||||
valueFunc, valueIsDynamic := createTemplateFunc(value, templateFunctions)
|
valueFunc, valueIsDynamic := createTemplateFunc(value, lazyRoot)
|
||||||
if valueIsDynamic {
|
if valueIsDynamic {
|
||||||
isDynamic = true
|
isDynamic = true
|
||||||
}
|
}
|
||||||
|
|||||||
267
internal/sarin/runner.go
Normal file
267
internal/sarin/runner.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package sarin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"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 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, stopCtrl *StopController) {
|
||||||
|
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, stopCtrl, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,778 +0,0 @@
|
|||||||
package sarin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/url"
|
|
||||||
"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/valyala/fasthttp"
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSarin creates a new sarin instance for load testing.
|
|
||||||
// It can return the following errors:
|
|
||||||
// - types.ProxyDialError
|
|
||||||
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,
|
|
||||||
) (*sarin, error) {
|
|
||||||
if workers == 0 {
|
|
||||||
workers = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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)
|
|
||||||
|
|
||||||
requestGenerator, isDynamic := NewRequestGenerator(q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
maxConns := max(fasthttp.DefaultMaxConnsPerHost, workers)
|
|
||||||
maxConns = ((maxConns * 50 / 100) + maxConns)
|
|
||||||
return NewHostClients(
|
|
||||||
ctx,
|
|
||||||
timeout,
|
|
||||||
proxiesRaw,
|
|
||||||
maxConns,
|
|
||||||
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{}{}
|
|
||||||
}
|
|
||||||
55
internal/sarin/stop.go
Normal file
55
internal/sarin/stop.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package sarin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,12 @@ package sarin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/md5" // #nosec G501 -- exposed intentionally as a template utility helper
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -12,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/brianvoe/gofakeit/v7"
|
"github.com/brianvoe/gofakeit/v7"
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) template.FuncMap {
|
func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) template.FuncMap {
|
||||||
@@ -62,10 +67,6 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
},
|
},
|
||||||
"strings_TrimPrefix": strings.TrimPrefix,
|
"strings_TrimPrefix": strings.TrimPrefix,
|
||||||
"strings_TrimSuffix": strings.TrimSuffix,
|
"strings_TrimSuffix": strings.TrimSuffix,
|
||||||
"strings_Join": func(sep string, values ...string) string {
|
|
||||||
return strings.Join(values, sep)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Dict
|
// Dict
|
||||||
"dict_Str": func(values ...string) map[string]string {
|
"dict_Str": func(values ...string) map[string]string {
|
||||||
dict := make(map[string]string)
|
dict := make(map[string]string)
|
||||||
@@ -83,14 +84,87 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"slice_Str": func(values ...string) []string { return values },
|
"slice_Str": func(values ...string) []string { return values },
|
||||||
"slice_Int": func(values ...int) []int { return values },
|
"slice_Int": func(values ...int) []int { return values },
|
||||||
"slice_Uint": func(values ...uint) []uint { return values },
|
"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() },
|
||||||
|
"time_NowRFC3339": func() string { return time.Now().Format(time.RFC3339) },
|
||||||
|
"time_Format": func(layout string, t time.Time) string {
|
||||||
|
return t.Format(layout)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Crypto
|
||||||
|
"crypto_SHA256": func(s string) string {
|
||||||
|
sum := sha256.Sum256([]byte(s))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
},
|
||||||
|
"crypto_MD5": func(s string) string {
|
||||||
|
sum := md5.Sum([]byte(s)) // #nosec G401 -- MD5 is intentionally provided as a non-security template helper
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
},
|
||||||
|
"crypto_HMACSHA256": func(key string, msg string) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(key))
|
||||||
|
_, _ = mac.Write([]byte(msg))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
},
|
||||||
|
"crypto_Base64URL": func(s string) string {
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(s))
|
||||||
|
},
|
||||||
|
|
||||||
// File
|
// File
|
||||||
|
// file_Read reads a file (local or remote URL) and returns its content as a string.
|
||||||
|
// Usage: {{ file_Read "/path/to/file.txt" }}
|
||||||
|
// {{ file_Read "https://example.com/data.txt" }}
|
||||||
|
"file_Read": func(source string) (string, error) {
|
||||||
|
if fileCache == nil {
|
||||||
|
return "", types.ErrFileCacheNotInitialized
|
||||||
|
}
|
||||||
|
cached, err := fileCache.GetOrLoad(source)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(cached.Content), nil
|
||||||
|
},
|
||||||
|
|
||||||
// file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content.
|
// file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content.
|
||||||
// Usage: {{ file_Base64 "/path/to/file.pdf" }}
|
// Usage: {{ file_Base64 "/path/to/file.pdf" }}
|
||||||
// {{ file_Base64 "https://example.com/image.png" }}
|
// {{ file_Base64 "https://example.com/image.png" }}
|
||||||
"file_Base64": func(source string) (string, error) {
|
"file_Base64": func(source string) (string, error) {
|
||||||
if fileCache == nil {
|
if fileCache == nil {
|
||||||
return "", errors.New("file cache is not initialized")
|
return "", types.ErrFileCacheNotInitialized
|
||||||
}
|
}
|
||||||
cached, err := fileCache.GetOrLoad(source)
|
cached, err := fileCache.GetOrLoad(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -245,7 +319,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_AdverbFrequencyDefinite": fakeit.AdverbFrequencyDefinite,
|
"fakeit_AdverbFrequencyDefinite": fakeit.AdverbFrequencyDefinite,
|
||||||
"fakeit_AdverbFrequencyIndefinite": fakeit.AdverbFrequencyIndefinite,
|
"fakeit_AdverbFrequencyIndefinite": fakeit.AdverbFrequencyIndefinite,
|
||||||
|
|
||||||
// Propositions
|
// Prepositions
|
||||||
"fakeit_Preposition": fakeit.Preposition,
|
"fakeit_Preposition": fakeit.Preposition,
|
||||||
"fakeit_PrepositionSimple": fakeit.PrepositionSimple,
|
"fakeit_PrepositionSimple": fakeit.PrepositionSimple,
|
||||||
"fakeit_PrepositionDouble": fakeit.PrepositionDouble,
|
"fakeit_PrepositionDouble": fakeit.PrepositionDouble,
|
||||||
@@ -533,8 +607,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
|
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
|
||||||
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
|
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
|
||||||
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().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": fakeit.School,
|
"fakeit_School": fakeit.School,
|
||||||
@@ -544,19 +617,68 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_SongName": fakeit.SongName,
|
"fakeit_SongName": fakeit.SongName,
|
||||||
"fakeit_SongArtist": fakeit.SongArtist,
|
"fakeit_SongArtist": fakeit.SongArtist,
|
||||||
"fakeit_SongGenre": fakeit.SongGenre,
|
"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))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type BodyTemplateFuncMapData struct {
|
type BodyTemplateFuncMapData struct {
|
||||||
formDataContenType string
|
formDataContentType string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (data BodyTemplateFuncMapData) GetFormDataContenType() string {
|
func (data BodyTemplateFuncMapData) GetFormDataContentType() string {
|
||||||
return data.formDataContenType
|
return data.formDataContentType
|
||||||
}
|
}
|
||||||
|
|
||||||
func (data *BodyTemplateFuncMapData) ClearFormDataContenType() {
|
func (data *BodyTemplateFuncMapData) ClearFormDataContentType() {
|
||||||
data.formDataContenType = ""
|
data.formDataContentType = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDefaultBodyTemplateFuncMap(
|
func NewDefaultBodyTemplateFuncMap(
|
||||||
@@ -582,12 +704,12 @@ func NewDefaultBodyTemplateFuncMap(
|
|||||||
// {{ body_FormData "name" "John" "avatar" "@/path/to/photo.jpg" "doc" "@https://example.com/file.pdf" }}
|
// {{ body_FormData "name" "John" "avatar" "@/path/to/photo.jpg" "doc" "@https://example.com/file.pdf" }}
|
||||||
funcMap["body_FormData"] = func(pairs ...string) (string, error) {
|
funcMap["body_FormData"] = func(pairs ...string) (string, error) {
|
||||||
if len(pairs)%2 != 0 {
|
if len(pairs)%2 != 0 {
|
||||||
return "", errors.New("body_FormData requires an even number of arguments (key-value pairs)")
|
return "", types.ErrFormDataOddArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
var multipartData bytes.Buffer
|
var multipartData bytes.Buffer
|
||||||
writer := multipart.NewWriter(&multipartData)
|
writer := multipart.NewWriter(&multipartData)
|
||||||
data.formDataContenType = writer.FormDataContentType()
|
data.formDataContentType = writer.FormDataContentType()
|
||||||
|
|
||||||
for i := 0; i < len(pairs); i += 2 {
|
for i := 0; i < len(pairs); i += 2 {
|
||||||
key := pairs[i]
|
key := pairs[i]
|
||||||
@@ -602,7 +724,7 @@ func NewDefaultBodyTemplateFuncMap(
|
|||||||
case strings.HasPrefix(val, "@"):
|
case strings.HasPrefix(val, "@"):
|
||||||
// File (local path or remote URL)
|
// File (local path or remote URL)
|
||||||
if fileCache == nil {
|
if fileCache == nil {
|
||||||
return "", errors.New("file cache is not initialized")
|
return "", types.ErrFileCacheNotInitialized
|
||||||
}
|
}
|
||||||
source := val[1:]
|
source := val[1:]
|
||||||
cached, err := fileCache.GetOrLoad(source)
|
cached, err := fileCache.GetOrLoad(source)
|
||||||
|
|||||||
300
internal/sarin/tui.go
Normal file
300
internal/sarin/tui.go
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
package sarin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/progress"
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
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.KeyMsg:
|
||||||
|
if msg.Type == tea.KeyCtrlC {
|
||||||
|
m.cancelling = true
|
||||||
|
m.stop()
|
||||||
|
}
|
||||||
|
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... (Ctrl+C again to force)"))
|
||||||
|
} 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
|
||||||
|
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.KeyMsg:
|
||||||
|
if msg.Type == tea.KeyCtrlC {
|
||||||
|
m.cancelling = true
|
||||||
|
m.stop()
|
||||||
|
}
|
||||||
|
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... (Ctrl+C again to force)"))
|
||||||
|
} else {
|
||||||
|
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finalBuilder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q sarin) streamProgress(
|
||||||
|
ctx context.Context,
|
||||||
|
stopCtrl *StopController,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
messages: make([]string, 8),
|
||||||
|
ctx: ctx,
|
||||||
|
stop: stopCtrl.Stop,
|
||||||
|
quit: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
program = tea.NewProgram(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopCtrl.AttachProgram(program)
|
||||||
|
defer stopCtrl.AttachProgram(nil)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for msg := range messageChannel {
|
||||||
|
program.Send(msg)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err := program.Run(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
done <- struct{}{}
|
||||||
|
}
|
||||||
270
internal/sarin/worker.go
Normal file
270
internal/sarin/worker.go
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
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() 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) 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
internal/script/chain.go
Normal file
107
internal/script/chain.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chain holds the loaded script sources and can create engine instances.
|
||||||
|
// The sources are loaded once, but engines are created per-worker since they're not thread-safe.
|
||||||
|
type Chain struct {
|
||||||
|
luaSources []*Source
|
||||||
|
jsSources []*Source
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChain creates a new script chain from loaded sources.
|
||||||
|
// Lua scripts run first, then JavaScript scripts, in the order provided.
|
||||||
|
func NewChain(luaSources, jsSources []*Source) *Chain {
|
||||||
|
return &Chain{
|
||||||
|
luaSources: luaSources,
|
||||||
|
jsSources: jsSources,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true if there are no scripts to execute.
|
||||||
|
func (c *Chain) IsEmpty() bool {
|
||||||
|
return len(c.luaSources) == 0 && len(c.jsSources) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transformer holds instantiated script engines for a single worker.
|
||||||
|
// It is NOT safe for concurrent use.
|
||||||
|
type Transformer struct {
|
||||||
|
luaEngines []*LuaEngine
|
||||||
|
jsEngines []*JsEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTransformer creates engine instances from the chain's sources.
|
||||||
|
// Call this once per worker goroutine.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ScriptChainError
|
||||||
|
func (c *Chain) NewTransformer() (*Transformer, error) {
|
||||||
|
if c.IsEmpty() {
|
||||||
|
return &Transformer{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &Transformer{
|
||||||
|
luaEngines: make([]*LuaEngine, 0, len(c.luaSources)),
|
||||||
|
jsEngines: make([]*JsEngine, 0, len(c.jsSources)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Lua engines
|
||||||
|
for i, src := range c.luaSources {
|
||||||
|
engine, err := NewLuaEngine(src.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Close() // Clean up already created engines
|
||||||
|
return nil, types.NewScriptChainError("lua", i, err)
|
||||||
|
}
|
||||||
|
t.luaEngines = append(t.luaEngines, engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create JS engines
|
||||||
|
for i, src := range c.jsSources {
|
||||||
|
engine, err := NewJsEngine(src.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Close() // Clean up already created engines
|
||||||
|
return nil, types.NewScriptChainError("js", i, err)
|
||||||
|
}
|
||||||
|
t.jsEngines = append(t.jsEngines, engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform applies all scripts to the request data.
|
||||||
|
// Lua scripts run first, then JavaScript scripts.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ScriptChainError
|
||||||
|
func (t *Transformer) Transform(req *RequestData) error {
|
||||||
|
// Run Lua scripts
|
||||||
|
for i, engine := range t.luaEngines {
|
||||||
|
if err := engine.Transform(req); err != nil {
|
||||||
|
return types.NewScriptChainError("lua", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run JS scripts
|
||||||
|
for i, engine := range t.jsEngines {
|
||||||
|
if err := engine.Transform(req); err != nil {
|
||||||
|
return types.NewScriptChainError("js", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases all engine resources.
|
||||||
|
func (t *Transformer) Close() {
|
||||||
|
for _, engine := range t.luaEngines {
|
||||||
|
engine.Close()
|
||||||
|
}
|
||||||
|
for _, engine := range t.jsEngines {
|
||||||
|
engine.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true if there are no engines.
|
||||||
|
func (t *Transformer) IsEmpty() bool {
|
||||||
|
return len(t.luaEngines) == 0 && len(t.jsEngines) == 0
|
||||||
|
}
|
||||||
198
internal/script/js.go
Normal file
198
internal/script/js.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JsEngine implements the Engine interface using goja (JavaScript).
|
||||||
|
type JsEngine struct {
|
||||||
|
runtime *goja.Runtime
|
||||||
|
transform goja.Callable
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJsEngine creates a new JavaScript script engine with the given script content.
|
||||||
|
// The script must define a global `transform` function that takes a request object
|
||||||
|
// and returns the modified request object.
|
||||||
|
//
|
||||||
|
// Example JavaScript script:
|
||||||
|
//
|
||||||
|
// function transform(req) {
|
||||||
|
// req.headers["X-Custom"] = ["value"];
|
||||||
|
// return req;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptTransformMissing
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
||||||
|
vm := goja.New()
|
||||||
|
|
||||||
|
// Execute the script to define the transform function
|
||||||
|
_, err := vm.RunString(scriptContent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewScriptExecutionError("JavaScript", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transform function
|
||||||
|
transformVal := vm.Get("transform")
|
||||||
|
if transformVal == nil || goja.IsUndefined(transformVal) || goja.IsNull(transformVal) {
|
||||||
|
return nil, types.ErrScriptTransformMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
transform, ok := goja.AssertFunction(transformVal)
|
||||||
|
if !ok {
|
||||||
|
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &JsEngine{
|
||||||
|
runtime: vm,
|
||||||
|
transform: transform,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform executes the JavaScript transform function with the given request data.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
func (e *JsEngine) Transform(req *RequestData) error {
|
||||||
|
// Convert RequestData to JavaScript object
|
||||||
|
reqObj := e.requestDataToObject(req)
|
||||||
|
|
||||||
|
// Call transform(req)
|
||||||
|
result, err := e.transform(goja.Undefined(), reqObj)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewScriptExecutionError("JavaScript", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update RequestData from the returned object
|
||||||
|
if err := e.objectToRequestData(result, req); err != nil {
|
||||||
|
return types.NewScriptExecutionError("JavaScript", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the JavaScript runtime resources.
|
||||||
|
func (e *JsEngine) Close() {
|
||||||
|
// goja doesn't have an explicit close method, but we can help GC
|
||||||
|
e.runtime = nil
|
||||||
|
e.transform = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestDataToObject converts RequestData to a goja Value (JavaScript object).
|
||||||
|
func (e *JsEngine) requestDataToObject(req *RequestData) goja.Value {
|
||||||
|
obj := e.runtime.NewObject()
|
||||||
|
|
||||||
|
_ = obj.Set("method", req.Method)
|
||||||
|
_ = obj.Set("path", req.Path)
|
||||||
|
_ = obj.Set("body", req.Body)
|
||||||
|
|
||||||
|
// Headers (map[string][]string -> object of arrays)
|
||||||
|
headers := e.runtime.NewObject()
|
||||||
|
for k, values := range req.Headers {
|
||||||
|
_ = headers.Set(k, e.stringSliceToArray(values))
|
||||||
|
}
|
||||||
|
_ = obj.Set("headers", headers)
|
||||||
|
|
||||||
|
// Params (map[string][]string -> object of arrays)
|
||||||
|
params := e.runtime.NewObject()
|
||||||
|
for k, values := range req.Params {
|
||||||
|
_ = params.Set(k, e.stringSliceToArray(values))
|
||||||
|
}
|
||||||
|
_ = obj.Set("params", params)
|
||||||
|
|
||||||
|
// Cookies (map[string][]string -> object of arrays)
|
||||||
|
cookies := e.runtime.NewObject()
|
||||||
|
for k, values := range req.Cookies {
|
||||||
|
_ = cookies.Set(k, e.stringSliceToArray(values))
|
||||||
|
}
|
||||||
|
_ = obj.Set("cookies", cookies)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectToRequestData updates RequestData from a JavaScript object.
|
||||||
|
func (e *JsEngine) objectToRequestData(val goja.Value, req *RequestData) error {
|
||||||
|
if val == nil || goja.IsUndefined(val) || goja.IsNull(val) {
|
||||||
|
return types.ErrScriptTransformReturnObject
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := val.ToObject(e.runtime)
|
||||||
|
if obj == nil {
|
||||||
|
return types.ErrScriptTransformReturnObject
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method
|
||||||
|
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) {
|
||||||
|
req.Method = v.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path
|
||||||
|
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) {
|
||||||
|
req.Path = v.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body
|
||||||
|
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) {
|
||||||
|
req.Body = v.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
req.Headers = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params
|
||||||
|
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
req.Params = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookies
|
||||||
|
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
req.Cookies = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringSliceToArray converts a Go []string to a JavaScript array.
|
||||||
|
func (e *JsEngine) stringSliceToArray(values []string) *goja.Object {
|
||||||
|
ifaces := make([]any, len(values))
|
||||||
|
for i, v := range values {
|
||||||
|
ifaces[i] = v
|
||||||
|
}
|
||||||
|
return e.runtime.NewArray(ifaces...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectToStringSliceMap converts a JavaScript object to a Go map[string][]string.
|
||||||
|
// Supports both single string values and array values.
|
||||||
|
func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string {
|
||||||
|
if obj == nil {
|
||||||
|
return make(map[string][]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string][]string)
|
||||||
|
for _, key := range obj.Keys() {
|
||||||
|
v := obj.Get(key)
|
||||||
|
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an array
|
||||||
|
if arr, ok := v.Export().([]any); ok {
|
||||||
|
var values []string
|
||||||
|
for _, item := range arr {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
values = append(values, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[key] = values
|
||||||
|
} else {
|
||||||
|
// Single value - wrap in slice
|
||||||
|
result[key] = []string{v.String()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
191
internal/script/lua.go
Normal file
191
internal/script/lua.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LuaEngine implements the Engine interface using gopher-lua.
|
||||||
|
type LuaEngine struct {
|
||||||
|
state *lua.LState
|
||||||
|
transform *lua.LFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLuaEngine creates a new Lua script engine with the given script content.
|
||||||
|
// The script must define a global `transform` function that takes a request table
|
||||||
|
// and returns the modified request table.
|
||||||
|
//
|
||||||
|
// Example Lua script:
|
||||||
|
//
|
||||||
|
// function transform(req)
|
||||||
|
// req.headers["X-Custom"] = {"value"}
|
||||||
|
// return req
|
||||||
|
// end
|
||||||
|
//
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptTransformMissing
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
||||||
|
L := lua.NewState()
|
||||||
|
|
||||||
|
// Execute the script to define the transform function
|
||||||
|
if err := L.DoString(scriptContent); err != nil {
|
||||||
|
L.Close()
|
||||||
|
return nil, types.NewScriptExecutionError("Lua", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transform function
|
||||||
|
transform := L.GetGlobal("transform")
|
||||||
|
if transform.Type() != lua.LTFunction {
|
||||||
|
L.Close()
|
||||||
|
return nil, types.ErrScriptTransformMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LuaEngine{
|
||||||
|
state: L,
|
||||||
|
transform: transform.(*lua.LFunction),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform executes the Lua transform function with the given request data.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
func (e *LuaEngine) Transform(req *RequestData) error {
|
||||||
|
// Convert RequestData to Lua table
|
||||||
|
reqTable := e.requestDataToTable(req)
|
||||||
|
|
||||||
|
// Call transform(req)
|
||||||
|
e.state.Push(e.transform)
|
||||||
|
e.state.Push(reqTable)
|
||||||
|
if err := e.state.PCall(1, 1, nil); err != nil {
|
||||||
|
return types.NewScriptExecutionError("Lua", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the result
|
||||||
|
result := e.state.Get(-1)
|
||||||
|
e.state.Pop(1)
|
||||||
|
|
||||||
|
if result.Type() != lua.LTTable {
|
||||||
|
return types.NewScriptExecutionError("Lua", fmt.Errorf("transform function must return a table, got %s", result.Type()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update RequestData from the returned table
|
||||||
|
e.tableToRequestData(result.(*lua.LTable), req)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the Lua state resources.
|
||||||
|
func (e *LuaEngine) Close() {
|
||||||
|
if e.state != nil {
|
||||||
|
e.state.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestDataToTable converts RequestData to a Lua table.
|
||||||
|
func (e *LuaEngine) requestDataToTable(req *RequestData) *lua.LTable {
|
||||||
|
L := e.state
|
||||||
|
t := L.NewTable()
|
||||||
|
|
||||||
|
t.RawSetString("method", lua.LString(req.Method))
|
||||||
|
t.RawSetString("path", lua.LString(req.Path))
|
||||||
|
t.RawSetString("body", lua.LString(req.Body))
|
||||||
|
|
||||||
|
// Headers (map[string][]string -> table of arrays)
|
||||||
|
headers := L.NewTable()
|
||||||
|
for k, values := range req.Headers {
|
||||||
|
arr := L.NewTable()
|
||||||
|
for _, v := range values {
|
||||||
|
arr.Append(lua.LString(v))
|
||||||
|
}
|
||||||
|
headers.RawSetString(k, arr)
|
||||||
|
}
|
||||||
|
t.RawSetString("headers", headers)
|
||||||
|
|
||||||
|
// Params (map[string][]string -> table of arrays)
|
||||||
|
params := L.NewTable()
|
||||||
|
for k, values := range req.Params {
|
||||||
|
arr := L.NewTable()
|
||||||
|
for _, v := range values {
|
||||||
|
arr.Append(lua.LString(v))
|
||||||
|
}
|
||||||
|
params.RawSetString(k, arr)
|
||||||
|
}
|
||||||
|
t.RawSetString("params", params)
|
||||||
|
|
||||||
|
// Cookies (map[string][]string -> table of arrays)
|
||||||
|
cookies := L.NewTable()
|
||||||
|
for k, values := range req.Cookies {
|
||||||
|
arr := L.NewTable()
|
||||||
|
for _, v := range values {
|
||||||
|
arr.Append(lua.LString(v))
|
||||||
|
}
|
||||||
|
cookies.RawSetString(k, arr)
|
||||||
|
}
|
||||||
|
t.RawSetString("cookies", cookies)
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableToRequestData updates RequestData from a Lua table.
|
||||||
|
func (e *LuaEngine) tableToRequestData(t *lua.LTable, req *RequestData) {
|
||||||
|
// Method
|
||||||
|
if v := t.RawGetString("method"); v.Type() == lua.LTString {
|
||||||
|
req.Method = string(v.(lua.LString))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path
|
||||||
|
if v := t.RawGetString("path"); v.Type() == lua.LTString {
|
||||||
|
req.Path = string(v.(lua.LString))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body
|
||||||
|
if v := t.RawGetString("body"); v.Type() == lua.LTString {
|
||||||
|
req.Body = string(v.(lua.LString))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
if v := t.RawGetString("headers"); v.Type() == lua.LTTable {
|
||||||
|
req.Headers = e.tableToStringSliceMap(v.(*lua.LTable))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params
|
||||||
|
if v := t.RawGetString("params"); v.Type() == lua.LTTable {
|
||||||
|
req.Params = e.tableToStringSliceMap(v.(*lua.LTable))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookies
|
||||||
|
if v := t.RawGetString("cookies"); v.Type() == lua.LTTable {
|
||||||
|
req.Cookies = e.tableToStringSliceMap(v.(*lua.LTable))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableToStringSliceMap converts a Lua table to a Go map[string][]string.
|
||||||
|
// Supports both single string values and array values.
|
||||||
|
func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string {
|
||||||
|
result := make(map[string][]string)
|
||||||
|
t.ForEach(func(k, v lua.LValue) {
|
||||||
|
if k.Type() != lua.LTString {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := string(k.(lua.LString))
|
||||||
|
|
||||||
|
switch v.Type() {
|
||||||
|
case lua.LTString:
|
||||||
|
// Single string value
|
||||||
|
result[key] = []string{string(v.(lua.LString))}
|
||||||
|
case lua.LTTable:
|
||||||
|
// Array of strings
|
||||||
|
var values []string
|
||||||
|
v.(*lua.LTable).ForEach(func(_, item lua.LValue) {
|
||||||
|
if item.Type() == lua.LTString {
|
||||||
|
values = append(values, string(item.(lua.LString)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
result[key] = values
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
197
internal/script/script.go
Normal file
197
internal/script/script.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestData represents the request data passed to scripts for transformation.
|
||||||
|
// Scripts can modify any field and the changes will be applied to the actual request.
|
||||||
|
// Headers, Params, and Cookies use []string values to support multiple values per key.
|
||||||
|
type RequestData struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Headers map[string][]string `json:"headers"`
|
||||||
|
Params map[string][]string `json:"params"`
|
||||||
|
Cookies map[string][]string `json:"cookies"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine defines the interface for script engines (Lua, JavaScript).
|
||||||
|
// Each engine must be able to transform request data using a user-provided script.
|
||||||
|
type Engine interface {
|
||||||
|
// Transform executes the script's transform function with the given request data.
|
||||||
|
// The script should modify the RequestData and return it.
|
||||||
|
Transform(req *RequestData) error
|
||||||
|
|
||||||
|
// Close releases any resources held by the engine.
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineType represents the type of script engine.
|
||||||
|
type EngineType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EngineTypeLua EngineType = "lua"
|
||||||
|
EngineTypeJavaScript EngineType = "js"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source represents a loaded script source.
|
||||||
|
type Source struct {
|
||||||
|
Content string
|
||||||
|
EngineType EngineType
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSource loads a script from the given source string.
|
||||||
|
// The source can be:
|
||||||
|
// - Inline script: any string not starting with "@"
|
||||||
|
// - Escaped "@": strings starting with "@@" (literal "@" at start, returns string without first @)
|
||||||
|
// - File reference: "@/path/to/file" or "@./relative/path"
|
||||||
|
// - URL reference: "@http://..." or "@https://..."
|
||||||
|
//
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ScriptLoadError
|
||||||
|
func LoadSource(ctx context.Context, source string, engineType EngineType) (*Source, error) {
|
||||||
|
if source == "" {
|
||||||
|
return nil, types.ErrScriptEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var content string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(source, "@@"):
|
||||||
|
// Escaped @ - it's an inline script starting with literal @
|
||||||
|
content = source[1:] // Remove first @, keep the rest
|
||||||
|
case strings.HasPrefix(source, "@"):
|
||||||
|
// File or URL reference
|
||||||
|
ref := source[1:]
|
||||||
|
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
|
||||||
|
content, err = fetchURL(ctx, ref)
|
||||||
|
} else {
|
||||||
|
content, err = readFile(ref)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewScriptLoadError(ref, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Inline script
|
||||||
|
content = source
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Source{
|
||||||
|
Content: content,
|
||||||
|
EngineType: engineType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSources loads multiple script sources.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ScriptLoadError
|
||||||
|
func LoadSources(ctx context.Context, sources []string, engineType EngineType) ([]*Source, error) {
|
||||||
|
loaded := make([]*Source, 0, len(sources))
|
||||||
|
for _, src := range sources {
|
||||||
|
source, err := LoadSource(ctx, src, engineType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
loaded = append(loaded, source)
|
||||||
|
}
|
||||||
|
return loaded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateScript validates a script source by loading it and checking syntax.
|
||||||
|
// It loads the script (from file/URL/inline), parses it, and verifies
|
||||||
|
// that a 'transform' function is defined.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ErrScriptTransformMissing
|
||||||
|
// - types.ScriptLoadError
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
// - types.ScriptUnknownEngineError
|
||||||
|
func ValidateScript(ctx context.Context, source string, engineType EngineType) error {
|
||||||
|
// Load the script source
|
||||||
|
src, err := LoadSource(ctx, source, engineType)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create an engine - this validates syntax and transform function
|
||||||
|
var engine Engine
|
||||||
|
switch engineType {
|
||||||
|
case EngineTypeLua:
|
||||||
|
engine, err = NewLuaEngine(src.Content)
|
||||||
|
case EngineTypeJavaScript:
|
||||||
|
engine, err = NewJsEngine(src.Content)
|
||||||
|
default:
|
||||||
|
return types.NewScriptUnknownEngineError(string(engineType))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the engine - we only needed it for validation
|
||||||
|
engine.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchURL downloads content from an HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
|
func fetchURL(ctx context.Context, url string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewHTTPFetchError(url, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewHTTPFetchError(url, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewHTTPFetchError(url, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFile reads content from a local file.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
|
func readFile(path string) (string, error) {
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
pwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewFileReadError(path, err)
|
||||||
|
}
|
||||||
|
path = filepath.Join(pwd, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewFileReadError(path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ func (cookies Cookies) GetValue(key string) *[]string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cookies *Cookies) Append(cookie ...Cookie) {
|
func (cookies *Cookies) Merge(cookie ...Cookie) {
|
||||||
for _, c := range cookie {
|
for _, c := range cookie {
|
||||||
if item := cookies.GetValue(c.Key); item != nil {
|
if item := cookies.GetValue(c.Key); item != nil {
|
||||||
*item = append(*item, c.Value...)
|
*item = append(*item, c.Value...)
|
||||||
@@ -27,7 +27,7 @@ func (cookies *Cookies) Append(cookie ...Cookie) {
|
|||||||
|
|
||||||
func (cookies *Cookies) Parse(rawValues ...string) {
|
func (cookies *Cookies) Parse(rawValues ...string) {
|
||||||
for _, rawValue := range rawValues {
|
for _, rawValue := range rawValues {
|
||||||
cookies.Append(*ParseCookie(rawValue))
|
*cookies = append(*cookies, *ParseCookie(rawValue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
// General
|
|
||||||
ErrNoError = errors.New("no error (internal)")
|
|
||||||
|
|
||||||
// CLI
|
|
||||||
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
|
|
||||||
)
|
|
||||||
|
|
||||||
// ======================================== General ========================================
|
// ======================================== General ========================================
|
||||||
|
|
||||||
|
var (
|
||||||
|
errNoError = errors.New("no error (internal)")
|
||||||
|
)
|
||||||
|
|
||||||
type FieldParseError struct {
|
type FieldParseError struct {
|
||||||
Field string
|
Field string
|
||||||
Value string
|
Value string
|
||||||
@@ -24,7 +20,7 @@ type FieldParseError struct {
|
|||||||
|
|
||||||
func NewFieldParseError(field string, value string, err error) FieldParseError {
|
func NewFieldParseError(field string, value string, err error) FieldParseError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = ErrNoError
|
err = errNoError
|
||||||
}
|
}
|
||||||
return FieldParseError{field, value, err}
|
return FieldParseError{field, value, err}
|
||||||
}
|
}
|
||||||
@@ -72,7 +68,7 @@ type FieldValidationError struct {
|
|||||||
|
|
||||||
func NewFieldValidationError(field string, value string, err error) FieldValidationError {
|
func NewFieldValidationError(field string, value string, err error) FieldValidationError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = ErrNoError
|
err = errNoError
|
||||||
}
|
}
|
||||||
return FieldValidationError{field, value, err}
|
return FieldValidationError{field, value, err}
|
||||||
}
|
}
|
||||||
@@ -118,7 +114,7 @@ type UnmarshalError struct {
|
|||||||
|
|
||||||
func NewUnmarshalError(err error) UnmarshalError {
|
func NewUnmarshalError(err error) UnmarshalError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = ErrNoError
|
err = errNoError
|
||||||
}
|
}
|
||||||
return UnmarshalError{err}
|
return UnmarshalError{err}
|
||||||
}
|
}
|
||||||
@@ -131,6 +127,160 @@ func (e UnmarshalError) Unwrap() error {
|
|||||||
return e.error
|
return e.error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======================================== General I/O ========================================
|
||||||
|
|
||||||
|
type FileReadError struct {
|
||||||
|
Path string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileReadError(path string, err error) FileReadError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return FileReadError{path, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e FileReadError) Error() string {
|
||||||
|
return fmt.Sprintf("failed to read file %s: %v", e.Path, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e FileReadError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPFetchError struct {
|
||||||
|
URL string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPFetchError(url string, err error) HTTPFetchError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return HTTPFetchError{url, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e HTTPFetchError) Error() string {
|
||||||
|
return fmt.Sprintf("failed to fetch %s: %v", e.URL, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e HTTPFetchError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPStatusError struct {
|
||||||
|
URL string
|
||||||
|
StatusCode int
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPStatusError(url string, statusCode int, status string) HTTPStatusError {
|
||||||
|
return HTTPStatusError{url, statusCode, status}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e HTTPStatusError) Error() string {
|
||||||
|
return fmt.Sprintf("HTTP %d %s (url: %s)", e.StatusCode, e.Status, e.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
type URLParseError struct {
|
||||||
|
URL string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewURLParseError(url string, err error) URLParseError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return URLParseError{url, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e URLParseError) Error() string {
|
||||||
|
return fmt.Sprintf("invalid URL %q: %v", e.URL, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e URLParseError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================== Template ========================================
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTemplateParseError(err error) TemplateParseError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return TemplateParseError{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e TemplateParseError) Error() string {
|
||||||
|
return "template parse error: " + e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e TemplateParseError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateRenderError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTemplateRenderError(err error) TemplateRenderError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return TemplateRenderError{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e TemplateRenderError) Error() string {
|
||||||
|
return "template rendering: " + e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e TemplateRenderError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
// ======================================== CLI ========================================
|
// ======================================== CLI ========================================
|
||||||
|
|
||||||
type CLIUnexpectedArgsError struct {
|
type CLIUnexpectedArgsError struct {
|
||||||
@@ -153,7 +303,7 @@ type ConfigFileReadError struct {
|
|||||||
|
|
||||||
func NewConfigFileReadError(err error) ConfigFileReadError {
|
func NewConfigFileReadError(err error) ConfigFileReadError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = ErrNoError
|
err = errNoError
|
||||||
}
|
}
|
||||||
return ConfigFileReadError{err}
|
return ConfigFileReadError{err}
|
||||||
}
|
}
|
||||||
@@ -168,6 +318,61 @@ func (e ConfigFileReadError) Unwrap() error {
|
|||||||
|
|
||||||
// ======================================== Proxy ========================================
|
// ======================================== Proxy ========================================
|
||||||
|
|
||||||
|
type ProxyUnsupportedSchemeError struct {
|
||||||
|
Scheme string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyUnsupportedSchemeError(scheme string) ProxyUnsupportedSchemeError {
|
||||||
|
return ProxyUnsupportedSchemeError{scheme}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyUnsupportedSchemeError) Error() string {
|
||||||
|
return "unsupported proxy scheme: " + e.Scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyParseError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyParseError(err error) ProxyParseError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ProxyParseError{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyParseError) Error() string {
|
||||||
|
return "failed to parse proxy URL: " + e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyParseError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyConnectError struct {
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyConnectError(status string) ProxyConnectError {
|
||||||
|
return ProxyConnectError{status}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyConnectError) Error() string {
|
||||||
|
return "proxy CONNECT failed: " + e.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyResolveError struct {
|
||||||
|
Host string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyResolveError(host string) ProxyResolveError {
|
||||||
|
return ProxyResolveError{host}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyResolveError) Error() string {
|
||||||
|
return "no IP addresses found for host: " + e.Host
|
||||||
|
}
|
||||||
|
|
||||||
type ProxyDialError struct {
|
type ProxyDialError struct {
|
||||||
Proxy string
|
Proxy string
|
||||||
Err error
|
Err error
|
||||||
@@ -175,7 +380,7 @@ type ProxyDialError struct {
|
|||||||
|
|
||||||
func NewProxyDialError(proxy string, err error) ProxyDialError {
|
func NewProxyDialError(proxy string, err error) ProxyDialError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = ErrNoError
|
err = errNoError
|
||||||
}
|
}
|
||||||
return ProxyDialError{proxy, err}
|
return ProxyDialError{proxy, err}
|
||||||
}
|
}
|
||||||
@@ -187,3 +392,174 @@ func (e ProxyDialError) Error() string {
|
|||||||
func (e ProxyDialError) Unwrap() error {
|
func (e ProxyDialError) Unwrap() error {
|
||||||
return e.Err
|
return e.Err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======================================== Script ========================================
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrScriptEmpty = errors.New("script cannot be empty")
|
||||||
|
ErrScriptSourceEmpty = errors.New("script source cannot be empty after @")
|
||||||
|
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
|
||||||
|
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
|
||||||
|
ErrScriptURLNoHost = errors.New("script URL must have a host")
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScriptLoadError struct {
|
||||||
|
Source string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptLoadError(source string, err error) ScriptLoadError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ScriptLoadError{source, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptLoadError) Error() string {
|
||||||
|
return fmt.Sprintf("failed to load script from %q: %v", e.Source, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptLoadError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptExecutionError struct {
|
||||||
|
EngineType string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptExecutionError(engineType string, err error) ScriptExecutionError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ScriptExecutionError{engineType, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptExecutionError) Error() string {
|
||||||
|
return fmt.Sprintf("%s script error: %v", e.EngineType, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptExecutionError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptChainError struct {
|
||||||
|
EngineType string
|
||||||
|
Index int
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptChainError(engineType string, index int, err error) ScriptChainError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ScriptChainError{engineType, index, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptChainError) Error() string {
|
||||||
|
return fmt.Sprintf("%s script[%d]: %v", e.EngineType, e.Index, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptChainError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptUnknownEngineError struct {
|
||||||
|
EngineType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptUnknownEngineError(engineType string) ScriptUnknownEngineError {
|
||||||
|
return ScriptUnknownEngineError{engineType}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (headers Headers) GetValue(key string) *[]string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (headers *Headers) Append(header ...Header) {
|
func (headers *Headers) Merge(header ...Header) {
|
||||||
for _, h := range header {
|
for _, h := range header {
|
||||||
if item := headers.GetValue(h.Key); item != nil {
|
if item := headers.GetValue(h.Key); item != nil {
|
||||||
*item = append(*item, h.Value...)
|
*item = append(*item, h.Value...)
|
||||||
@@ -36,7 +36,7 @@ func (headers *Headers) Append(header ...Header) {
|
|||||||
|
|
||||||
func (headers *Headers) Parse(rawValues ...string) {
|
func (headers *Headers) Parse(rawValues ...string) {
|
||||||
for _, rawValue := range rawValues {
|
for _, rawValue := range rawValues {
|
||||||
headers.Append(*ParseHeader(rawValue))
|
*headers = append(*headers, *ParseHeader(rawValue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func (params Params) GetValue(key string) *[]string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (params *Params) Append(param ...Param) {
|
func (params *Params) Merge(param ...Param) {
|
||||||
for _, p := range param {
|
for _, p := range param {
|
||||||
if item := params.GetValue(p.Key); item != nil {
|
if item := params.GetValue(p.Key); item != nil {
|
||||||
*item = append(*item, p.Value...)
|
*item = append(*item, p.Value...)
|
||||||
@@ -27,7 +27,7 @@ func (params *Params) Append(param ...Param) {
|
|||||||
|
|
||||||
func (params *Params) Parse(rawValues ...string) {
|
func (params *Params) Parse(rawValues ...string) {
|
||||||
for _, rawValue := range rawValues {
|
for _, rawValue := range rawValues {
|
||||||
params.Append(*ParseParam(rawValue))
|
*params = append(*params, *ParseParam(rawValue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +16,9 @@ func (proxies *Proxies) Append(proxy ...Proxy) {
|
|||||||
*proxies = append(*proxies, proxy...)
|
*proxies = append(*proxies, proxy...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse parses a raw proxy string and appends it to the list.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - ProxyParseError
|
||||||
func (proxies *Proxies) Parse(rawValue string) error {
|
func (proxies *Proxies) Parse(rawValue string) error {
|
||||||
parsedProxy, err := ParseProxy(rawValue)
|
parsedProxy, err := ParseProxy(rawValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -27,10 +29,13 @@ func (proxies *Proxies) Parse(rawValue string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseProxy parses a raw proxy URL string into a Proxy.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - ProxyParseError
|
||||||
func ParseProxy(rawValue string) (*Proxy, error) {
|
func ParseProxy(rawValue string) (*Proxy, error) {
|
||||||
urlParsed, err := url.Parse(rawValue)
|
urlParsed, err := url.Parse(rawValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse proxy URL: %w", err)
|
return nil, NewProxyParseError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyParsed := Proxy(*urlParsed)
|
proxyParsed := Proxy(*urlParsed)
|
||||||
|
|||||||
Reference in New Issue
Block a user