Compare commits

...
2 Commits
6 changed files with 79 additions and 40 deletions

No files matched your search

+1 -1
View File
@@ -75,7 +75,7 @@ func main() {
}), }),
) )
srn.Start(ctx, stopCtrl) srn.Start(stopCtrl)
switch *combinedConfig.Output { switch *combinedConfig.Output {
case config.ConfigOutputTypeNone: case config.ConfigOutputTypeNone:
+2 -2
View File
@@ -456,7 +456,7 @@ function transform(req)
end 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"}`). > **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`). Numbers and booleans are converted to text, other values are skipped.
> **Note:** Lua scripts can send their own HTTP requests with `http` and work with JSON through the global `json` table. See [HTTP Requests in Scripts](#http-requests-in-scripts). > **Note:** Lua scripts can send their own HTTP requests with `http` and work with JSON through the global `json` table. See [HTTP Requests in Scripts](#http-requests-in-scripts).
@@ -523,7 +523,7 @@ function transform(req) {
} }
``` ```
> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`). > **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`). Numbers and booleans are converted to text, other values are skipped.
> **Note:** JavaScript scripts can send their own HTTP requests with `http`. See [HTTP Requests in Scripts](#http-requests-in-scripts). > **Note:** JavaScript scripts can send their own HTTP requests with `http`. See [HTTP Requests in Scripts](#http-requests-in-scripts).
+19 -13
View File
@@ -199,6 +199,10 @@ type sarin struct {
logError bool logError bool
logFile string logFile string
// jobsCtx ends the run, either when the duration expires or on Ctrl+C.
jobsCtx context.Context //nolint:containedctx
jobsCancel context.CancelFunc
hostClients []*fasthttp.HostClient hostClients []*fasthttp.HostClient
scriptHTTPClients []*scriptHTTPClient scriptHTTPClients []*scriptHTTPClient
responses *SarinResponseData responses *SarinResponseData
@@ -272,15 +276,20 @@ func NewSarin(
scriptChain := script.NewChain(luaSources, jsSources) scriptChain := script.NewChain(luaSources, jsSources)
jobsCtx, jobsCancel := context.WithCancel(ctx)
var scriptHTTPClients []*scriptHTTPClient var scriptHTTPClients []*scriptHTTPClient
if !scriptChain.IsEmpty() { if !scriptChain.IsEmpty() {
scriptHTTPClients, err = newScriptHTTPClients(ctx, proxyURLs, workers) scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers)
if err != nil { if err != nil {
jobsCancel()
return nil, err return nil, err
} }
} }
srn := &sarin{ srn := &sarin{
jobsCtx: jobsCtx,
jobsCancel: jobsCancel,
workers: workers, workers: workers,
requestURL: requestURL, requestURL: requestURL,
methods: methods, methods: methods,
@@ -316,9 +325,7 @@ func (s sarin) GetResponses() *SarinResponseData {
return s.responses return s.responses
} }
func (s sarin) Start(ctx context.Context, stopCtrl *StopController) { func (s sarin) Start(stopCtrl *StopController) {
jobsCtx, jobsCancel := context.WithCancel(ctx)
var workersWG sync.WaitGroup var workersWG sync.WaitGroup
jobsCh := make(chan struct{}, max(s.workers, 1)) jobsCh := make(chan struct{}, max(s.workers, 1))
@@ -382,15 +389,14 @@ func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
s.startWorkers(&workersWG, jobsCh, &counter, sendLog, sendRespLog) s.startWorkers(&workersWG, jobsCh, &counter, sendLog, sendRespLog)
if runTUI { if runTUI {
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar) go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar)
} }
// Setup duration-based cancellation // Setup duration-based cancellation
s.setupDurationTimeout(ctx, jobsCancel) s.setupDurationTimeout()
// Distribute jobs to workers. // Distribute jobs to workers.
// This blocks until all jobs are sent or the context is canceled. // This blocks until all jobs are sent or the context is canceled.
s.sendJobs(jobsCtx, jobsCh) s.sendJobs(jobsCh)
// Close the jobs channel so workers stop after completing their current job // Close the jobs channel so workers stop after completing their current job
close(jobsCh) close(jobsCh)
@@ -455,31 +461,31 @@ func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, counter *a
} }
} }
func (s sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) { func (s sarin) setupDurationTimeout() {
if s.totalDuration != nil { if s.totalDuration != nil {
go func() { go func() {
timer := time.NewTimer(*s.totalDuration) timer := time.NewTimer(*s.totalDuration)
defer timer.Stop() defer timer.Stop()
select { select {
case <-timer.C: case <-timer.C:
cancel() s.jobsCancel()
case <-ctx.Done(): case <-s.jobsCtx.Done():
// Context cancelled, cleanup // Context cancelled, cleanup
} }
}() }()
} }
} }
func (s sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) { func (s sarin) sendJobs(jobs chan<- struct{}) {
if s.totalRequests != nil && *s.totalRequests > 0 { if s.totalRequests != nil && *s.totalRequests > 0 {
for range *s.totalRequests { for range *s.totalRequests {
if ctx.Err() != nil { if s.jobsCtx.Err() != nil {
break break
} }
jobs <- struct{}{} jobs <- struct{}{}
} }
} else { } else {
for ctx.Err() == nil { for s.jobsCtx.Err() == nil {
jobs <- struct{}{} jobs <- struct{}{}
} }
} }
+9 -3
View File
@@ -17,6 +17,7 @@ const scriptHTTPDefaultTimeout = 30 * time.Second
// scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use. // scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use.
type scriptHTTPClient struct { type scriptHTTPClient struct {
ctx context.Context //nolint:containedctx
client *fasthttp.Client client *fasthttp.Client
insecureClient *fasthttp.Client insecureClient *fasthttp.Client
defaultTimeout time.Duration defaultTimeout time.Duration
@@ -29,7 +30,7 @@ var _ script.HTTPDoer = (*scriptHTTPClient)(nil)
// - types.ProxyDialError // - types.ProxyDialError
func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint) ([]*scriptHTTPClient, error) { func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint) ([]*scriptHTTPClient, error) {
if len(proxies) == 0 { if len(proxies) == 0 {
return []*scriptHTTPClient{newScriptHTTPClient(fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns)}, nil return []*scriptHTTPClient{newScriptHTTPClient(ctx, fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns)}, nil
} }
clients := make([]*scriptHTTPClient, 0, len(proxies)) clients := make([]*scriptHTTPClient, 0, len(proxies))
@@ -38,12 +39,12 @@ func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint)
if err != nil { if err != nil {
return nil, types.NewProxyDialError(proxy.String(), err) return nil, types.NewProxyDialError(proxy.String(), err)
} }
clients = append(clients, newScriptHTTPClient(dial, scriptHTTPDefaultTimeout, maxConns)) clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns))
} }
return clients, nil return clients, nil
} }
func newScriptHTTPClient(dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint) *scriptHTTPClient { func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint) *scriptHTTPClient {
newClient := func(insecure bool) *fasthttp.Client { newClient := func(insecure bool) *fasthttp.Client {
// Read and write timeouts stay unset because they would cap the per-call timeout, // Read and write timeouts stay unset because they would cap the per-call timeout,
// so dialWithDeadline is what bounds the TLS handshake. // so dialWithDeadline is what bounds the TLS handshake.
@@ -60,6 +61,7 @@ func newScriptHTTPClient(dial fasthttp.DialFuncWithTimeout, defaultTimeout time.
} }
return &scriptHTTPClient{ return &scriptHTTPClient{
ctx: ctx,
client: newClient(false), client: newClient(false),
insecureClient: newClient(true), insecureClient: newClient(true),
defaultTimeout: defaultTimeout, defaultTimeout: defaultTimeout,
@@ -91,6 +93,10 @@ func dialWithDeadline(dial fasthttp.DialFuncWithTimeout) fasthttp.DialFuncWithTi
// It can return the following errors: // It can return the following errors:
// - types.ScriptHTTPRequestError // - types.ScriptHTTPRequestError
func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, error) { func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, error) {
if err := c.ctx.Err(); err != nil {
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
}
parsedURL, err := url.Parse(r.URL) parsedURL, err := url.Parse(r.URL)
if err != nil { if err != nil {
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
+24 -10
View File
@@ -2,6 +2,7 @@ package script
import ( import (
"errors" "errors"
"strconv"
"github.com/dop251/goja" "github.com/dop251/goja"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
@@ -195,22 +196,23 @@ func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string
result := make(map[string][]string) result := make(map[string][]string)
for _, key := range obj.Keys() { for _, key := range obj.Keys() {
v := obj.Get(key) v := obj.Get(key)
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
continue
}
// Check if it's an array // Check if it's an array
if arr, ok := v.Export().([]any); ok { if arr, ok := v.(*goja.Object); ok && arr.ClassName() == "Array" {
var values []string length := int(arr.Get("length").ToInteger())
for _, item := range arr { values := make([]string, 0, length)
if s, ok := item.(string); ok { for i := range length {
values = append(values, s) if text, ok := jsStringValue(arr.Get(strconv.Itoa(i))); ok {
values = append(values, text)
} }
} }
result[key] = values result[key] = values
} else { continue
}
// Single value, wrap it in a slice // Single value, wrap it in a slice
result[key] = []string{v.String()} if text, ok := jsStringValue(v); ok {
result[key] = []string{text}
} }
} }
return result return result
@@ -231,3 +233,15 @@ func (e *JsEngine) exceptionError(err error) error {
} }
return errors.New(message) return errors.New(message)
} }
// jsStringValue renders a JavaScript primitive the way the script would see it.
// Objects, arrays and functions have no useful text form, so they are skipped.
func jsStringValue(v goja.Value) (string, bool) {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return "", false
}
if _, isObject := v.(*goja.Object); isObject {
return "", false
}
return v.String(), true
}
+24 -11
View File
@@ -168,20 +168,33 @@ func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string {
} }
key := string(k.(lua.LString)) key := string(k.(lua.LString))
switch v.Type() { if v.Type() == lua.LTTable {
case lua.LTString: // Array of values, keys outside 1..n are skipped
// Single string value arr := v.(*lua.LTable)
result[key] = []string{string(v.(lua.LString))} length := arr.Len()
case lua.LTTable: values := make([]string, 0, length)
// Array of strings for i := 1; i <= length; i++ {
var values []string if text, ok := luaScalarString(arr.RawGetInt(i)); ok {
v.(*lua.LTable).ForEach(func(_, item lua.LValue) { values = append(values, text)
if item.Type() == lua.LTString { }
values = append(values, string(item.(lua.LString)))
} }
})
result[key] = values result[key] = values
return
}
if text, ok := luaScalarString(v); ok {
result[key] = []string{text}
} }
}) })
return result return result
} }
// luaScalarString renders strings, numbers and booleans as text.
func luaScalarString(v lua.LValue) (string, bool) {
switch v.Type() {
case lua.LTString, lua.LTNumber, lua.LTBool:
return v.String(), true
default:
return "", false
}
}