From ed8efd7cb78432b40d060e8b7ae3ee7dd5ccb9e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Fri, 29 Jun 2018 19:25:19 +0200 Subject: [PATCH 01/10] attack: Preparation for alternative target formats This commit introduces the following changes in preparation for alternative target formats: - LazyTargeter renamed to LegacyTargeter - EagerTargeter refactored to take in any Targeter - `-format` flag added to the attack command - Extended documentation of the legacy target format --- README.md | 153 +++++++++++++++++--------------------------- attack.go | 16 +++-- lib/targets.go | 27 +++++--- lib/targets_test.go | 12 ++-- 4 files changed, 92 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 4c1890de..0323dc8f 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ attack command: Max open idle connections per target host (default 10000) -duration duration Duration of the test [0 = forever] + -format string + Targets format [legacy] (default "legacy") + -h2c + Send HTTP/2 requests without TLS encryption -header value Request header -http2 @@ -113,47 +117,7 @@ Specifies which profiler to enable during execution. Both *cpu* and #### `-version` Prints the version and exits. -### `attack` -```console -$ vegeta attack -h -Usage of vegeta attack: - -body string - Requests body file - -cert string - TLS client PEM encoded certificate file - -connections int - Max open idle connections per target host (default 10000) - -duration duration - Duration of the test [0 = forever] - -header value - Request header - -http2 - Send HTTP/2 requests when supported by the server (default true) - -insecure - Ignore invalid server TLS certificates - -keepalive - Use persistent connections (default true) - -key string - TLS client PEM encoded private key file - -laddr value - Local IP address (default 0.0.0.0) - -lazy - Read targets lazily - -output string - Output file (default "stdout") - -rate uint - Requests per second (default 50) - -redirects int - Number of redirects to follow. -1 will not follow but marks as success (default 10) - -root-certs value - TLS root certificate files (comma separated list) - -targets string - Targets file (default "stdin") - -timeout duration - Requests timeout (default 30s) - -workers uint - Initial number of workers (default 10) -``` +### `attack` command #### `-body` Specifies the file whose content will be set as the body of every @@ -172,6 +136,56 @@ The internal concurrency structure's setup has this value as a variable. The actual run time of the test can be longer than specified due to the responses delay. Use 0 for an infinite attack. +#### `-format` +Specifies the targets format to decode. + +##### `legacy` format + +The ill-defined legacy format almost resembles the plain-text HTTP message format but +doesn't support in-line HTTP bodies, only references to files that are loaded and used +as request bodies (as exemplified below). + +Although targets in this format can be produced by other programs, it was originally +meant to be used by people writing targets by hand for simple use cases. + +Here are a few examples of valid targets files in the legacy format: + +Simple targets +``` +GET http://goku:9090/path/to/dragon?item=ball +GET http://user:password@goku:9090/path/to +HEAD http://goku:9090/path/to/success +``` + +Targets with custom headers +``` +GET http://user:password@goku:9090/path/to +X-Account-ID: 8675309 + +DELETE http://goku:9090/path/to/remove +Confirmation-Token: 90215 +Authorization: Token DEADBEEF +``` + +Targets with custom bodies +``` +POST http://goku:9090/things +@/path/to/newthing.json + +PATCH http://goku:9090/thing/71988591 +@/path/to/thing-71988591.json +``` + +Targets with custom bodies and headers +``` +POST http://goku:9090/things +X-Account-ID: 99 +@/path/to/newthing.json +``` + +#### `-h2c` +Specifies that HTTP2 requests are to be sent over TCP without TLS encryption. + #### `-header` Specifies a request header to be used in all targets defined, see `-targets`. You can specify as many as needed by repeating the flag. @@ -220,39 +234,6 @@ list. If unspecified, the default system CAs certificates will be used. Specifies the attack targets in a line separated file, defaulting to stdin. The format should be as follows, combining any or all of the following: -Simple targets -``` -GET http://goku:9090/path/to/dragon?item=balls -GET http://user:password@goku:9090/path/to -HEAD http://goku:9090/path/to/success -``` - -Targets with custom headers -``` -GET http://user:password@goku:9090/path/to -X-Account-ID: 8675309 - -DELETE http://goku:9090/path/to/remove -Confirmation-Token: 90215 -Authorization: Token DEADBEEF -``` - -Targets with custom bodies -``` -POST http://goku:9090/things -@/path/to/newthing.json - -PATCH http://goku:9090/thing/71988591 -@/path/to/thing-71988591.json -``` - -Targets with custom bodies and headers -``` -POST http://goku:9090/things -X-Account-ID: 99 -@/path/to/newthing.json -``` - #### `-timeout` Specifies the timeout for each request. The default is 0 which disables timeouts. @@ -262,17 +243,7 @@ Specifies the initial number of workers used in the attack. The actual number of workers will increase if necessary in order to sustain the requested rate. -### report -```console -$ vegeta report -h -Usage of vegeta report: - -inputs string - Input files (comma separated) (default "stdin") - -output string - Output file (default "stdout") - -reporter string - Reporter [text, json, plot, hist[buckets]] (default "text") -``` +### report command #### `-inputs` Specifies the input files to generate the report of, defaulting to stdin. @@ -363,17 +334,7 @@ Bucket # % Histogram [6ms, +Inf] 4771 25.93% ################### ``` -### `dump` -```console -$ vegeta dump -h -Usage of vegeta dump: - -dumper string - Dumper [json, csv] (default "json") - -inputs string - Input files (comma separated) (default "stdin") - -output string - Output file (default "stdout") -``` +### `dump` command #### `-inputs` Specifies the input files containing attack results to be dumped. You can specify more than one (comma separated). diff --git a/attack.go b/attack.go index 2c017595..9c9ce3cc 100644 --- a/attack.go +++ b/attack.go @@ -25,6 +25,7 @@ func attackCmd() command { fs.StringVar(&opts.name, "name", "", "Attack name") fs.StringVar(&opts.targetsf, "targets", "stdin", "Targets file") + fs.StringVar(&opts.format, "format", "legacy", "Targets format [legacy]") fs.StringVar(&opts.outputf, "output", "stdout", "Output file") fs.StringVar(&opts.bodyf, "body", "", "Requests body file") fs.StringVar(&opts.certf, "cert", "", "TLS client PEM encoded certificate file") @@ -59,6 +60,7 @@ var ( type attackOpts struct { name string targetsf string + format string outputf string bodyf string certf string @@ -111,10 +113,16 @@ func attack(opts *attackOpts) (err error) { src = files[opts.targetsf] hdr = opts.headers.Header ) - if opts.lazy { - tr = vegeta.NewLazyTargeter(src, body, hdr) - } else if tr, err = vegeta.NewEagerTargeter(src, body, hdr); err != nil { - return err + + switch opts.format { + case "legacy": + tr = vegeta.NewLegacyTargeter(src, body, hdr) + } + + if !opts.lazy { + if tr, err = vegeta.NewEagerTargeter(tr); err != nil { + return err + } } out, err := file(opts.outputf, true) diff --git a/lib/targets.go b/lib/targets.go index 9624aa2f..b27820a8 100644 --- a/lib/targets.go +++ b/lib/targets.go @@ -64,38 +64,45 @@ func NewStaticTargeter(tgts ...Target) Targeter { } } -// NewEagerTargeter eagerly reads all Targets out of the provided io.Reader and +// NewEagerTargeter eagerly reads all Targets out of the provided Targeter and // returns a NewStaticTargeter with them. -// -// body will be set as the Target's body if no body is provided. -// hdr will be merged with the each Target's headers. -func NewEagerTargeter(src io.Reader, body []byte, header http.Header) (Targeter, error) { +func NewEagerTargeter(t Targeter) (Targeter, error) { var ( - sc = NewLazyTargeter(src, body, header) tgts []Target tgt Target err error ) + for { - if err = sc(&tgt); err == ErrNoTargets { + if err = t(&tgt); err == ErrNoTargets { break } else if err != nil { return nil, err } tgts = append(tgts, tgt) } + if len(tgts) == 0 { return nil, ErrNoTargets } + return NewStaticTargeter(tgts...), nil } -// NewLazyTargeter returns a new Targeter that lazily scans Targets from the -// provided io.Reader on every invocation. +// NewLegacyTargeter returns a new Targeter that decodes one Target from the +// given io.Reader on every invocation. The format is as follows: +// +// GET https://foo.bar/a/b/c +// Header-X: 123 +// Header-Y: 321 +// @/path/to/body/file +// +// POST https://foo.bar/b/c/a +// Header-X: 123 // // body will be set as the Target's body if no body is provided. // hdr will be merged with the each Target's headers. -func NewLazyTargeter(src io.Reader, body []byte, hdr http.Header) Targeter { +func NewLegacyTargeter(src io.Reader, body []byte, hdr http.Header) Targeter { var mu sync.Mutex sc := peekingScanner{src: bufio.NewScanner(src)} return func(tgt *Target) (err error) { diff --git a/lib/targets_test.go b/lib/targets_test.go index c645661f..00730122 100644 --- a/lib/targets_test.go +++ b/lib/targets_test.go @@ -62,7 +62,7 @@ func TestNewEagerTargeter(t *testing.T) { t.Parallel() src := []byte("GET http://:6060/\nHEAD http://:6606/") - read, err := NewEagerTargeter(bytes.NewReader(src), []byte("body"), nil) + read, err := NewEagerTargeter(NewLegacyTargeter(bytes.NewReader(src), []byte("body"), nil)) if err != nil { t.Fatalf("Couldn't parse valid source: %s", err) } @@ -89,7 +89,7 @@ func TestNewEagerTargeter(t *testing.T) { } } -func TestNewLazyTargeter(t *testing.T) { +func TestNewLegacyTargeter(t *testing.T) { t.Parallel() for want, def := range map[error]string{ @@ -111,7 +111,7 @@ func TestNewLazyTargeter(t *testing.T) { : 1234`, } { src := bytes.NewBufferString(strings.TrimSpace(def)) - read := NewLazyTargeter(src, []byte{}, http.Header{}) + read := NewLegacyTargeter(src, []byte{}, http.Header{}) if got := read(&Target{}); got == nil || !strings.HasPrefix(got.Error(), want.Error()) { t.Errorf("got: %s, want: %s\n%s", got, want, def) } @@ -149,7 +149,7 @@ func TestNewLazyTargeter(t *testing.T) { ) src := bytes.NewBufferString(strings.TrimSpace(targets)) - read := NewLazyTargeter(src, []byte{}, http.Header{"Content-Type": []string{"text/plain"}}) + read := NewLegacyTargeter(src, []byte{}, http.Header{"Content-Type": []string{"text/plain"}}) for _, want := range []Target{ { Method: "GET", @@ -215,13 +215,13 @@ func TestNewLazyTargeter(t *testing.T) { func TestErrNilTarget(t *testing.T) { t.Parallel() - eager, err := NewEagerTargeter(strings.NewReader("GET http://foo.bar"), nil, nil) + eager, err := NewEagerTargeter(NewLegacyTargeter(strings.NewReader("GET http://foo.bar"), nil, nil)) if err != nil { t.Fatal(err) } for i, tr := range []Targeter{ NewStaticTargeter(Target{Method: "GET", URL: "http://foo.bar"}), - NewLazyTargeter(strings.NewReader("GET http://foo.bar"), nil, nil), + NewLegacyTargeter(strings.NewReader("GET http://foo.bar"), nil, nil), eager, } { if got, want := tr(nil), ErrNilTarget; got != want { From 50ef463683cb99729124e2d4add550fe52982904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Fri, 29 Jun 2018 21:39:57 +0200 Subject: [PATCH 02/10] attack: Implement JSON target format --- README.md | 12 +++++++++++- attack.go | 6 +++++- lib/targets.go | 47 +++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0323dc8f..08057191 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ attack command: -duration duration Duration of the test [0 = forever] -format string - Targets format [legacy] (default "legacy") + Targets format [legacy, json] (default "legacy") -h2c Send HTTP/2 requests without TLS encryption -header value @@ -139,6 +139,16 @@ responses delay. Use 0 for an infinite attack. #### `-format` Specifies the targets format to decode. +##### `json` format + +The JSON format makes integration with programs that produce targets dynamically easier. +Each target is one JSON object in its own line. If present, the body field must be base64 encoded. + +```bash +jq -ncM '{method: "GET", url: "http://goku", body: "Punch!" | @base64, header: {"Content-Type": ["text/plain"]}}' | + vegeta attack -format=json -rate=100 | vegeta dump +``` + ##### `legacy` format The ill-defined legacy format almost resembles the plain-text HTTP message format but diff --git a/attack.go b/attack.go index 9c9ce3cc..e65fe938 100644 --- a/attack.go +++ b/attack.go @@ -25,7 +25,7 @@ func attackCmd() command { fs.StringVar(&opts.name, "name", "", "Attack name") fs.StringVar(&opts.targetsf, "targets", "stdin", "Targets file") - fs.StringVar(&opts.format, "format", "legacy", "Targets format [legacy]") + fs.StringVar(&opts.format, "format", "legacy", "Targets format [legacy, json]") fs.StringVar(&opts.outputf, "output", "stdout", "Output file") fs.StringVar(&opts.bodyf, "body", "", "Requests body file") fs.StringVar(&opts.certf, "cert", "", "TLS client PEM encoded certificate file") @@ -115,7 +115,11 @@ func attack(opts *attackOpts) (err error) { ) switch opts.format { + case "json": + tr = vegeta.NewJSONTargeter(src, body, hdr) case "legacy": + fallthrough + default: tr = vegeta.NewLegacyTargeter(src, body, hdr) } diff --git a/lib/targets.go b/lib/targets.go index b27820a8..22001b4f 100644 --- a/lib/targets.go +++ b/lib/targets.go @@ -3,6 +3,7 @@ package vegeta import ( "bufio" "bytes" + "encoding/json" "errors" "fmt" "io" @@ -17,10 +18,10 @@ import ( // Target is an HTTP request blueprint. type Target struct { - Method string - URL string - Body []byte - Header http.Header + Method string `json:"method"` + URL string `json:"url"` + Body []byte `json:"body"` + Header http.Header `json:"header"` } // Request creates an *http.Request out of Target and returns it along with an @@ -51,6 +52,44 @@ var ( // Implementations must be safe for concurrent use. type Targeter func(*Target) error +// NewJSONTargeter returns a new targeter that decodes one Target from the +// given io.Reader on every invocation. Each target is one JSON object in its own line. +// The body field of each target must be base64 encoded. +// +// {"method":"POST", "url":"https://goku/1", "header":{"Content-Type":["text/plain"], "body": "Rk9P"} +// {"method":"GET", "url":"https://goku/2"} +// +// body will be set as the Target's body if no body is provided in each target definiton. +// hdr will be merged with the each Target's headers. +// +func NewJSONTargeter(src io.Reader, body []byte, header http.Header) Targeter { + type decoder struct { + *json.Decoder + sync.Mutex + } + dec := decoder{Decoder: json.NewDecoder(src)} + + return func(tgt *Target) (err error) { + if tgt == nil { + return ErrNilTarget + } + + dec.Lock() + defer dec.Unlock() + + if err = dec.Decode(tgt); err == nil { + return nil + } + + switch err { + case io.EOF: + return ErrNoTargets + default: + return err + } + } +} + // NewStaticTargeter returns a Targeter which round-robins over the passed // Targets. func NewStaticTargeter(tgts ...Target) Targeter { From a7df0cf3a3053065c720821b0b979dd6e4b6c698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Sat, 7 Jul 2018 15:03:51 +0200 Subject: [PATCH 03/10] targets: Rename legacy format to http --- README.md | 17 +++++++++-------- attack.go | 6 +++--- lib/targets.go | 4 ++-- lib/targets_test.go | 12 ++++++------ 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 289a1030..9293f377 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ attack command: -duration duration Duration of the test [0 = forever] -format string - Targets format [legacy, json] (default "legacy") + Targets format [http, json] (default "http") -h2c Send HTTP/2 requests without TLS encryption -header value @@ -149,25 +149,26 @@ jq -ncM '{method: "GET", url: "http://goku", body: "Punch!" | @base64, header: { vegeta attack -format=json -rate=100 | vegeta dump ``` -##### `legacy` format +##### `http` format -The ill-defined legacy format almost resembles the plain-text HTTP message format but +The http format almost resembles the plain-text HTTP message format defined in +[RFC 2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html) but it doesn't support in-line HTTP bodies, only references to files that are loaded and used as request bodies (as exemplified below). Although targets in this format can be produced by other programs, it was originally meant to be used by people writing targets by hand for simple use cases. -Here are a few examples of valid targets files in the legacy format: +Here are a few examples of valid targets files in the http format: -Simple targets +###### Simple targets ``` GET http://goku:9090/path/to/dragon?item=ball GET http://user:password@goku:9090/path/to HEAD http://goku:9090/path/to/success ``` -Targets with custom headers +###### Targets with custom headers ``` GET http://user:password@goku:9090/path/to X-Account-ID: 8675309 @@ -177,7 +178,7 @@ Confirmation-Token: 90215 Authorization: Token DEADBEEF ``` -Targets with custom bodies +###### Targets with custom bodies ``` POST http://goku:9090/things @/path/to/newthing.json @@ -186,7 +187,7 @@ PATCH http://goku:9090/thing/71988591 @/path/to/thing-71988591.json ``` -Targets with custom bodies and headers +###### Targets with custom bodies and headers ``` POST http://goku:9090/things X-Account-ID: 99 diff --git a/attack.go b/attack.go index e65fe938..3e75fcee 100644 --- a/attack.go +++ b/attack.go @@ -25,7 +25,7 @@ func attackCmd() command { fs.StringVar(&opts.name, "name", "", "Attack name") fs.StringVar(&opts.targetsf, "targets", "stdin", "Targets file") - fs.StringVar(&opts.format, "format", "legacy", "Targets format [legacy, json]") + fs.StringVar(&opts.format, "format", "http", "Targets format [http, json]") fs.StringVar(&opts.outputf, "output", "stdout", "Output file") fs.StringVar(&opts.bodyf, "body", "", "Requests body file") fs.StringVar(&opts.certf, "cert", "", "TLS client PEM encoded certificate file") @@ -117,10 +117,10 @@ func attack(opts *attackOpts) (err error) { switch opts.format { case "json": tr = vegeta.NewJSONTargeter(src, body, hdr) - case "legacy": + case "http": fallthrough default: - tr = vegeta.NewLegacyTargeter(src, body, hdr) + tr = vegeta.NewHTTPTargeter(src, body, hdr) } if !opts.lazy { diff --git a/lib/targets.go b/lib/targets.go index 22001b4f..16a9c2df 100644 --- a/lib/targets.go +++ b/lib/targets.go @@ -128,7 +128,7 @@ func NewEagerTargeter(t Targeter) (Targeter, error) { return NewStaticTargeter(tgts...), nil } -// NewLegacyTargeter returns a new Targeter that decodes one Target from the +// NewHTTPTargeter returns a new Targeter that decodes one Target from the // given io.Reader on every invocation. The format is as follows: // // GET https://foo.bar/a/b/c @@ -141,7 +141,7 @@ func NewEagerTargeter(t Targeter) (Targeter, error) { // // body will be set as the Target's body if no body is provided. // hdr will be merged with the each Target's headers. -func NewLegacyTargeter(src io.Reader, body []byte, hdr http.Header) Targeter { +func NewHTTPTargeter(src io.Reader, body []byte, hdr http.Header) Targeter { var mu sync.Mutex sc := peekingScanner{src: bufio.NewScanner(src)} return func(tgt *Target) (err error) { diff --git a/lib/targets_test.go b/lib/targets_test.go index 00730122..188d7d31 100644 --- a/lib/targets_test.go +++ b/lib/targets_test.go @@ -62,7 +62,7 @@ func TestNewEagerTargeter(t *testing.T) { t.Parallel() src := []byte("GET http://:6060/\nHEAD http://:6606/") - read, err := NewEagerTargeter(NewLegacyTargeter(bytes.NewReader(src), []byte("body"), nil)) + read, err := NewEagerTargeter(NewHTTPTargeter(bytes.NewReader(src), []byte("body"), nil)) if err != nil { t.Fatalf("Couldn't parse valid source: %s", err) } @@ -89,7 +89,7 @@ func TestNewEagerTargeter(t *testing.T) { } } -func TestNewLegacyTargeter(t *testing.T) { +func TestNewHTTPTargeter(t *testing.T) { t.Parallel() for want, def := range map[error]string{ @@ -111,7 +111,7 @@ func TestNewLegacyTargeter(t *testing.T) { : 1234`, } { src := bytes.NewBufferString(strings.TrimSpace(def)) - read := NewLegacyTargeter(src, []byte{}, http.Header{}) + read := NewHTTPTargeter(src, []byte{}, http.Header{}) if got := read(&Target{}); got == nil || !strings.HasPrefix(got.Error(), want.Error()) { t.Errorf("got: %s, want: %s\n%s", got, want, def) } @@ -149,7 +149,7 @@ func TestNewLegacyTargeter(t *testing.T) { ) src := bytes.NewBufferString(strings.TrimSpace(targets)) - read := NewLegacyTargeter(src, []byte{}, http.Header{"Content-Type": []string{"text/plain"}}) + read := NewHTTPTargeter(src, []byte{}, http.Header{"Content-Type": []string{"text/plain"}}) for _, want := range []Target{ { Method: "GET", @@ -215,13 +215,13 @@ func TestNewLegacyTargeter(t *testing.T) { func TestErrNilTarget(t *testing.T) { t.Parallel() - eager, err := NewEagerTargeter(NewLegacyTargeter(strings.NewReader("GET http://foo.bar"), nil, nil)) + eager, err := NewEagerTargeter(NewHTTPTargeter(strings.NewReader("GET http://foo.bar"), nil, nil)) if err != nil { t.Fatal(err) } for i, tr := range []Targeter{ NewStaticTargeter(Target{Method: "GET", URL: "http://foo.bar"}), - NewLegacyTargeter(strings.NewReader("GET http://foo.bar"), nil, nil), + NewHTTPTargeter(strings.NewReader("GET http://foo.bar"), nil, nil), eager, } { if got, want := tr(nil), ErrNilTarget; got != want { From ec323fa5180048cfa08b2e75214c0b907b9cf164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Sat, 7 Jul 2018 15:15:06 +0200 Subject: [PATCH 04/10] targets: Introduce constants for different formats --- lib/targets.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/targets.go b/lib/targets.go index 16a9c2df..9bc8dfbe 100644 --- a/lib/targets.go +++ b/lib/targets.go @@ -48,6 +48,13 @@ var ( ErrNilTarget = errors.New("nil target") ) +const ( + // HTTPTargetFormat is the human readable identifier for the HTTP target format. + HTTPTargetFormat = "http" + // JSONTargetFormat is the human readable identifier for the JSON target format. + JSONTargetFormat = "json" +) + // A Targeter decodes a Target or returns an error in case of failure. // Implementations must be safe for concurrent use. type Targeter func(*Target) error From 188262ac239deb1c63461545668e6bec3178ee13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Sat, 7 Jul 2018 15:15:53 +0200 Subject: [PATCH 05/10] attack cmd: Return error on bad format selection --- attack.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/attack.go b/attack.go index 3e75fcee..fd479502 100644 --- a/attack.go +++ b/attack.go @@ -118,9 +118,10 @@ func attack(opts *attackOpts) (err error) { case "json": tr = vegeta.NewJSONTargeter(src, body, hdr) case "http": - fallthrough - default: tr = vegeta.NewHTTPTargeter(src, body, hdr) + default: + valid := [...]string{vegeta.HTTPTargetFormat, vegeta.JSONTargetFormat} + return fmt.Errorf("format %q isn't one of %v", opts.format, valid) } if !opts.lazy { From dae3d2d0098123a3e1c71f3feb84bbf9f9146060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Sat, 7 Jul 2018 15:29:53 +0200 Subject: [PATCH 06/10] attack cmd: Re-use target format constants --- attack.go | 12 +++++++----- lib/targets.go | 3 +++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/attack.go b/attack.go index fd479502..c0379b93 100644 --- a/attack.go +++ b/attack.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "os/signal" + "strings" "time" vegeta "github.com/tsenart/vegeta/lib" @@ -25,7 +26,8 @@ func attackCmd() command { fs.StringVar(&opts.name, "name", "", "Attack name") fs.StringVar(&opts.targetsf, "targets", "stdin", "Targets file") - fs.StringVar(&opts.format, "format", "http", "Targets format [http, json]") + fs.StringVar(&opts.format, "format", vegeta.HTTPTargetFormat, + fmt.Sprintf("Targets format [%s]", strings.Join(vegeta.TargetFormats, ", "))) fs.StringVar(&opts.outputf, "output", "stdout", "Output file") fs.StringVar(&opts.bodyf, "body", "", "Requests body file") fs.StringVar(&opts.certf, "cert", "", "TLS client PEM encoded certificate file") @@ -115,13 +117,13 @@ func attack(opts *attackOpts) (err error) { ) switch opts.format { - case "json": + case vegeta.JSONTargetFormat: tr = vegeta.NewJSONTargeter(src, body, hdr) - case "http": + case vegeta.HTTPTargetFormat: tr = vegeta.NewHTTPTargeter(src, body, hdr) default: - valid := [...]string{vegeta.HTTPTargetFormat, vegeta.JSONTargetFormat} - return fmt.Errorf("format %q isn't one of %v", opts.format, valid) + return fmt.Errorf("format %q isn't one of [%s]", + opts.format, strings.Join(vegeta.TargetFormats, ", ")) } if !opts.lazy { diff --git a/lib/targets.go b/lib/targets.go index 9bc8dfbe..b3d157fc 100644 --- a/lib/targets.go +++ b/lib/targets.go @@ -46,6 +46,9 @@ var ( ErrNoTargets = errors.New("no targets to attack") // ErrNilTarget is returned when the passed Target pointer is nil. ErrNilTarget = errors.New("nil target") + // TargetFormats contains the canonical list of the valid target + // format identifiers. + TargetFormats = []string{HTTPTargetFormat, JSONTargetFormat} ) const ( From eb227c2c655741800fe6581167dfe5d922eb8775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Sat, 7 Jul 2018 16:29:28 +0200 Subject: [PATCH 07/10] targets: Generate vegeta.Target JSON schema --- Gopkg.lock | 8 ++++- Makefile | 6 +++- README.md | 4 ++- internal/cmd/jsonschema/main.go | 61 +++++++++++++++++++++++++++++++++ lib/target.schema.json | 39 +++++++++++++++++++++ lib/targets.go | 10 ++++-- 6 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 internal/cmd/jsonschema/main.go create mode 100644 lib/target.schema.json diff --git a/Gopkg.lock b/Gopkg.lock index 42009909..fac24eab 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -1,6 +1,12 @@ # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. +[[projects]] + branch = "master" + name = "github.com/alecthomas/jsonschema" + packages = ["."] + revision = "f2c93856175a7dd6abe88c5c3900b67ad054adcc" + [[projects]] branch = "master" name = "github.com/lucasb-eyer/go-colorful" @@ -48,6 +54,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "c4733e995a7d5a7521b2b14b7b4a275f0ff10f65871f452e21c3cd1f0bd8a948" + inputs-digest = "b7241bb8cec207e1b751be687240fd7f6af51a3a9e37a70c4d62c33a96c1391e" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Makefile b/Makefile index 1241ba78..3dc99e49 100644 --- a/Makefile +++ b/Makefile @@ -2,13 +2,17 @@ COMMIT=$(shell git rev-parse HEAD) VERSION=$(shell git describe --tags --exact-match --always) DATE=$(shell date +'%FT%TZ%z') -vegeta: vendor +vegeta: vendor generate CGO_ENABLED=0 go build -v -a -tags=netgo \ -ldflags '-s -w -extldflags "-static" -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.Date=$(DATE)' clean-vegeta: rm vegeta +generate: vendor + go install ./internal/cmd/... + go generate ./... + vendor: dep ensure -v diff --git a/README.md b/README.md index 9293f377..e749818f 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,9 @@ Specifies the targets format to decode. ##### `json` format The JSON format makes integration with programs that produce targets dynamically easier. -Each target is one JSON object in its own line. If present, the body field must be base64 encoded. +Each target is one JSON object in its own line. The method and url fields are required. +If present, the body field must be base64 encoded. The generated [JSON Schema](lib/target.schema.json) +defines the format in detail. ```bash jq -ncM '{method: "GET", url: "http://goku", body: "Punch!" | @base64, header: {"Content-Type": ["text/plain"]}}' | diff --git a/internal/cmd/jsonschema/main.go b/internal/cmd/jsonschema/main.go new file mode 100644 index 00000000..70337491 --- /dev/null +++ b/internal/cmd/jsonschema/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "os" + "strings" + + "github.com/alecthomas/jsonschema" + + vegeta "github.com/tsenart/vegeta/lib" +) + +func main() { + types := map[string]interface{}{ + "Target": &vegeta.Target{}, + } + + valid := strings.Join(keys(types), ", ") + + fs := flag.NewFlagSet("jsonschema", flag.ExitOnError) + typ := fs.String("type", "", fmt.Sprintf("Vegeta type to generate a JSON schema for [%s]", valid)) + out := fs.String("output", "stdout", "Output file") + + fs.Parse(os.Args[1:]) + + t, ok := types[*typ] + if !ok { + die("invalid type %q not in [%s]", *typ, valid) + } + + schema, err := json.MarshalIndent(jsonschema.Reflect(t), "", " ") + if err != nil { + die("%s", err) + } + + switch *out { + case "stdout": + _, err = os.Stdout.Write(schema) + default: + err = ioutil.WriteFile(*out, schema, 0644) + } + + if err != nil { + die("%s", err) + } +} + +func die(s string, args ...interface{}) { + fmt.Fprintf(os.Stderr, s, args...) + os.Exit(1) +} + +func keys(types map[string]interface{}) (ks []string) { + for k := range types { + ks = append(ks, k) + } + return ks +} diff --git a/lib/target.schema.json b/lib/target.schema.json new file mode 100644 index 00000000..00286725 --- /dev/null +++ b/lib/target.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "$ref": "#/definitions/Target", + "definitions": { + "Target": { + "required": [ + "method", + "url" + ], + "properties": { + "body": { + "type": "string", + "media": { + "binaryEncoding": "base64" + } + }, + "header": { + "patternProperties": { + ".*": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "method": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + } + } +} \ No newline at end of file diff --git a/lib/targets.go b/lib/targets.go index b3d157fc..f7df8d28 100644 --- a/lib/targets.go +++ b/lib/targets.go @@ -17,11 +17,13 @@ import ( ) // Target is an HTTP request blueprint. +// +//go:generate jsonschema -type=Target -output=target.schema.json type Target struct { Method string `json:"method"` URL string `json:"url"` - Body []byte `json:"body"` - Header http.Header `json:"header"` + Body []byte `json:"body,omitempty"` + Header http.Header `json:"header,omitempty"` } // Request creates an *http.Request out of Target and returns it along with an @@ -64,7 +66,9 @@ type Targeter func(*Target) error // NewJSONTargeter returns a new targeter that decodes one Target from the // given io.Reader on every invocation. Each target is one JSON object in its own line. -// The body field of each target must be base64 encoded. +// +// The method and url fields are required. If present, the body field must be base64 encoded. +// The generated [JSON Schema](lib/target.schema.json) defines the format in detail. // // {"method":"POST", "url":"https://goku/1", "header":{"Content-Type":["text/plain"], "body": "Rk9P"} // {"method":"GET", "url":"https://goku/2"} From 2e261e6a95bddd9a062118b66f71bbbb982b3e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Sat, 7 Jul 2018 18:02:25 +0200 Subject: [PATCH 08/10] targets: Test and refine JSONTargeter --- lib/targets.go | 75 +++++++++++++++++++++++++++++++++---- lib/targets_test.go | 90 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 7 deletions(-) diff --git a/lib/targets.go b/lib/targets.go index f7df8d28..43055ba2 100644 --- a/lib/targets.go +++ b/lib/targets.go @@ -43,11 +43,50 @@ func (t *Target) Request() (*http.Request, error) { return req, nil } +// Equal returns true if the target is equal to the other given target. +func (t *Target) Equal(other *Target) bool { + switch { + case t == other: + return true + case t == nil || other == nil: + return false + default: + equal := t.Method == other.Method && + t.URL == other.URL && + bytes.Equal(t.Body, other.Body) && + len(t.Header) == len(other.Header) + + if !equal { + return false + } + + for k := range t.Header { + left, right := t.Header[k], other.Header[k] + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + } + + return true + } +} + var ( // ErrNoTargets is returned when not enough Targets are available. ErrNoTargets = errors.New("no targets to attack") // ErrNilTarget is returned when the passed Target pointer is nil. ErrNilTarget = errors.New("nil target") + // ErrNoMethod is returned by JSONTargeter when a parsed Target has + // no method. + ErrNoMethod = errors.New("target: required method is missing") + // ErrNoURL is returned by JSONTargeter when a parsed Target has no + // URL. + ErrNoURL = errors.New("target: required url is missing") // TargetFormats contains the canonical list of the valid target // format identifiers. TargetFormats = []string{HTTPTargetFormat, JSONTargetFormat} @@ -91,16 +130,38 @@ func NewJSONTargeter(src io.Reader, body []byte, header http.Header) Targeter { dec.Lock() defer dec.Unlock() - if err = dec.Decode(tgt); err == nil { - return nil + var t Target + if err = dec.Decode(&t); err != nil && err != io.EOF { + return err + } else if t.Method == "" { + return ErrNoMethod + } else if t.URL == "" { + return ErrNoURL } - switch err { - case io.EOF: - return ErrNoTargets - default: - return err + tgt.Method = t.Method + tgt.URL = t.URL + if tgt.Body = body; len(t.Body) > 0 { + tgt.Body = t.Body + } + + if tgt.Header == nil { + tgt.Header = http.Header{} + } + + for k, vs := range header { + tgt.Header[k] = append(tgt.Header[k], vs...) + } + + for k, vs := range t.Header { + tgt.Header[k] = append(tgt.Header[k], vs...) } + + if err == io.EOF { + err = ErrNoTargets + } + + return err } } diff --git a/lib/targets_test.go b/lib/targets_test.go index 188d7d31..5806b7d2 100644 --- a/lib/targets_test.go +++ b/lib/targets_test.go @@ -58,6 +58,96 @@ func TestTargetRequest(t *testing.T) { } } +func TestJSONTargeter(t *testing.T) { + for _, tc := range []struct { + name string + src io.Reader + body []byte + hdr http.Header + in *Target + out *Target + err error + }{ + { + name: "nil target", + src: &bytes.Buffer{}, + in: nil, + out: nil, + err: ErrNilTarget, + }, + { + name: "empty buffer", + src: &bytes.Buffer{}, + in: &Target{}, + out: &Target{}, + err: ErrNoMethod, + }, + { + name: "empty object", + src: strings.NewReader(`{}`), + in: &Target{}, + out: &Target{}, + err: ErrNoMethod, + }, + { + name: "empty method", + src: strings.NewReader(`{"method": ""}`), + in: &Target{}, + out: &Target{}, + err: ErrNoMethod, + }, + { + name: "empty url", + src: strings.NewReader(`{"method": "GET"}`), + in: &Target{}, + out: &Target{}, + err: ErrNoURL, + }, + { + name: "bad body encoding", + src: strings.NewReader(`{"method": "GET", "url": "http://goku", "body": "NOT BASE64"}`), + in: &Target{}, + out: &Target{}, + err: errors.New("illegal base64 data at input byte 3"), + }, + { + name: "default body", + src: strings.NewReader(`{"method": "GET", "url": "http://goku"}`), + body: []byte(`ATTACK!`), + in: &Target{}, + out: &Target{Method: "GET", URL: "http://goku", Body: []byte("ATTACK!")}, + }, + { + name: "headers merge", + src: strings.NewReader(`{"method": "GET", "url": "http://goku", "header":{"x": ["foo"]}}`), + hdr: http.Header{"x": []string{"bar"}}, + in: &Target{Header: http.Header{"y": []string{"baz"}}}, + out: &Target{Method: "GET", URL: "http://goku", Header: http.Header{"y": []string{"baz"}, "x": []string{"bar", "foo"}}}, + }, + { + name: "no defaults", + src: strings.NewReader(`{"method": "GET", "url": "http://goku", "header":{"x": ["foo"]}, "body": "QVRUQUNLIQ=="}`), + in: &Target{}, + out: &Target{Method: "GET", URL: "http://goku", Header: http.Header{"x": []string{"foo"}}, Body: []byte("ATTACK!")}, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := NewJSONTargeter(tc.src, tc.body, tc.hdr)(tc.in) + if got, want := tc.in, tc.out; !got.Equal(want) { + t.Errorf("got Target %#v, want %#v", got, want) + } + + if got, want := fmt.Sprint(err), fmt.Sprint(tc.err); got != want { + t.Errorf("got error: %+v, want: %+v", got, want) + } + }) + } + +} + func TestNewEagerTargeter(t *testing.T) { t.Parallel() From 4911bc1a9bc8eca54da945ead58a22d9e0f73ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Sat, 7 Jul 2018 18:03:31 +0200 Subject: [PATCH 09/10] jsonschema: Exit on fs.Parse error --- internal/cmd/jsonschema/main.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/cmd/jsonschema/main.go b/internal/cmd/jsonschema/main.go index 70337491..22e56035 100644 --- a/internal/cmd/jsonschema/main.go +++ b/internal/cmd/jsonschema/main.go @@ -20,11 +20,13 @@ func main() { valid := strings.Join(keys(types), ", ") - fs := flag.NewFlagSet("jsonschema", flag.ExitOnError) + fs := flag.NewFlagSet("jsonschema", flag.ContinueOnError) typ := fs.String("type", "", fmt.Sprintf("Vegeta type to generate a JSON schema for [%s]", valid)) out := fs.String("output", "stdout", "Output file") - fs.Parse(os.Args[1:]) + if err := fs.Parse(os.Args[1:]); err != nil { + die("%s", err) + } t, ok := types[*typ] if !ok { From 6de2156a2d9638a937b4cbf2078686910eafa573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Sat, 7 Jul 2018 18:19:13 +0200 Subject: [PATCH 10/10] targets: Refactor EagerTargeter to ReadAllTargets --- attack.go | 4 +++- lib/targets.go | 14 ++++---------- lib/targets_test.go | 30 ++++++++++++------------------ 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/attack.go b/attack.go index c0379b93..4680ef7d 100644 --- a/attack.go +++ b/attack.go @@ -127,9 +127,11 @@ func attack(opts *attackOpts) (err error) { } if !opts.lazy { - if tr, err = vegeta.NewEagerTargeter(tr); err != nil { + targets, err := vegeta.ReadAllTargets(tr) + if err != nil { return err } + tr = vegeta.NewStaticTargeter(targets...) } out, err := file(opts.outputf, true) diff --git a/lib/targets.go b/lib/targets.go index 43055ba2..02d06b4c 100644 --- a/lib/targets.go +++ b/lib/targets.go @@ -178,16 +178,10 @@ func NewStaticTargeter(tgts ...Target) Targeter { } } -// NewEagerTargeter eagerly reads all Targets out of the provided Targeter and -// returns a NewStaticTargeter with them. -func NewEagerTargeter(t Targeter) (Targeter, error) { - var ( - tgts []Target - tgt Target - err error - ) - +// ReadAllTargets eagerly reads all Targets out of the provided Targeter. +func ReadAllTargets(t Targeter) (tgts []Target, err error) { for { + var tgt Target if err = t(&tgt); err == ErrNoTargets { break } else if err != nil { @@ -200,7 +194,7 @@ func NewEagerTargeter(t Targeter) (Targeter, error) { return nil, ErrNoTargets } - return NewStaticTargeter(tgts...), nil + return tgts, nil } // NewHTTPTargeter returns a new Targeter that decodes one Target from the diff --git a/lib/targets_test.go b/lib/targets_test.go index 5806b7d2..60ca9875 100644 --- a/lib/targets_test.go +++ b/lib/targets_test.go @@ -148,15 +148,11 @@ func TestJSONTargeter(t *testing.T) { } -func TestNewEagerTargeter(t *testing.T) { +func TestReadAllTargets(t *testing.T) { t.Parallel() src := []byte("GET http://:6060/\nHEAD http://:6606/") - read, err := NewEagerTargeter(NewHTTPTargeter(bytes.NewReader(src), []byte("body"), nil)) - if err != nil { - t.Fatalf("Couldn't parse valid source: %s", err) - } - for _, want := range []Target{ + want := []Target{ { Method: "GET", URL: "http://:6060/", @@ -169,13 +165,15 @@ func TestNewEagerTargeter(t *testing.T) { Body: []byte("body"), Header: http.Header{}, }, - } { - var got Target - if err := read(&got); err != nil { - t.Fatal(err) - } else if !reflect.DeepEqual(want, got) { - t.Fatalf("want: %#v, got: %#v", want, got) - } + } + + got, err := ReadAllTargets(NewHTTPTargeter(bytes.NewReader(src), []byte("body"), nil)) + if err != nil { + t.Fatalf("error reading all targets: %v", err) + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("got: %#v, want: %#v", got, want) } } @@ -305,14 +303,10 @@ func TestNewHTTPTargeter(t *testing.T) { func TestErrNilTarget(t *testing.T) { t.Parallel() - eager, err := NewEagerTargeter(NewHTTPTargeter(strings.NewReader("GET http://foo.bar"), nil, nil)) - if err != nil { - t.Fatal(err) - } for i, tr := range []Targeter{ NewStaticTargeter(Target{Method: "GET", URL: "http://foo.bar"}), + NewJSONTargeter(strings.NewReader(""), nil, nil), NewHTTPTargeter(strings.NewReader("GET http://foo.bar"), nil, nil), - eager, } { if got, want := tr(nil), ErrNilTarget; got != want { t.Errorf("test #%d: got: %v, want: %v", i, got, want)