mirror of
https://github.com/aykhans/sarin.git
synced 2026-04-18 13:49:37 +00:00
Compare commits
11 Commits
cea692cf1b
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f24aea819f | |||
| e0db4df17a | |||
| 475dda98ff | |||
| dcb0e3171f | |||
| 2eebac68c9 | |||
| e62fd33f9c | |||
| e9b9b8890c | |||
| 8577c771e4 | |||
| c839b71c9e | |||
| 0e0ef72778 | |||
|
|
8d10198f02 |
2
.github/workflows/lint.yaml
vendored
2
.github/workflows/lint.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.26.1
|
||||
go-version: 1.26.2
|
||||
- name: go fix
|
||||
run: |
|
||||
go fix ./...
|
||||
|
||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
run: |
|
||||
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
|
||||
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||
echo "GO_VERSION=1.26.1" >> $GITHUB_ENV
|
||||
echo "GO_VERSION=1.26.2" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Go
|
||||
if: github.event_name == 'release' || inputs.build_binaries
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ARG GO_VERSION=1.26.1
|
||||
ARG GO_VERSION=1.26.2
|
||||
|
||||
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
## 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 |
|
||||
| ---------------------------------------------------------- | ------------------------------- |
|
||||
@@ -106,7 +106,7 @@ For detailed documentation on all configuration options (URL, method, timeout, c
|
||||
|
||||
## Templating
|
||||
|
||||
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 320+ built-in functions to generate dynamic data for each request.
|
||||
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 340+ built-in functions to generate dynamic data for each request.
|
||||
|
||||
**Example:**
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go listenForTermination(func() { cancel() })
|
||||
stopCtrl := sarin.NewStopController(cancel)
|
||||
go listenForTermination(stopCtrl.Stop)
|
||||
|
||||
combinedConfig := config.ReadAllConfigs()
|
||||
|
||||
@@ -73,7 +74,7 @@ func main() {
|
||||
}),
|
||||
)
|
||||
|
||||
srn.Start(ctx)
|
||||
srn.Start(ctx, stopCtrl)
|
||||
|
||||
switch *combinedConfig.Output {
|
||||
case config.ConfigOutputTypeNone:
|
||||
@@ -87,9 +88,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func listenForTermination(do func()) {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
func listenForTermination(stop func()) {
|
||||
sigChan := make(chan os.Signal, 4)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
do()
|
||||
for range sigChan {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Configuration
|
||||
|
||||
Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalent—YAML files have the most configuration options, followed by CLI flags, and then environment variables.
|
||||
Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalent: YAML files have the most configuration options, followed by CLI flags, and then environment variables.
|
||||
|
||||
When the same option is specified in multiple sources, the following priority order applies:
|
||||
|
||||
@@ -107,9 +107,9 @@ If all four files define `url`, the value from `config3.yaml` wins.
|
||||
|
||||
**Merge behavior by field:**
|
||||
|
||||
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.) — higher priority overrides lower priority
|
||||
- **Method and Body** — higher priority overrides lower priority (no merging)
|
||||
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js** — accumulated across all config files
|
||||
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.): higher priority overrides lower priority
|
||||
- **Method and Body**: higher priority overrides lower priority (no merging)
|
||||
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js**: accumulated across all config files
|
||||
|
||||
## URL
|
||||
|
||||
@@ -408,7 +408,7 @@ SARIN_VALUES="key1=value1"
|
||||
|
||||
Lua script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent.
|
||||
|
||||
If multiple Lua scripts are provided, they are chained in order—the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||
If multiple Lua scripts are provided, they are chained in order-the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||
|
||||
**Script sources:**
|
||||
|
||||
@@ -473,7 +473,7 @@ SARIN_LUA='function transform(req) req.headers["X-Custom"] = "my-value" return r
|
||||
|
||||
JavaScript script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent.
|
||||
|
||||
If multiple JavaScript scripts are provided, they are chained in order—the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||
If multiple JavaScript scripts are provided, they are chained in order-the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||
|
||||
**Script sources:**
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ Captcha functions solve a captcha challenge through a third-party solving servic
|
||||
|
||||
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:**
|
||||
> **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.
|
||||
|
||||
10
go.mod
10
go.mod
@@ -1,6 +1,6 @@
|
||||
module go.aykhans.me/sarin
|
||||
|
||||
go 1.26.1
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.1
|
||||
@@ -15,7 +15,7 @@ require (
|
||||
github.com/yuin/gopher-lua v1.1.2
|
||||
go.aykhans.me/utils v1.0.7
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/net v0.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -52,7 +52,7 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
)
|
||||
|
||||
16
go.sum
16
go.sum
@@ -109,16 +109,16 @@ go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
|
||||
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/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
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=
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
|
||||
const (
|
||||
captchaPollInterval = 1 * time.Second
|
||||
captchaTimeout = 120 * time.Second
|
||||
captchaPollTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
var captchaHTTPClient = &http.Client{Timeout: captchaTimeout}
|
||||
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.
|
||||
@@ -31,8 +31,9 @@ var captchaHTTPClient = &http.Client{Timeout: captchaTimeout}
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaTimeoutError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey string, taskIDIsString bool) (string, error) {
|
||||
if apiKey == "" {
|
||||
@@ -52,6 +53,7 @@ func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey strin
|
||||
//
|
||||
// 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{
|
||||
@@ -61,7 +63,7 @@ func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, err
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", types.NewCaptchaRequestError("createTask", err)
|
||||
return "", types.NewCaptchaDecodeError("createTask", err)
|
||||
}
|
||||
|
||||
resp, err := captchaHTTPClient.Post(
|
||||
@@ -81,7 +83,7 @@ func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, err
|
||||
TaskID json.RawMessage `json:"taskId"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", types.NewCaptchaRequestError("createTask", err)
|
||||
return "", types.NewCaptchaDecodeError("createTask", err)
|
||||
}
|
||||
|
||||
if result.ErrorID != 0 {
|
||||
@@ -98,15 +100,15 @@ func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, err
|
||||
}
|
||||
|
||||
// captchaPollResult polls the getTaskResult endpoint at captchaPollInterval until the task
|
||||
// is solved, an error is returned by the service, or the overall captchaTimeout is hit.
|
||||
// is solved, an error is returned by the service, or the overall captchaPollTimeout is hit.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.CaptchaTimeoutError
|
||||
// - types.CaptchaRequestError
|
||||
// - 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(), captchaTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), captchaPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(captchaPollInterval)
|
||||
@@ -115,12 +117,18 @@ func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsStri
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", types.NewCaptchaTimeoutError(taskID)
|
||||
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
|
||||
}
|
||||
@@ -134,6 +142,7 @@ func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsStri
|
||||
// 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) {
|
||||
@@ -146,7 +155,7 @@ func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsS
|
||||
|
||||
data, err := json.Marshal(bodyMap)
|
||||
if err != nil {
|
||||
return "", types.NewCaptchaRequestError("getTaskResult", err)
|
||||
return "", types.NewCaptchaDecodeError("getTaskResult", err)
|
||||
}
|
||||
|
||||
resp, err := captchaHTTPClient.Post(
|
||||
@@ -167,7 +176,7 @@ func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsS
|
||||
Solution map[string]any `json:"solution"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", types.NewCaptchaRequestError("getTaskResult", err)
|
||||
return "", types.NewCaptchaDecodeError("getTaskResult", err)
|
||||
}
|
||||
|
||||
if result.ErrorID != 0 {
|
||||
@@ -199,8 +208,9 @@ const twoCaptchaBaseURL = "https://api.2captcha.com"
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaTimeoutError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
|
||||
return solveCaptcha(twoCaptchaBaseURL, apiKey, map[string]any{
|
||||
@@ -216,8 +226,9 @@ func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string,
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaTimeoutError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
|
||||
task := map[string]any{
|
||||
@@ -237,8 +248,9 @@ func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction strin
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaTimeoutError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
|
||||
task := map[string]any{
|
||||
@@ -261,8 +273,9 @@ const antiCaptchaBaseURL = "https://api.anti-captcha.com"
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaTimeoutError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
|
||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
|
||||
@@ -279,8 +292,9 @@ func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string,
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaTimeoutError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
|
||||
task := map[string]any{
|
||||
@@ -301,8 +315,9 @@ func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction stri
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaTimeoutError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, error) {
|
||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
|
||||
@@ -318,8 +333,9 @@ func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, er
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaTimeoutError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
|
||||
task := map[string]any{
|
||||
@@ -342,8 +358,9 @@ const capSolverBaseURL = "https://api.capsolver.com"
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaTimeoutError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
|
||||
return solveCaptcha(capSolverBaseURL, apiKey, map[string]any{
|
||||
@@ -359,8 +376,9 @@ func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, e
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaTimeoutError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
|
||||
task := map[string]any{
|
||||
@@ -380,8 +398,9 @@ func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaTimeoutError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
|
||||
task := map[string]any{
|
||||
|
||||
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,816 +0,0 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/term"
|
||||
"github.com/valyala/fasthttp"
|
||||
"go.aykhans.me/sarin/internal/script"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
)
|
||||
|
||||
type runtimeMessageLevel uint8
|
||||
|
||||
const (
|
||||
runtimeMessageLevelWarning runtimeMessageLevel = iota
|
||||
runtimeMessageLevelError
|
||||
)
|
||||
|
||||
type runtimeMessage struct {
|
||||
timestamp time.Time
|
||||
level runtimeMessageLevel
|
||||
text string
|
||||
}
|
||||
|
||||
type messageSender func(level runtimeMessageLevel, text string)
|
||||
|
||||
type sarin struct {
|
||||
workers uint
|
||||
requestURL *url.URL
|
||||
methods []string
|
||||
params types.Params
|
||||
headers types.Headers
|
||||
cookies types.Cookies
|
||||
bodies []string
|
||||
totalRequests *uint64
|
||||
totalDuration *time.Duration
|
||||
timeout time.Duration
|
||||
quiet bool
|
||||
skipCertVerify bool
|
||||
values []string
|
||||
collectStats bool
|
||||
dryRun bool
|
||||
|
||||
hostClients []*fasthttp.HostClient
|
||||
responses *SarinResponseData
|
||||
fileCache *FileCache
|
||||
scriptChain *script.Chain
|
||||
}
|
||||
|
||||
// NewSarin creates a new sarin instance for load testing.
|
||||
// It can return the following errors:
|
||||
// - types.ProxyDialError
|
||||
// - types.ErrScriptEmpty
|
||||
// - types.ScriptLoadError
|
||||
func NewSarin(
|
||||
ctx context.Context,
|
||||
methods []string,
|
||||
requestURL *url.URL,
|
||||
timeout time.Duration,
|
||||
workers uint,
|
||||
totalRequests *uint64,
|
||||
totalDuration *time.Duration,
|
||||
quiet bool,
|
||||
skipCertVerify bool,
|
||||
params types.Params,
|
||||
headers types.Headers,
|
||||
cookies types.Cookies,
|
||||
bodies []string,
|
||||
proxies types.Proxies,
|
||||
values []string,
|
||||
collectStats bool,
|
||||
dryRun bool,
|
||||
luaScripts []string,
|
||||
jsScripts []string,
|
||||
) (*sarin, error) {
|
||||
if workers == 0 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load script sources
|
||||
luaSources, err := script.LoadSources(ctx, luaScripts, script.EngineTypeLua)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jsSources, err := script.LoadSources(ctx, jsScripts, script.EngineTypeJavaScript)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scriptChain := script.NewChain(luaSources, jsSources)
|
||||
|
||||
srn := &sarin{
|
||||
workers: workers,
|
||||
requestURL: requestURL,
|
||||
methods: methods,
|
||||
params: params,
|
||||
headers: headers,
|
||||
cookies: cookies,
|
||||
bodies: bodies,
|
||||
totalRequests: totalRequests,
|
||||
totalDuration: totalDuration,
|
||||
timeout: timeout,
|
||||
quiet: quiet,
|
||||
skipCertVerify: skipCertVerify,
|
||||
values: values,
|
||||
collectStats: collectStats,
|
||||
dryRun: dryRun,
|
||||
hostClients: hostClients,
|
||||
fileCache: NewFileCache(time.Second * 10),
|
||||
scriptChain: scriptChain,
|
||||
}
|
||||
|
||||
if collectStats {
|
||||
srn.responses = NewSarinResponseData(uint32(100))
|
||||
}
|
||||
|
||||
return srn, nil
|
||||
}
|
||||
|
||||
func (q sarin) GetResponses() *SarinResponseData {
|
||||
return q.responses
|
||||
}
|
||||
|
||||
func (q sarin) Start(ctx context.Context) {
|
||||
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
||||
|
||||
var workersWG sync.WaitGroup
|
||||
jobsCh := make(chan struct{}, max(q.workers, 1))
|
||||
|
||||
var counter atomic.Uint64
|
||||
|
||||
totalRequests := uint64(0)
|
||||
if q.totalRequests != nil {
|
||||
totalRequests = *q.totalRequests
|
||||
}
|
||||
|
||||
var streamCtx context.Context
|
||||
var streamCancel context.CancelFunc
|
||||
var streamCh chan struct{}
|
||||
var messageChannel chan runtimeMessage
|
||||
var sendMessage messageSender
|
||||
|
||||
if !q.quiet && !term.IsTerminal(os.Stdout.Fd()) {
|
||||
q.quiet = true
|
||||
}
|
||||
|
||||
if q.quiet {
|
||||
sendMessage = func(level runtimeMessageLevel, text string) {}
|
||||
} else {
|
||||
streamCtx, streamCancel = context.WithCancel(context.Background())
|
||||
defer streamCancel()
|
||||
streamCh = make(chan struct{})
|
||||
messageChannel = make(chan runtimeMessage, max(q.workers, 1))
|
||||
sendMessage = func(level runtimeMessageLevel, text string) {
|
||||
messageChannel <- runtimeMessage{
|
||||
timestamp: time.Now(),
|
||||
level: level,
|
||||
text: text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start workers
|
||||
q.startWorkers(&workersWG, jobsCh, q.hostClients, &counter, sendMessage)
|
||||
|
||||
if !q.quiet {
|
||||
// Start streaming to terminal
|
||||
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
|
||||
go q.streamProgress(streamCtx, jobsCancel, streamCh, totalRequests, &counter, messageChannel)
|
||||
}
|
||||
|
||||
// Setup duration-based cancellation
|
||||
q.setupDurationTimeout(ctx, jobsCancel)
|
||||
// Distribute jobs to workers.
|
||||
// This blocks until all jobs are sent or the context is canceled.
|
||||
q.sendJobs(jobsCtx, jobsCh)
|
||||
|
||||
// Close the jobs channel so workers stop after completing their current job
|
||||
close(jobsCh)
|
||||
// Wait until all workers stopped
|
||||
workersWG.Wait()
|
||||
if messageChannel != nil {
|
||||
close(messageChannel)
|
||||
}
|
||||
|
||||
if !q.quiet {
|
||||
// Stop the progress streaming
|
||||
streamCancel()
|
||||
// Wait until progress streaming has completely stopped
|
||||
<-streamCh
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) Worker(
|
||||
jobs <-chan struct{},
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
req := fasthttp.AcquireRequest()
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
// Create script transformer for this worker (engines are not thread-safe)
|
||||
// Scripts are pre-validated in NewSarin, so this should not fail
|
||||
var scriptTransformer *script.Transformer
|
||||
if !q.scriptChain.IsEmpty() {
|
||||
var err error
|
||||
scriptTransformer, err = q.scriptChain.NewTransformer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer scriptTransformer.Close()
|
||||
}
|
||||
|
||||
requestGenerator, isDynamic := NewRequestGenerator(
|
||||
q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache, scriptTransformer,
|
||||
)
|
||||
|
||||
if q.dryRun {
|
||||
switch {
|
||||
case q.collectStats && isDynamic:
|
||||
q.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
|
||||
case q.collectStats && !isDynamic:
|
||||
q.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
|
||||
case !q.collectStats && isDynamic:
|
||||
q.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
|
||||
default:
|
||||
q.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
case q.collectStats && isDynamic:
|
||||
q.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
||||
case q.collectStats && !isDynamic:
|
||||
q.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
||||
case !q.collectStats && isDynamic:
|
||||
q.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
||||
default:
|
||||
q.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
resp.Reset()
|
||||
|
||||
if err := requestGenerator(req); err != nil {
|
||||
q.responses.Add(err.Error(), 0)
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
||||
if err != nil {
|
||||
q.responses.Add(err.Error(), time.Since(startTime))
|
||||
} else {
|
||||
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
|
||||
}
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerStatsWithStatic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
// Static request generation failed - record all jobs as errors
|
||||
for range jobs {
|
||||
q.responses.Add(err.Error(), 0)
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
resp.Reset()
|
||||
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
||||
if err != nil {
|
||||
q.responses.Add(err.Error(), time.Since(startTime))
|
||||
} else {
|
||||
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
|
||||
}
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerNoStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
resp.Reset()
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerNoStatsWithStatic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
|
||||
// Static request generation failed - just count the jobs without sending
|
||||
for range jobs {
|
||||
counter.Add(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
resp.Reset()
|
||||
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
const dryRunResponseKey = "dry-run"
|
||||
|
||||
// statusCodeStrings contains pre-computed string representations for HTTP status codes 100-599.
|
||||
var statusCodeStrings = func() map[int]string {
|
||||
m := make(map[int]string, 500)
|
||||
for i := 100; i < 600; i++ {
|
||||
m[i] = strconv.Itoa(i)
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// statusCodeToString returns a string representation of the HTTP status code.
|
||||
// Uses a pre-computed map for codes 100-599, falls back to strconv.Itoa for others.
|
||||
func statusCodeToString(code int) string {
|
||||
if s, ok := statusCodeStrings[code]; ok {
|
||||
return s
|
||||
}
|
||||
return strconv.Itoa(code)
|
||||
}
|
||||
|
||||
func (q sarin) workerDryRunStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
startTime := time.Now()
|
||||
if err := requestGenerator(req); err != nil {
|
||||
q.responses.Add(err.Error(), time.Since(startTime))
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
q.responses.Add(dryRunResponseKey, time.Since(startTime))
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerDryRunStatsWithStatic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
// Static request generation failed - record all jobs as errors
|
||||
for range jobs {
|
||||
q.responses.Add(err.Error(), 0)
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
q.responses.Add(dryRunResponseKey, 0)
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerDryRunNoStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
}
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) workerDryRunNoStatsWithStatic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendMessage messageSender,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendMessage(runtimeMessageLevelError, err.Error())
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
counter.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// newHostClients initializes HTTP clients for the given configuration.
|
||||
// It can return the following errors:
|
||||
// - types.ProxyDialError
|
||||
func newHostClients(
|
||||
ctx context.Context,
|
||||
timeout time.Duration,
|
||||
proxies types.Proxies,
|
||||
workers uint,
|
||||
requestURL *url.URL,
|
||||
skipCertVerify bool,
|
||||
) ([]*fasthttp.HostClient, error) {
|
||||
proxiesRaw := make([]url.URL, len(proxies))
|
||||
for i, proxy := range proxies {
|
||||
proxiesRaw[i] = url.URL(proxy)
|
||||
}
|
||||
|
||||
return NewHostClients(
|
||||
ctx,
|
||||
timeout,
|
||||
proxiesRaw,
|
||||
workers,
|
||||
requestURL,
|
||||
skipCertVerify,
|
||||
)
|
||||
}
|
||||
|
||||
func (q sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendMessage messageSender) {
|
||||
for range max(q.workers, 1) {
|
||||
wg.Go(func() {
|
||||
q.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
|
||||
if q.totalDuration != nil {
|
||||
go func() {
|
||||
timer := time.NewTimer(*q.totalDuration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
// Context cancelled, cleanup
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (q sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
|
||||
if q.totalRequests != nil && *q.totalRequests > 0 {
|
||||
for range *q.totalRequests {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
jobs <- struct{}{}
|
||||
}
|
||||
} else {
|
||||
for ctx.Err() == nil {
|
||||
jobs <- struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
var (
|
||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
|
||||
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
|
||||
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD93D")).Bold(true)
|
||||
messageChannelStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder(), false, false, false, true).
|
||||
BorderForeground(lipgloss.Color("#757575")).
|
||||
PaddingLeft(1).
|
||||
Margin(1, 0, 0, 0).
|
||||
Foreground(lipgloss.Color("#888888"))
|
||||
)
|
||||
|
||||
type progressModel struct {
|
||||
progress progress.Model
|
||||
startTime time.Time
|
||||
messages []string
|
||||
counter *atomic.Uint64
|
||||
current uint64
|
||||
maxValue uint64
|
||||
ctx context.Context //nolint:containedctx
|
||||
cancel context.CancelFunc
|
||||
cancelling bool
|
||||
}
|
||||
|
||||
func (m progressModel) Init() tea.Cmd {
|
||||
return tea.Batch(progressTickCmd())
|
||||
}
|
||||
|
||||
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
m.cancelling = true
|
||||
m.cancel()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.progress.Width = max(10, msg.Width-1)
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case runtimeMessage:
|
||||
var msgBuilder strings.Builder
|
||||
msgBuilder.WriteString("[")
|
||||
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
|
||||
msgBuilder.WriteString("] ")
|
||||
switch msg.level {
|
||||
case runtimeMessageLevelError:
|
||||
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
|
||||
case runtimeMessageLevelWarning:
|
||||
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
|
||||
}
|
||||
msgBuilder.WriteString(msg.text)
|
||||
m.messages = append(m.messages[1:], msgBuilder.String())
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, progressTickCmd()
|
||||
|
||||
default:
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m progressModel) View() string {
|
||||
var messagesBuilder strings.Builder
|
||||
for i, msg := range m.messages {
|
||||
if len(msg) > 0 {
|
||||
messagesBuilder.WriteString(msg)
|
||||
if i < len(m.messages)-1 {
|
||||
messagesBuilder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var finalBuilder strings.Builder
|
||||
if messagesBuilder.Len() > 0 {
|
||||
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
|
||||
finalBuilder.WriteString("\n")
|
||||
}
|
||||
|
||||
m.current = m.counter.Load()
|
||||
finalBuilder.WriteString("\n ")
|
||||
finalBuilder.WriteString(strconv.FormatUint(m.current, 10))
|
||||
finalBuilder.WriteString("/")
|
||||
finalBuilder.WriteString(strconv.FormatUint(m.maxValue, 10))
|
||||
finalBuilder.WriteString(" - ")
|
||||
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||
finalBuilder.WriteString("\n ")
|
||||
finalBuilder.WriteString(m.progress.ViewAs(float64(m.current) / float64(m.maxValue)))
|
||||
finalBuilder.WriteString("\n\n ")
|
||||
if m.cancelling {
|
||||
finalBuilder.WriteString(helpStyle.Render("Stopping..."))
|
||||
} else {
|
||||
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
|
||||
}
|
||||
return finalBuilder.String()
|
||||
}
|
||||
|
||||
func progressTickCmd() tea.Cmd {
|
||||
return tea.Tick(time.Millisecond*250, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
var infiniteProgressStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00D4FF"))
|
||||
|
||||
type infiniteProgressModel struct {
|
||||
spinner spinner.Model
|
||||
startTime time.Time
|
||||
counter *atomic.Uint64
|
||||
messages []string
|
||||
ctx context.Context //nolint:containedctx
|
||||
quit bool
|
||||
cancel context.CancelFunc
|
||||
cancelling bool
|
||||
}
|
||||
|
||||
func (m infiniteProgressModel) Init() tea.Cmd {
|
||||
return m.spinner.Tick
|
||||
}
|
||||
|
||||
func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
m.cancelling = true
|
||||
m.cancel()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case runtimeMessage:
|
||||
var msgBuilder strings.Builder
|
||||
msgBuilder.WriteString("[")
|
||||
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
|
||||
msgBuilder.WriteString("] ")
|
||||
switch msg.level {
|
||||
case runtimeMessageLevelError:
|
||||
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
|
||||
case runtimeMessageLevelWarning:
|
||||
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
|
||||
}
|
||||
msgBuilder.WriteString(msg.text)
|
||||
m.messages = append(m.messages[1:], msgBuilder.String())
|
||||
if m.ctx.Err() != nil {
|
||||
m.quit = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
|
||||
default:
|
||||
if m.ctx.Err() != nil {
|
||||
m.quit = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
func (m infiniteProgressModel) View() string {
|
||||
var messagesBuilder strings.Builder
|
||||
for i, msg := range m.messages {
|
||||
if len(msg) > 0 {
|
||||
messagesBuilder.WriteString(msg)
|
||||
if i < len(m.messages)-1 {
|
||||
messagesBuilder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var finalBuilder strings.Builder
|
||||
if messagesBuilder.Len() > 0 {
|
||||
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
|
||||
finalBuilder.WriteString("\n")
|
||||
}
|
||||
|
||||
if m.quit {
|
||||
finalBuilder.WriteString("\n ")
|
||||
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
||||
finalBuilder.WriteString(" ")
|
||||
finalBuilder.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
|
||||
finalBuilder.WriteString(" ")
|
||||
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||
finalBuilder.WriteString("\n\n")
|
||||
} else {
|
||||
finalBuilder.WriteString("\n ")
|
||||
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
||||
finalBuilder.WriteString(" ")
|
||||
finalBuilder.WriteString(m.spinner.View())
|
||||
finalBuilder.WriteString(" ")
|
||||
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||
finalBuilder.WriteString("\n\n ")
|
||||
if m.cancelling {
|
||||
finalBuilder.WriteString(helpStyle.Render("Stopping..."))
|
||||
} else {
|
||||
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
|
||||
}
|
||||
}
|
||||
return finalBuilder.String()
|
||||
}
|
||||
|
||||
func (q sarin) streamProgress(
|
||||
ctx context.Context,
|
||||
cancel context.CancelFunc,
|
||||
done chan<- struct{},
|
||||
total uint64,
|
||||
counter *atomic.Uint64,
|
||||
messageChannel <-chan runtimeMessage,
|
||||
) {
|
||||
var program *tea.Program
|
||||
if total > 0 {
|
||||
model := progressModel{
|
||||
progress: progress.New(progress.WithGradient("#151594", "#00D4FF")),
|
||||
startTime: time.Now(),
|
||||
messages: make([]string, 8),
|
||||
counter: counter,
|
||||
current: 0,
|
||||
maxValue: total,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
program = tea.NewProgram(model)
|
||||
} else {
|
||||
model := infiniteProgressModel{
|
||||
spinner: spinner.New(
|
||||
spinner.WithSpinner(
|
||||
spinner.Spinner{
|
||||
Frames: []string{
|
||||
"●∙∙∙∙",
|
||||
"∙●∙∙∙",
|
||||
"∙∙●∙∙",
|
||||
"∙∙∙●∙",
|
||||
"∙∙∙∙●",
|
||||
"∙∙∙●∙",
|
||||
"∙∙●∙∙",
|
||||
"∙●∙∙∙",
|
||||
},
|
||||
FPS: time.Second / 8, //nolint:mnd
|
||||
},
|
||||
),
|
||||
spinner.WithStyle(infiniteProgressStyle),
|
||||
),
|
||||
startTime: time.Now(),
|
||||
counter: counter,
|
||||
messages: make([]string, 8),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
quit: false,
|
||||
}
|
||||
|
||||
program = tea.NewProgram(model)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for msg := range messageChannel {
|
||||
program.Send(msg)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := program.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
done <- struct{}{}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -520,15 +520,35 @@ func (e CaptchaRequestError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type CaptchaTimeoutError struct {
|
||||
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 NewCaptchaTimeoutError(taskID string) CaptchaTimeoutError {
|
||||
return CaptchaTimeoutError{TaskID: taskID}
|
||||
func NewCaptchaPollTimeoutError(taskID string) CaptchaPollTimeoutError {
|
||||
return CaptchaPollTimeoutError{TaskID: taskID}
|
||||
}
|
||||
|
||||
func (e CaptchaTimeoutError) Error() string {
|
||||
func (e CaptchaPollTimeoutError) Error() string {
|
||||
return fmt.Sprintf("captcha solving timed out (taskId: %s)", e.TaskID)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user