mirror of
https://github.com/aykhans/sarin.git
synced 2026-10-14 21:58:51 +00:00
494 lines
13 KiB
Go
494 lines
13 KiB
Go
package sarin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/x/term"
|
|
"github.com/valyala/fasthttp"
|
|
"go.aykhans.me/sarin/internal/script"
|
|
"go.aykhans.me/sarin/internal/types"
|
|
)
|
|
|
|
type runtimeLogLevel uint8
|
|
|
|
const (
|
|
runtimeLogLevelInfo runtimeLogLevel = iota
|
|
runtimeLogLevelError
|
|
)
|
|
|
|
type runtimeLog struct {
|
|
timestamp time.Time
|
|
level runtimeLogLevel
|
|
text string
|
|
}
|
|
|
|
type runtimeLogger func(level runtimeLogLevel, text string)
|
|
|
|
// SplitLogLevels parses a comma-separated log-level string into a deduplicated
|
|
// slice of level tokens (surrounding whitespace trimmed, empties dropped). Level
|
|
// names are matched case-sensitively, like the other enum options.
|
|
func SplitLogLevels(levels string) []string {
|
|
var out []string
|
|
seen := make(map[string]bool)
|
|
for part := range strings.SplitSeq(levels, ",") {
|
|
token := strings.TrimSpace(part)
|
|
if token == "" || seen[token] {
|
|
continue
|
|
}
|
|
seen[token] = true
|
|
out = append(out, token)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// IsInteractiveTerminal reports whether fd is a terminal that can actually be
|
|
// drawn on. A pty reports as a terminal even while its size is still zero, and
|
|
// Bubble Tea paints into a width x height cell buffer, so a zero dimension
|
|
// renders nothing at all.
|
|
func IsInteractiveTerminal(fd uintptr) bool {
|
|
if !term.IsTerminal(fd) {
|
|
return false
|
|
}
|
|
width, height, err := term.GetSize(fd)
|
|
return err == nil && width > 0 && height > 0
|
|
}
|
|
|
|
// respLogger logs a single completed response.
|
|
type respLogger func(duration time.Duration, resp *fasthttp.Response)
|
|
|
|
func noopLog(runtimeLogLevel, string) {}
|
|
func noopRespLog(time.Duration, *fasthttp.Response) {}
|
|
|
|
// gateSendLog wraps emit with level filtering decided once from the enabled
|
|
// levels. It stays general (any level filters correctly) while avoiding a
|
|
// per-log check when both levels are on and any work at all when both are off.
|
|
func gateSendLog(logInfo, logError bool, emit func(level runtimeLogLevel, text string)) runtimeLogger {
|
|
switch {
|
|
case logInfo && logError:
|
|
return emit
|
|
case logInfo:
|
|
return func(level runtimeLogLevel, text string) {
|
|
if level == runtimeLogLevelInfo {
|
|
emit(level, text)
|
|
}
|
|
}
|
|
case logError:
|
|
return func(level runtimeLogLevel, text string) {
|
|
if level == runtimeLogLevelError {
|
|
emit(level, text)
|
|
}
|
|
}
|
|
default:
|
|
return noopLog
|
|
}
|
|
}
|
|
|
|
// respBodySnippetLen bounds how many bytes of the body the compact (TUI)
|
|
// rendering shows.
|
|
const respBodySnippetLen = 100
|
|
|
|
// formatRuntimeLogLine renders a runtime log as a plain (unstyled) line for
|
|
// stderr output. ANSI styling is left to the TUI.
|
|
func formatRuntimeLogLine(timestamp time.Time, level runtimeLogLevel, text string) string {
|
|
levelStr := "ERROR"
|
|
if level == runtimeLogLevelInfo {
|
|
levelStr = "INFO"
|
|
}
|
|
return "[" + timestamp.Format("15:04:05") + "] " + levelStr + ": " + text
|
|
}
|
|
|
|
// respToLog renders a response as a compact one-line summary for the TUI log box
|
|
// (the "[time] INFO:" prefix is added by the TUI).
|
|
func respToLog(duration time.Duration, resp *fasthttp.Response) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(statusCodeToString(resp.StatusCode()))
|
|
sb.WriteString(" ")
|
|
sb.WriteString(Duration(duration).String())
|
|
if snippet := bodySnippet(resp.Body(), respBodySnippetLen); snippet != "" {
|
|
sb.WriteString(" | ")
|
|
sb.WriteString(snippet)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
type respLogEntry struct {
|
|
Status int `json:"status"`
|
|
Duration string `json:"duration"`
|
|
Headers map[string][]string `json:"headers,omitempty"`
|
|
Body string `json:"body,omitempty"`
|
|
}
|
|
|
|
// respToLogJSON renders a response as a single, self-contained JSON line so the
|
|
// stderr stream stays valid NDJSON (pipeable to jq or any consumer).
|
|
func respToLogJSON(duration time.Duration, resp *fasthttp.Response) string {
|
|
entry := respLogEntry{
|
|
Status: resp.StatusCode(),
|
|
Duration: Duration(duration).String(),
|
|
Headers: collectRespHeaders(resp),
|
|
Body: string(resp.Body()),
|
|
}
|
|
data, err := json.Marshal(entry)
|
|
if err != nil {
|
|
return `{"error":"failed to marshal response log"}`
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func collectRespHeaders(resp *fasthttp.Response) map[string][]string {
|
|
headers := make(map[string][]string)
|
|
for key, value := range resp.Header.All() {
|
|
k := string(key)
|
|
headers[k] = append(headers[k], string(value))
|
|
}
|
|
return headers
|
|
}
|
|
|
|
// bodySnippet returns the first maxLen bytes of body collapsed onto a single
|
|
// line (control characters replaced with spaces), with an ellipsis when truncated.
|
|
func bodySnippet(body []byte, maxLen int) string {
|
|
if len(body) == 0 {
|
|
return ""
|
|
}
|
|
|
|
truncated := false
|
|
if len(body) > maxLen {
|
|
body = body[:maxLen]
|
|
truncated = true
|
|
}
|
|
|
|
s := strings.Map(func(r rune) rune {
|
|
if r == '\n' || r == '\r' || r == '\t' {
|
|
return ' '
|
|
}
|
|
return r
|
|
}, string(body))
|
|
|
|
if truncated {
|
|
s += "..."
|
|
}
|
|
return s
|
|
}
|
|
|
|
type sarin struct {
|
|
workers uint
|
|
requestURL *url.URL
|
|
methods []string
|
|
params types.Params
|
|
headers types.Headers
|
|
cookies types.Cookies
|
|
bodies []string
|
|
totalRequests *uint64
|
|
totalDuration *time.Duration
|
|
timeout time.Duration
|
|
showProgress bool
|
|
skipCertVerify bool
|
|
values []string
|
|
collectStats bool
|
|
dryRun bool
|
|
logInfo bool
|
|
logError bool
|
|
logFile string
|
|
|
|
// jobsCtx ends the run, either when the duration expires or on Ctrl+C.
|
|
jobsCtx context.Context //nolint:containedctx
|
|
jobsCancel context.CancelFunc
|
|
|
|
hostClients []*fasthttp.HostClient
|
|
scriptHTTPClients []*scriptHTTPClient
|
|
responses *SarinResponseData
|
|
fileCache *FileCache
|
|
scriptChain *script.Chain
|
|
}
|
|
|
|
// NewSarin creates a new sarin instance for load testing.
|
|
// It can return the following errors:
|
|
// - types.ProxyDialError
|
|
// - types.ErrScriptEmpty
|
|
// - types.ScriptLoadError
|
|
func NewSarin(
|
|
ctx context.Context,
|
|
methods []string,
|
|
requestURL *url.URL,
|
|
timeout time.Duration,
|
|
workers uint,
|
|
totalRequests *uint64,
|
|
totalDuration *time.Duration,
|
|
showProgress bool,
|
|
skipCertVerify bool,
|
|
maxResponseBody uint64,
|
|
params types.Params,
|
|
headers types.Headers,
|
|
cookies types.Cookies,
|
|
bodies []string,
|
|
proxies types.Proxies,
|
|
values []string,
|
|
collectStats bool,
|
|
dryRun bool,
|
|
logLevel string,
|
|
logFile string,
|
|
luaScripts []string,
|
|
jsScripts []string,
|
|
) (*sarin, error) {
|
|
if workers == 0 {
|
|
workers = 1
|
|
}
|
|
|
|
// Resolve which log levels are enabled once, up front.
|
|
var logInfo, logError bool
|
|
for _, level := range SplitLogLevels(logLevel) {
|
|
switch level {
|
|
case "info":
|
|
logInfo = true
|
|
case "error":
|
|
logError = true
|
|
}
|
|
}
|
|
|
|
proxyURLs := make([]url.URL, len(proxies))
|
|
for i, proxy := range proxies {
|
|
proxyURLs[i] = url.URL(proxy)
|
|
}
|
|
|
|
hostClients, err := NewHostClients(ctx, timeout, proxyURLs, workers, requestURL, skipCertVerify, maxResponseBody)
|
|
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)
|
|
|
|
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
|
|
|
var scriptHTTPClients []*scriptHTTPClient
|
|
if !scriptChain.IsEmpty() {
|
|
scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers, maxResponseBody)
|
|
if err != nil {
|
|
jobsCancel()
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
srn := &sarin{
|
|
jobsCtx: jobsCtx,
|
|
jobsCancel: jobsCancel,
|
|
workers: workers,
|
|
requestURL: requestURL,
|
|
methods: methods,
|
|
params: params,
|
|
headers: headers,
|
|
cookies: cookies,
|
|
bodies: bodies,
|
|
totalRequests: totalRequests,
|
|
totalDuration: totalDuration,
|
|
timeout: timeout,
|
|
showProgress: showProgress,
|
|
skipCertVerify: skipCertVerify,
|
|
values: values,
|
|
collectStats: collectStats,
|
|
dryRun: dryRun,
|
|
logInfo: logInfo,
|
|
logError: logError,
|
|
logFile: logFile,
|
|
hostClients: hostClients,
|
|
scriptHTTPClients: scriptHTTPClients,
|
|
fileCache: NewFileCache(time.Second * 30),
|
|
scriptChain: scriptChain,
|
|
}
|
|
|
|
if collectStats {
|
|
srn.responses = NewSarinResponseData(uint32(100))
|
|
}
|
|
|
|
return srn, nil
|
|
}
|
|
|
|
func (s sarin) GetResponses() *SarinResponseData {
|
|
return s.responses
|
|
}
|
|
|
|
func (s sarin) Start(stopCtrl *StopController) {
|
|
var workersWG sync.WaitGroup
|
|
jobsCh := make(chan struct{}, max(s.workers, 1))
|
|
|
|
var counter atomic.Uint64
|
|
|
|
totalRequests := uint64(0)
|
|
if s.totalRequests != nil {
|
|
totalRequests = *s.totalRequests
|
|
}
|
|
|
|
onTerminal := IsInteractiveTerminal(os.Stdout.Fd())
|
|
// The progress bar needs an interactive terminal to render.
|
|
showProgressBar := s.showProgress && onTerminal
|
|
// The bubbletea TUI hosts the bar and/or the live log box, so it runs whenever
|
|
// either has something to show: the bar, or logs that would land in the box
|
|
// (i.e. logs are enabled and not redirected to a file).
|
|
runTUI := showProgressBar || (onTerminal && (s.logInfo || s.logError) && s.logFile == "")
|
|
|
|
// Open the log file up front, before registering any defer, so a bad path
|
|
// exits cleanly.
|
|
var logFile *os.File
|
|
if s.logFile != "" {
|
|
f, err := os.OpenFile(s.logFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "failed to open log file "+s.logFile+": "+err.Error())
|
|
os.Exit(1)
|
|
}
|
|
defer f.Close() //nolint:errcheck
|
|
logFile = f
|
|
}
|
|
|
|
var (
|
|
streamCtx context.Context
|
|
streamCancel context.CancelFunc
|
|
streamCh chan struct{}
|
|
tuiLogChannel chan runtimeLog
|
|
)
|
|
if runTUI {
|
|
streamCtx, streamCancel = context.WithCancel(context.Background())
|
|
defer streamCancel()
|
|
streamCh = make(chan struct{})
|
|
tuiLogChannel = make(chan runtimeLog, max(s.workers, 1))
|
|
}
|
|
|
|
// Route logs: to the file if given, else to the TUI log box while it runs,
|
|
// otherwise to stderr.
|
|
var (
|
|
sendLog runtimeLogger
|
|
sendRespLog respLogger
|
|
)
|
|
switch {
|
|
case logFile != nil:
|
|
sendLog, sendRespLog = s.newWriterLog(logFile)
|
|
case runTUI:
|
|
sendLog, sendRespLog = s.newChannelLog(tuiLogChannel)
|
|
default:
|
|
sendLog, sendRespLog = s.newWriterLog(os.Stderr)
|
|
}
|
|
|
|
// Start workers
|
|
s.startWorkers(&workersWG, jobsCh, &counter, sendLog, sendRespLog)
|
|
|
|
if runTUI {
|
|
go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar)
|
|
}
|
|
|
|
// Setup duration-based cancellation
|
|
s.setupDurationTimeout()
|
|
// Distribute jobs to workers.
|
|
// This blocks until all jobs are sent or the context is canceled.
|
|
s.sendJobs(jobsCh)
|
|
|
|
// Close the jobs channel so workers stop after completing their current job
|
|
close(jobsCh)
|
|
// Wait until all workers stopped
|
|
workersWG.Wait()
|
|
if tuiLogChannel != nil {
|
|
close(tuiLogChannel)
|
|
}
|
|
|
|
if runTUI {
|
|
// Stop the progress streaming
|
|
streamCancel()
|
|
// Wait until progress streaming has completely stopped
|
|
<-streamCh
|
|
}
|
|
}
|
|
|
|
// newWriterLog builds the loggers that write formatted lines to w (a log file or
|
|
// stderr). sendLog stays general (it filters by each log's level), while sendRespLog
|
|
// only ever emits info, so its decision is baked once into a no-op when off.
|
|
func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) {
|
|
// log.Logger serializes writes with its own mutex, so concurrent workers
|
|
// won't interleave lines.
|
|
logger := log.New(w, "", 0)
|
|
|
|
sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
|
|
logger.Println(formatRuntimeLogLine(time.Now(), level, text))
|
|
})
|
|
|
|
var sendRespLog respLogger = noopRespLog
|
|
if s.logInfo {
|
|
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
|
|
logger.Println(formatRuntimeLogLine(time.Now(), runtimeLogLevelInfo, respToLogJSON(duration, resp)))
|
|
}
|
|
}
|
|
|
|
return sendLog, sendRespLog
|
|
}
|
|
|
|
// newChannelLog builds the loggers that feed the TUI log box through ch, with the
|
|
// same gating as newWriterLog.
|
|
func (s sarin) newChannelLog(ch chan<- runtimeLog) (runtimeLogger, respLogger) {
|
|
sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
|
|
ch <- runtimeLog{timestamp: time.Now(), level: level, text: text}
|
|
})
|
|
|
|
var sendRespLog respLogger = noopRespLog
|
|
if s.logInfo {
|
|
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
|
|
ch <- runtimeLog{timestamp: time.Now(), level: runtimeLogLevelInfo, text: respToLog(duration, resp)}
|
|
}
|
|
}
|
|
|
|
return sendLog, sendRespLog
|
|
}
|
|
|
|
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
|
|
for range max(s.workers, 1) {
|
|
wg.Go(func() {
|
|
s.Worker(jobs, counter, sendLog, sendRespLog)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s sarin) setupDurationTimeout() {
|
|
if s.totalDuration != nil {
|
|
go func() {
|
|
timer := time.NewTimer(*s.totalDuration)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-timer.C:
|
|
s.jobsCancel()
|
|
case <-s.jobsCtx.Done():
|
|
// Context cancelled, cleanup
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
func (s sarin) sendJobs(jobs chan<- struct{}) {
|
|
if s.totalRequests != nil && *s.totalRequests > 0 {
|
|
for range *s.totalRequests {
|
|
if s.jobsCtx.Err() != nil {
|
|
break
|
|
}
|
|
jobs <- struct{}{}
|
|
}
|
|
} else {
|
|
for s.jobsCtx.Err() == nil {
|
|
jobs <- struct{}{}
|
|
}
|
|
}
|
|
}
|