Compare commits

...
18 Commits
Author SHA1 Message Date
aykhans 9c7b7c26a0 refactor(script): drop the per-call maxBodySize and its client cache 2026-09-29 10:58:25 +04:00
aykhans ef5907dc0c fix: cap body limits at 1GiB and key script clients by the exact size 2026-09-29 00:55:04 +04:00
aykhans 1956eb1c44 fix(script): enforce script body limits without streaming
Streaming accepted a chunked body cut at a chunk boundary as complete,
so the limit now comes from the client that reads the body, picked per
requested size. Script requests keep their own default, maxBodySize
accepts sizes like 10MiB, and files sarin downloads for itself are
capped at 64MiB.
2026-09-29 00:41:48 +04:00
aykhans f1d91e3d77 feat: limit response body sizes
fasthttp allocates the body from the declared Content-Length before
reading it, so a server could exhaust memory by declaring a size it
never sends. The new max response body config bounds every response,
defaults to 10MiB and accepts sizes like 10MiB or 2MB. Scripts get the
same as a maxBodySize option on http().
2026-09-28 22:49:39 +04:00
aykhans 412a02fea3 chore: bump golangci-lint to v2.14.0 2026-09-27 01:05:07 +04:00
aykhans 8da05bb10c fix(stats): key generation failures by source and bound the key count 2026-09-27 01:03:04 +04:00
aykhans c32949c8e0 fix(script): convert non-string header, param and cookie values to text 2026-09-26 01:17:49 +04:00
aykhans a30c862936 fix(script): bind script HTTP requests to the run context 2026-09-25 22:34:20 +04:00
aykhans 5d91141f80 fix(script): stringify thrown JavaScript values through Runtime.Try 2026-09-24 22:34:59 +04:00
aykhans 878ca6dfbd fix(script): bound maxRedirects to 100 and reject out of range values 2026-09-24 19:08:58 +04:00
aykhans 9b0e7c4304 fix: dial IPv6 hosts by enabling dual-stack and trying every resolved address 2026-09-23 01:03:42 +04:00
aykhans d4c7dcc42c fix: apply per-request timeouts to script HTTP proxy dials 2026-09-23 00:45:44 +04:00
aykhans 00226b0eb7 fix: size script HTTP client connection pool to worker count 2026-09-22 19:29:51 +04:00
aykhans a60287b78b docs: document http() and Lua json in scripts 2026-09-20 23:30:16 +04:00
aykhans 529420ee5a feat(script): replace followRedirects with maxRedirects http option 2026-09-20 23:18:21 +04:00
aykhans 8b437b487c feat(script): add http() and json to Lua and JS scripts 2026-09-20 23:02:51 +04:00
aykhans 5ca9cda2b9 docs(cli): show config-file flag type as []string in help text 2026-09-19 00:14:48 +04:00
aykhans 6b03ba9579 refactor(sarin): extract cookie header building into cookieHeaderValue 2026-09-18 22:39:56 +04:00
32 changed files with 1795 additions and 245 deletions

No files matched your search

+1 -1
View File
@@ -24,4 +24,4 @@ jobs:
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v9 uses: golangci/golangci-lint-action@v9
with: with:
version: v2.13.2 version: v2.14.0
+1 -1
View File
@@ -67,7 +67,7 @@ nix run github:aykhans/sarin/release -- -U http://example.com -r 100 -c 10
Install into your profile: Install into your profile:
```sh ```sh
nix profile install github:aykhans/sarin/release nix profile add github:aykhans/sarin/release
``` ```
Or add it to your own flake via the overlay: Or add it to your own flake via the overlay:
+1 -1
View File
@@ -5,7 +5,7 @@ vars:
BIN_DIR: ./bin BIN_DIR: ./bin
GO_VERSION: GO_VERSION:
sh: sed -n 's/^go //p' go.mod sh: sed -n 's/^go //p' go.mod
GOLANGCI_LINT_VERSION: v2.13.2 GOLANGCI_LINT_VERSION: v2.14.0
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}" GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
tasks: tasks:
+3 -2
View File
@@ -51,7 +51,8 @@ func main() {
ctx, ctx,
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout, combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration, *combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
*combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers, *combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, *combinedConfig.MaxResponseBody,
combinedConfig.Params, combinedConfig.Headers,
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values, combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
*combinedConfig.Output != config.ConfigOutputTypeNone, *combinedConfig.Output != config.ConfigOutputTypeNone,
*combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile, *combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile,
@@ -75,7 +76,7 @@ func main() {
}), }),
) )
srn.Start(ctx, stopCtrl) srn.Start(stopCtrl)
switch *combinedConfig.Output { switch *combinedConfig.Output {
case config.ConfigOutputTypeNone: case config.ConfigOutputTypeNone:
+118 -3
View File
@@ -15,7 +15,7 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
> **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values. > **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values.
| Name | YAML | CLI | ENV | Default | Description | | Name | YAML | CLI | ENV | Default | Description |
| --------------------------- | ----------------------------------- | -------------------------------------------- | -------------------------------- | ------- | ---------------------------- | | --------------------------------------- | ----------------------------------- | -------------------------------------------- | ----------------------------------- | ------- | ----------------------------- |
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message | | [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info | | [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info |
| [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration | | [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration |
@@ -23,6 +23,7 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
| [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) | | [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) |
| [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) | | [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) |
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout | | [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
| [Max Response Body](#max-response-body) | `maxResponseBody`<br>(size) | `-max-response-body` / `-S`<br>(size) | `SARIN_MAX_RESPONSE_BODY`<br>(size) | `10MiB` | Largest response body to read |
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers | | [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send | | [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration | | [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
@@ -168,6 +169,18 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
**Examples:** `5s`, `300ms`, `1m20s` **Examples:** `5s`, `300ms`, `1m20s`
## Max Response Body
Largest response body sarin reads. Defaults to `10MiB`, and `0` removes the limit.
The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1GiB`, because one response can take a few times the limit in memory.
**Examples:** `1048576`, `10MiB`, `2MB`, `512KiB`
A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `body size exceeds the given limit` and is counted like any other error.
The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. It covers the requests scripts make as well.
## Concurrency ## Concurrency
Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`. Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`.
@@ -456,7 +469,9 @@ 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).
**YAML example:** **YAML example:**
@@ -521,7 +536,9 @@ 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).
**YAML example:** **YAML example:**
@@ -554,3 +571,101 @@ js:
```sh ```sh
SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }' SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
``` ```
## HTTP Requests in Scripts
Lua and JavaScript scripts can send their own HTTP requests and use the response in the main request, for example to fetch a fresh token or CSRF value from another endpoint.
**Function:**
```
http(url, opts)
```
`url` must be an absolute `http` or `https` URL. `opts` is optional.
**Options:**
| Option | Type | Default | Description |
| -------------- | --------------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
| `method` | string | `GET` | HTTP method, sent as given |
| `headers` | table/object | - | Request headers, same shape as `req.headers` |
| `params` | table/object | - | Query parameters, added to any already in the URL |
| `cookies` | table/object | - | Cookies, sent as a single `Cookie` header |
| `body` | string | `""` | Request body |
| `timeout` | duration string (`"500ms"`) | `30s` | Request timeout (the `timeout` config does not apply), applied to each hop when redirects are followed |
| `insecure` | boolean | `false` | Skip TLS verification (the `insecure` config does not apply) |
| `maxRedirects` | number | `0` | How many redirects to follow, `0` follows none, maximum 100 |
Unknown options and values of the wrong type raise an error.
**Response:**
| Field / Method | Description |
| -------------- | ------------------------------------------------------------------ |
| `status` | Status code (number) |
| `headers` | Response headers, each key holding an array of values |
| `body` | Response body (string) |
| `header(name)` | First value of a header, case-insensitive; `nil`/`null` if missing |
| `cookie(name)` | Value of a cookie from `Set-Cookie`; `nil`/`null` if missing |
| `json()` | JavaScript only: parses the body as JSON |
In Lua, call the helpers as `res:header("name")` or `res.header("name")`.
**Behavior:**
- **Runs on every request:** the requests are sent each time `transform` runs, so every main request triggers them. Script globals persist across requests within a worker, so a script can cache a value itself (e.g. `if not token then token = login() end`).
- **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to.
- **Status codes:** non-2xx responses are returned normally; check `status` in the script.
- **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript).
- **Response bodies:** the [Max Response Body](#max-response-body) config bounds these requests too, and a body above it raises an error the script can catch.
- **Only inside `transform`:** `http` works anywhere while `transform` runs, including in helper functions it calls. Calling it at the top level of a script raises an error, because that code also runs during config validation and when each worker starts.
- **Dry run:** script requests are still sent in dry-run mode.
- **Not measured:** script requests are not included in the results.
**Lua JSON:**
Lua has no built-in JSON support, so Lua scripts get a global `json` table:
- `json.decode(string)`: arrays become 1-indexed tables and `null` becomes `nil` (so `null` array items leave holes).
- `json.encode(value)`: a table with keys exactly `1..n` becomes an array, any other table becomes an object, and an empty table becomes `{}`.
JavaScript scripts use the built-in `JSON` object.
**Lua example:**
```lua
function transform(req)
local res = http("https://api.example.com/login", {
method = "POST",
headers = { ["Content-Type"] = "application/json" },
body = json.encode({ user = "test", password = "secret" }),
timeout = "2s",
})
if res.status ~= 200 then
error("login failed: " .. res.status)
end
req.headers["Authorization"] = "Bearer " .. json.decode(res.body).token
return req
end
```
**JavaScript example:**
```javascript
function transform(req) {
const res = http("https://api.example.com/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user: "test", password: "secret" }),
timeout: "2s",
});
if (res.status !== 200) {
throw new Error("login failed: " + res.status);
}
req.headers["Authorization"] = "Bearer " + res.json().token;
return req;
}
```
+151
View File
@@ -1087,3 +1087,154 @@ js: "@./scripts/body.js"
``` ```
</details> </details>
**Fetch a value from another endpoint before each request:**
Scripts can send their own requests with `http`. Here each request first fetches a CSRF token and sends it in a header and cookie. See [HTTP Requests in Scripts](configuration.md#http-requests-in-scripts) for all options.
```sh
sarin -U http://example.com/api/orders -r 1000 -c 10 \
-M POST \
-lua 'function transform(req)
local res = http("http://example.com/csrf")
req.headers["X-CSRF-Token"] = json.decode(res.body).token
req.cookies["session"] = res:cookie("session")
return req
end'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/orders
requests: 1000
concurrency: 10
method: POST
lua: |
function transform(req)
local res = http("http://example.com/csrf")
req.headers["X-CSRF-Token"] = json.decode(res.body).token
req.cookies["session"] = res:cookie("session")
return req
end
```
</details>
**Log in once per worker and reuse the token (JavaScript):**
```yaml
url: http://example.com/api/profile
requests: 1000
concurrency: 10
js: |
let token;
function login() {
const res = http("http://example.com/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user: "test", password: "secret" }),
});
if (res.status !== 200) throw new Error("login failed: " + res.status);
return res.json().token;
}
function transform(req) {
if (!token) token = login();
req.headers["Authorization"] = "Bearer " + token;
return req;
}
```
**Chain two calls, then use the result in the URL path (Lua):**
```yaml
url: http://example.com/api/placeholder
requests: 500
concurrency: 10
lua: |
function transform(req)
local token = json.decode(http("http://example.com/login", {
method = "POST",
body = json.encode({ user = "test", password = "secret" }),
headers = { ["Content-Type"] = "application/json" },
}).body).token
local orders = json.decode(http("http://example.com/api/orders", {
headers = { Authorization = "Bearer " .. token },
}).body)
req.path = "/api/orders/" .. orders[1].id
req.headers["Authorization"] = "Bearer " .. token
return req
end
```
**Refresh a token only when it expires (JavaScript):**
```yaml
url: http://example.com/api/profile
requests: 5000
concurrency: 20
js: |
let token;
let expiresAt = 0;
function transform(req) {
if (Date.now() >= expiresAt) {
const res = http("http://example.com/login", { method: "POST", body: "{}" });
token = res.json().token;
expiresAt = Date.now() + 60000; // refresh once a minute per worker
}
req.headers["Authorization"] = "Bearer " + token;
return req;
}
```
**Follow redirects and use a slower timeout for a login endpoint:**
```sh
sarin -U http://example.com/api/me -r 200 -c 5 \
-lua 'function transform(req)
local res = http("http://example.com/login", {
method = "POST",
maxRedirects = 5,
timeout = "10s",
})
req.cookies["session"] = res:cookie("session")
return req
end'
```
**Keep sending the main request even when the extra call fails:**
A failed `http` call raises an error, which skips the main request and records the failure in the results. Catch it to decide yourself:
```sh
sarin -U http://example.com/api/items -r 1000 -c 10 \
-lua 'function transform(req)
local ok, res = pcall(http, "http://example.com/api/flags")
if ok and res.status == 200 then
req.headers["X-Flags"] = res.body
else
req.headers["X-Flags"] = "default"
end
return req
end'
```
The JavaScript equivalent:
```sh
sarin -U http://example.com/api/items -r 1000 -c 10 \
-js 'function transform(req) {
try {
req.headers["X-Flags"] = http("http://example.com/api/flags").body;
} catch (e) {
req.headers["X-Flags"] = "default";
}
return req;
}'
```
+1 -1
View File
@@ -521,7 +521,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` | | `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` | | `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
| `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` | | `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` |
| `fakeit_Quote` | Random quote | `"Energy is contagious—so is fear"` | | `fakeit_Quote` | Random quote | `"Energy is contagious so is fear"` |
| `fakeit_Phrase` | Random phrase | `"how many siblings do you have"` | | `fakeit_Phrase` | Random phrase | `"how many siblings do you have"` |
| `fakeit_PhraseNoun` | Noun phrase | `"a tribe"` | | `fakeit_PhraseNoun` | Noun phrase | `"a tribe"` |
| `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` | | `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` |
+49
View File
@@ -0,0 +1,49 @@
package config
import (
"flag"
"fmt"
"go.aykhans.me/sarin/internal/types"
"go.yaml.in/yaml/v4"
)
var (
_ flag.Value = (*byteSize)(nil)
_ yaml.Unmarshaler = (*byteSize)(nil)
)
// byteSize is a size in bytes that reads and prints unit suffixes such as 10MiB.
type byteSize uint64
func (size byteSize) String() string {
return types.FormatByteSize(uint64(size))
}
// Set fills the size from a command line value.
func (size *byteSize) Set(value string) error {
parsed, err := types.ParseByteSize(value)
if err != nil {
return err
}
*size = byteSize(parsed)
return nil
}
// UnmarshalYAML accepts both a YAML integer and a string with a unit.
func (size *byteSize) UnmarshalYAML(node *yaml.Node) error {
if node.Kind != yaml.ScalarNode {
return fmt.Errorf("expected a size, but got %s", node.Tag)
}
if node.Tag == "!!int" {
var parsed uint64
if err := node.Decode(&parsed); err != nil {
return types.ErrByteSizeInvalid
}
*size = byteSize(parsed)
return nil
}
return size.Set(node.Value)
}
+9 -1
View File
@@ -23,7 +23,7 @@ Flags:
-h, -help Help for sarin -h, -help Help for sarin
-v, -version Version for sarin -v, -version Version for sarin
-s, -show-config bool Show the final config after parsing all sources (default %v) -s, -show-config bool Show the final config after parsing all sources (default %v)
-f, -config-file string Path to the config file (local file / http URL) -f, -config-file []string Path to the config file (local file / http URL)
-c, -concurrency uint Number of concurrent requests (default %d) -c, -concurrency uint Number of concurrent requests (default %d)
-r, -requests uint Number of total requests -r, -requests uint Number of total requests
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h) -d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
@@ -43,6 +43,7 @@ Flags:
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080") -X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
-V, -values []string List of values for templating (e.g. "key1=value1") -V, -values []string List of values for templating (e.g. "key1=value1")
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v) -T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
-S, -max-response-body size Largest response body to read (e.g. 10MiB, 2MB), 0 for no limit (default %s)
-I, -insecure bool Skip SSL/TLS certificate verification (default %v) -I, -insecure bool Skip SSL/TLS certificate verification (default %v)
-lua []string Lua script for request transformation (inline or @file/@url) -lua []string Lua script for request transformation (inline or @file/@url)
-js []string JavaScript script for request transformation (inline or @file/@url)` -js []string JavaScript script for request transformation (inline or @file/@url)`
@@ -106,6 +107,7 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
proxies = stringSliceArg{} proxies = stringSliceArg{}
values = stringSliceArg{} values = stringSliceArg{}
timeout time.Duration timeout time.Duration
maxResponseBody byteSize
insecure bool insecure bool
luaScripts = stringSliceArg{} luaScripts = stringSliceArg{}
jsScripts = stringSliceArg{} jsScripts = stringSliceArg{}
@@ -174,6 +176,9 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)") flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)") flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
flagSet.Var(&maxResponseBody, "max-response-body", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit")
flagSet.Var(&maxResponseBody, "S", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit")
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification") flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification") flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
@@ -258,6 +263,8 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
config.Values = append(config.Values, values...) config.Values = append(config.Values, values...)
case "timeout", "T": case "timeout", "T":
config.Timeout = new(timeout) config.Timeout = new(timeout)
case "max-response-body", "S":
config.MaxResponseBody = new(uint64(maxResponseBody))
case "insecure", "I": case "insecure", "I":
config.Insecure = new(insecure) config.Insecure = new(insecure)
case "lua": case "lua":
@@ -286,6 +293,7 @@ func (parser ConfigCLIParser) PrintHelp() {
Defaults.Method, Defaults.Method,
Defaults.RequestTimeout, Defaults.RequestTimeout,
types.FormatByteSize(Defaults.MaxResponseBody),
Defaults.Insecure, Defaults.Insecure,
) )
} }
+24 -4
View File
@@ -30,6 +30,7 @@ var Defaults = struct {
UserAgent string UserAgent string
Method string Method string
RequestTimeout time.Duration RequestTimeout time.Duration
MaxResponseBody uint64
Concurrency uint Concurrency uint
ShowConfig bool ShowConfig bool
Progress ConfigProgressType Progress ConfigProgressType
@@ -41,6 +42,7 @@ var Defaults = struct {
UserAgent: "Sarin/" + version.Version, UserAgent: "Sarin/" + version.Version,
Method: "GET", Method: "GET",
RequestTimeout: time.Second * 10, RequestTimeout: time.Second * 10,
MaxResponseBody: 10 << 20, // 10 MiB
Concurrency: 1, Concurrency: 1,
ShowConfig: false, ShowConfig: false,
Progress: ConfigProgressTypeBar, Progress: ConfigProgressTypeBar,
@@ -87,6 +89,7 @@ type Config struct {
Methods []string `yaml:"methods,omitempty"` Methods []string `yaml:"methods,omitempty"`
URL *url.URL `yaml:"url,omitempty"` URL *url.URL `yaml:"url,omitempty"`
Timeout *time.Duration `yaml:"timeout,omitempty"` Timeout *time.Duration `yaml:"timeout,omitempty"`
MaxResponseBody *uint64 `yaml:"maxResponseBody,omitempty"`
Concurrency *uint `yaml:"concurrency,omitempty"` Concurrency *uint `yaml:"concurrency,omitempty"`
Requests *uint64 `yaml:"requests,omitempty"` Requests *uint64 `yaml:"requests,omitempty"`
Duration *time.Duration `yaml:"duration,omitempty"` Duration *time.Duration `yaml:"duration,omitempty"`
@@ -177,6 +180,9 @@ func (config Config) MarshalYAML() (any, error) {
if config.Timeout != nil { if config.Timeout != nil {
addField(content, "timeout", toNode(*config.Timeout), "") addField(content, "timeout", toNode(*config.Timeout), "")
} }
if config.MaxResponseBody != nil {
addField(content, "maxResponseBody", toNode(types.FormatByteSize(*config.MaxResponseBody)), "")
}
if config.Concurrency != nil { if config.Concurrency != nil {
addField(content, "concurrency", toNode(*config.Concurrency), "") addField(content, "concurrency", toNode(*config.Concurrency), "")
} }
@@ -301,6 +307,9 @@ func (config *Config) Merge(newConfig *Config) {
if newConfig.Timeout != nil { if newConfig.Timeout != nil {
config.Timeout = newConfig.Timeout config.Timeout = newConfig.Timeout
} }
if newConfig.MaxResponseBody != nil {
config.MaxResponseBody = newConfig.MaxResponseBody
}
if newConfig.Concurrency != nil { if newConfig.Concurrency != nil {
config.Concurrency = newConfig.Concurrency config.Concurrency = newConfig.Concurrency
} }
@@ -379,6 +388,9 @@ func (config *Config) SetDefaults() {
if config.Timeout == nil { if config.Timeout == nil {
config.Timeout = &Defaults.RequestTimeout config.Timeout = &Defaults.RequestTimeout
} }
if config.MaxResponseBody == nil {
config.MaxResponseBody = &Defaults.MaxResponseBody
}
if config.Concurrency == nil { if config.Concurrency == nil {
config.Concurrency = new(Defaults.Concurrency) config.Concurrency = new(Defaults.Concurrency)
} }
@@ -454,6 +466,14 @@ func (config Config) Validate() error {
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0"))) validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
} }
if config.MaxResponseBody != nil && *config.MaxResponseBody > types.ByteSizeLimit {
validationErrors = append(validationErrors, types.NewFieldValidationError(
"MaxResponseBody",
types.FormatByteSize(*config.MaxResponseBody),
errors.New("max response body must not exceed "+types.FormatByteSize(types.ByteSizeLimit)),
))
}
if config.ShowConfig == nil { if config.ShowConfig == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required"))) validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
} }
@@ -715,7 +735,7 @@ func validateScriptSource(script string) error {
return types.ErrScriptEmpty return types.ErrScriptEmpty
} }
// Not a file/URL reference - it's an inline script // Not a file/URL reference, so it's an inline script
if !strings.HasPrefix(script, "@") { if !strings.HasPrefix(script, "@") {
return nil return nil
} }
@@ -725,7 +745,7 @@ func validateScriptSource(script string) error {
return nil return nil
} }
// It's a file or URL reference - validate the source // It's a file or URL reference, so validate the source
source := script[1:] // Remove the @ prefix source := script[1:] // Remove the @ prefix
if source == "" { if source == "" {
@@ -744,7 +764,7 @@ func validateScriptSource(script string) error {
return nil return nil
} }
// It's a file path - basic validation (not empty, checked above) // It's a file path, already checked to be non-empty above
return nil return nil
} }
@@ -822,7 +842,7 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m printConfigModel) View() tea.View { func (m printConfigModel) View() tea.View {
// AltScreen and MouseMode were program options in bubbletea v1; in v2 they // AltScreen and MouseMode were program options in bubbletea v1, in v2 they
// are view properties, so every return path has to declare them. // are view properties, so every return path has to declare them.
newView := func(s string) tea.View { newView := func(s string) tea.View {
v := tea.NewView(s) v := tea.NewView(s)
+16
View File
@@ -195,6 +195,22 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
} }
} }
if maxResponseBody := parser.getEnv("MAX_RESPONSE_BODY"); maxResponseBody != "" {
maxResponseBodyParsed, err := types.ParseByteSize(maxResponseBody)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("MAX_RESPONSE_BODY"),
maxResponseBody,
err,
),
)
} else {
config.MaxResponseBody = &maxResponseBodyParsed
}
}
if insecure := parser.getEnv("INSECURE"); insecure != "" { if insecure := parser.getEnv("INSECURE"); insecure != "" {
insecureParsed, err := utilsParse.ParseString[bool](insecure) insecureParsed, err := utilsParse.ParseString[bool](insecure)
if err != nil { if err != nil {
+13 -1
View File
@@ -79,10 +79,18 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
} }
data, err := io.ReadAll(resp.Body) // A declared size above the limit is refused before anything is read.
if resp.ContentLength > types.RemoteFetchLimit {
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
if err != nil { if err != nil {
return nil, types.NewHTTPFetchError(url, err) return nil, types.NewHTTPFetchError(url, err)
} }
if len(data) > types.RemoteFetchLimit {
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
return data, nil return data, nil
} }
@@ -211,6 +219,7 @@ type configYAML struct {
Proxies stringOrSliceField `yaml:"proxy"` Proxies stringOrSliceField `yaml:"proxy"`
Values stringOrSliceField `yaml:"values"` Values stringOrSliceField `yaml:"values"`
Timeout *time.Duration `yaml:"timeout"` Timeout *time.Duration `yaml:"timeout"`
MaxResponseBody *byteSize `yaml:"maxResponseBody"`
Insecure *bool `yaml:"insecure"` Insecure *bool `yaml:"insecure"`
Lua stringOrSliceField `yaml:"lua"` Lua stringOrSliceField `yaml:"lua"`
Js stringOrSliceField `yaml:"js"` Js stringOrSliceField `yaml:"js"`
@@ -290,6 +299,9 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
config.Values = append(config.Values, parsedData.Values...) config.Values = append(config.Values, parsedData.Values...)
config.Timeout = parsedData.Timeout config.Timeout = parsedData.Timeout
if parsedData.MaxResponseBody != nil {
config.MaxResponseBody = new(uint64(*parsedData.MaxResponseBody))
}
config.Insecure = parsedData.Insecure config.Insecure = parsedData.Insecure
config.Lua = append(config.Lua, parsedData.Lua...) config.Lua = append(config.Lua, parsedData.Lua...)
config.Js = append(config.Js, parsedData.Js...) config.Js = append(config.Js, parsedData.Js...)
+1 -1
View File
@@ -193,7 +193,7 @@ func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.F
func ValidateTemplates(config *Config) []types.FieldValidationError { func ValidateTemplates(config *Config) []types.FieldValidationError {
// Create template function map using the same functions as sarin package // Create template function map using the same functions as sarin package
// Use nil for fileCache during validation - templates are only parsed, not executed // fileCache is nil because templates are only parsed here, not executed
randSource := sarin.NewDefaultRandSource() randSource := sarin.NewDefaultRandSource()
funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil) funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
+106 -85
View File
@@ -5,27 +5,19 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"math"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"time" "time"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpproxy"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
utilsSlice "go.aykhans.me/utils/slice" utilsSlice "go.aykhans.me/utils/slice"
"golang.org/x/net/proxy" "golang.org/x/net/proxy"
) )
type HostClientGenerator func() *fasthttp.HostClient // clientIndexGenerator returns the index of the host client (and proxy) to use next.
type clientIndexGenerator func() int
func safeUintToInt(u uint) int {
if u > math.MaxInt {
return math.MaxInt
}
return int(u)
}
// NewHostClients creates a list of fasthttp.HostClient instances for the given proxies. // NewHostClients creates a list of fasthttp.HostClient instances for the given proxies.
// If no proxies are provided, a single client without a proxy is returned. // If no proxies are provided, a single client without a proxy is returned.
@@ -38,6 +30,7 @@ func NewHostClients(
maxConns uint, maxConns uint,
requestURL *url.URL, requestURL *url.URL,
skipVerify bool, skipVerify bool,
maxResponseBody uint64,
) ([]*fasthttp.HostClient, error) { ) ([]*fasthttp.HostClient, error) {
isTLS := requestURL.Scheme == "https" isTLS := requestURL.Scheme == "https"
@@ -66,6 +59,7 @@ func NewHostClients(
MaxConnDuration: timeout, MaxConnDuration: timeout,
WriteTimeout: timeout, WriteTimeout: timeout,
ReadTimeout: timeout, ReadTimeout: timeout,
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
DisableHeaderNamesNormalizing: true, DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true, DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true, NoDefaultUserAgentHeader: true,
@@ -79,6 +73,7 @@ func NewHostClients(
client := &fasthttp.HostClient{ client := &fasthttp.HostClient{
MaxConns: safeUintToInt(maxConns), MaxConns: safeUintToInt(maxConns),
IsTLS: isTLS, IsTLS: isTLS,
DialDualStack: true,
TLSConfig: &tls.Config{ TLSConfig: &tls.Config{
InsecureSkipVerify: skipVerify, //nolint:gosec InsecureSkipVerify: skipVerify, //nolint:gosec
}, },
@@ -87,6 +82,7 @@ func NewHostClients(
MaxConnDuration: timeout, MaxConnDuration: timeout,
WriteTimeout: timeout, WriteTimeout: timeout,
ReadTimeout: timeout, ReadTimeout: timeout,
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
DisableHeaderNamesNormalizing: true, DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true, DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true, NoDefaultUserAgentHeader: true,
@@ -94,40 +90,45 @@ func NewHostClients(
return []*fasthttp.HostClient{client}, nil return []*fasthttp.HostClient{client}, nil
} }
// NewProxyDialFunc creates a dial function for the given proxy URL. // NewProxyDialFunc creates a dial function for the given proxy URL with a fixed timeout.
// It can return the following errors: // It can return the following errors:
// - types.ProxyUnsupportedSchemeError // - types.ProxyUnsupportedSchemeError
// - types.ErrProxyNoContextDialer
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) { func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
var ( dial, err := NewProxyDialFuncWithTimeout(ctx, proxyURL)
dialer fasthttp.DialFunc if err != nil {
err error return nil, err
) }
return func(addr string) (net.Conn, error) {
return dial(addr, timeout)
}, nil
}
// NewProxyDialFuncWithTimeout creates a dial function for the given proxy URL that takes the timeout per call.
// It can return the following errors:
// - types.ProxyUnsupportedSchemeError
// - types.ErrProxyNoContextDialer
func NewProxyDialFuncWithTimeout(ctx context.Context, proxyURL *url.URL) (fasthttp.DialFuncWithTimeout, error) {
switch proxyURL.Scheme { switch proxyURL.Scheme {
case "socks5": case "socks5":
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, true) return fasthttpSocksDialer(ctx, proxyURL, true)
if err != nil {
return nil, err
}
case "socks5h": case "socks5h":
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, false) return fasthttpSocksDialer(ctx, proxyURL, false)
if err != nil {
return nil, err
}
case "http": case "http":
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxyURL.String(), timeout) return fasthttpConnectDialer(proxyURL, false), nil
case "https": case "https":
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout) return fasthttpConnectDialer(proxyURL, true), nil
default: default:
return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme) return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme)
} }
return dialer, nil
} }
// The returned dial function can return the following errors: // fasthttpSocksDialer creates a SOCKS5 dial function that takes the timeout per call.
// It can return the following errors:
// - types.ErrProxyNoContextDialer
// - types.ProxyDialError // - types.ProxyDialError
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) { func fasthttpSocksDialer(ctx context.Context, proxyURL *url.URL, resolveLocally bool) (fasthttp.DialFuncWithTimeout, error) {
netDialer := &net.Dialer{} netDialer := &net.Dialer{}
// Parse auth from proxy URL if present // Parse auth from proxy URL if present
@@ -149,66 +150,92 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
proxyStr := proxyURL.String() proxyStr := proxyURL.String()
// Assert to ContextDialer for timeout support // Timeouts need DialContext, which proxy.SOCKS5 always provides with a net.Dialer.
contextDialer, ok := socksDialer.(proxy.ContextDialer) contextDialer, ok := socksDialer.(proxy.ContextDialer)
if !ok { if !ok {
// Fallback without timeout (should not happen with net.Dialer) return nil, types.ErrProxyNoContextDialer
return func(addr string) (net.Conn, error) {
conn, err := socksDialer.Dial("tcp", addr)
if err != nil {
return nil, types.NewProxyDialError(proxyStr, err)
}
return conn, nil
}, nil
} }
// Return dial function that uses context with timeout // Return dial function that uses context with timeout
return func(addr string) (net.Conn, error) { return func(addr string, timeout time.Duration) (net.Conn, error) {
deadline := time.Now().Add(timeout) deadline := time.Now().Add(timeout)
addrs := []string{addr}
if resolveLocally { if resolveLocally {
host, port, err := net.SplitHostPort(addr) var err error
addrs, err = resolveIPv4First(ctx, addr, timeout)
if err != nil { if err != nil {
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
dnsCancel()
if err != nil {
return nil, types.NewProxyDialError(proxyStr, err)
}
if len(ips) == 0 {
return nil, types.NewProxyDialError(proxyStr, types.NewProxyResolveError(host))
} }
// Use the first resolved IP // Try every resolved address, so one unreachable family does not fail the dial.
addr = net.JoinHostPort(ips[0].String(), port) var lastErr error
} for _, candidate := range addrs {
// Use remaining time for dial
remaining := time.Until(deadline) remaining := time.Until(deadline)
if remaining <= 0 { if remaining <= 0 {
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded) return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
} }
dialCtx, dialCancel := context.WithTimeout(ctx, remaining) dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
defer dialCancel() conn, err := contextDialer.DialContext(dialCtx, "tcp", candidate)
dialCancel()
conn, err := contextDialer.DialContext(dialCtx, "tcp", addr) if err == nil {
if err != nil {
return nil, types.NewProxyDialError(proxyStr, err)
}
return conn, nil return conn, nil
}
lastErr = err
}
return nil, types.NewProxyDialError(proxyStr, lastErr)
}, nil }, nil
} }
// resolveIPv4First resolves the host of addr into candidate addresses, IPv4 ones first.
// It can return the following errors:
// - types.ProxyResolveError
func resolveIPv4First(ctx context.Context, addr string, timeout time.Duration) ([]string, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
dnsCancel()
if err != nil {
return nil, err
}
if len(ips) == 0 {
return nil, types.NewProxyResolveError(host)
}
addrs := make([]string, 0, len(ips))
for _, ip := range ips {
if ip.To4() != nil {
addrs = append(addrs, net.JoinHostPort(ip.String(), port))
}
}
for _, ip := range ips {
if ip.To4() == nil {
addrs = append(addrs, net.JoinHostPort(ip.String(), port))
}
}
return addrs, nil
}
// fasthttpConnectDialer creates a dial function that tunnels through an HTTP proxy with CONNECT,
// reaching the proxy over TLS when useTLS is set.
//
// The returned dial function can return the following errors: // The returned dial function can return the following errors:
// - types.ProxyDialError // - types.ProxyDialError
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc { func fasthttpConnectDialer(proxyURL *url.URL, useTLS bool) fasthttp.DialFuncWithTimeout {
defaultPort := "80"
if useTLS {
defaultPort = "443"
}
proxyAddr := proxyURL.Host proxyAddr := proxyURL.Host
if proxyURL.Port() == "" { if proxyURL.Port() == "" {
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), "443") proxyAddr = net.JoinHostPort(proxyURL.Hostname(), defaultPort)
} }
// Build Proxy-Authorization header if auth is present // Build Proxy-Authorization header if auth is present
@@ -222,7 +249,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
proxyStr := proxyURL.String() proxyStr := proxyURL.String()
return func(addr string) (net.Conn, error) { return func(addr string, timeout time.Duration) (net.Conn, error) {
// Establish TCP connection to proxy with timeout // Establish TCP connection to proxy with timeout
start := time.Now() start := time.Now()
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout) conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
@@ -242,7 +269,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
// Upgrade to TLS if useTLS {
tlsConn := tls.Client(conn, &tls.Config{ tlsConn := tls.Client(conn, &tls.Config{
ServerName: proxyURL.Hostname(), ServerName: proxyURL.Hostname(),
}) })
@@ -250,6 +277,8 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
tlsConn.Close() //nolint:errcheck,gosec tlsConn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
conn = tlsConn
}
// Build and send CONNECT request // Build and send CONNECT request
connectReq := &http.Request{ connectReq := &http.Request{
@@ -262,35 +291,35 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
connectReq.Header.Set("Proxy-Authorization", proxyAuth) connectReq.Header.Set("Proxy-Authorization", proxyAuth)
} }
if err := connectReq.Write(tlsConn); err != nil { if err := connectReq.Write(conn); err != nil {
tlsConn.Close() //nolint:errcheck,gosec conn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
// Read response using buffered reader, but return wrapped connection // Read response using buffered reader, but return wrapped connection
// to preserve any buffered data // to preserve any buffered data
bufReader := bufio.NewReader(tlsConn) bufReader := bufio.NewReader(conn)
resp, err := http.ReadResponse(bufReader, connectReq) resp, err := http.ReadResponse(bufReader, connectReq)
if err != nil { if err != nil {
tlsConn.Close() //nolint:errcheck,gosec conn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
resp.Body.Close() //nolint:errcheck,gosec resp.Body.Close() //nolint:errcheck,gosec
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
tlsConn.Close() //nolint:errcheck,gosec conn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status)) return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
} }
// Clear deadline for the tunneled connection // Clear deadline for the tunneled connection
if err := tlsConn.SetDeadline(time.Time{}); err != nil { if err := conn.SetDeadline(time.Time{}); err != nil {
tlsConn.Close() //nolint:errcheck,gosec conn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
// Return wrapped connection that uses the buffered reader // Return wrapped connection that uses the buffered reader
// to avoid losing any data that was read ahead // to avoid losing any data that was read ahead
return &bufferedConn{Conn: tlsConn, reader: bufReader}, nil return &bufferedConn{Conn: conn, reader: bufReader}, nil
} }
} }
@@ -306,18 +335,10 @@ func (c *bufferedConn) Read(b []byte) (int, error) {
return c.reader.Read(b) return c.reader.Read(b)
} }
func NewHostClientGenerator(clients ...*fasthttp.HostClient) HostClientGenerator { func newClientIndexGenerator(count int) clientIndexGenerator {
switch len(clients) { indexes := make([]int, count)
case 0: for i := range indexes {
hostClient := &fasthttp.HostClient{} indexes[i] = i
return func() *fasthttp.HostClient {
return hostClient
}
case 1:
return func() *fasthttp.HostClient {
return clients[0]
}
default:
return utilsSlice.RandomCycle(nil, clients...)
} }
return utilsSlice.RandomCycle(nil, indexes...)
} }
+10 -3
View File
@@ -58,8 +58,7 @@ func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
file := &CachedFile{Content: content, Filename: filename} file := &CachedFile{Content: content, Filename: filename}
// LoadOrStore handles race condition - if another goroutine // If another goroutine cached it first, LoadOrStore returns theirs
// cached it first, we get theirs (no duplicate storage)
actual, _ := fc.cache.LoadOrStore(source, file) actual, _ := fc.cache.LoadOrStore(source, file)
return actual.(*CachedFile), nil return actual.(*CachedFile), nil
} }
@@ -94,10 +93,18 @@ func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
} }
content, err := io.ReadAll(resp.Body) // A declared size above the limit is refused before anything is read.
if resp.ContentLength > types.RemoteFetchLimit {
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
content, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
if err != nil { if err != nil {
return nil, "", types.NewHTTPFetchError(url, err) return nil, "", types.NewHTTPFetchError(url, err)
} }
if len(content) > types.RemoteFetchLimit {
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
// Extract filename from URL path // Extract filename from URL path
filename := path.Base(url) filename := path.Base(url)
+15
View File
@@ -1,6 +1,7 @@
package sarin package sarin
import ( import (
"math"
"math/rand/v2" "math/rand/v2"
"time" "time"
) )
@@ -19,3 +20,17 @@ func firstOrEmpty(values []string) string {
} }
return values[0] return values[0]
} }
func safeUintToInt(u uint) int {
if u > math.MaxInt {
return math.MaxInt
}
return int(u)
}
func safeUint64ToInt(u uint64) int {
if u > math.MaxInt {
return math.MaxInt
}
return int(u)
}
+12 -7
View File
@@ -191,8 +191,18 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
} }
if len(reqData.Cookies) > 0 { if len(reqData.Cookies) > 0 {
req.Header.Add("Cookie", cookieHeaderValue(reqData.Cookies))
}
if scheme == "https" {
req.URI().SetScheme("https")
}
}
// cookieHeaderValue joins cookies into a single Cookie header value ("a=1; b=2").
func cookieHeaderValue(cookies map[string][]string) string {
var sb strings.Builder var sb strings.Builder
for k, values := range reqData.Cookies { for k, values := range cookies {
for _, v := range values { for _, v := range values {
if sb.Len() > 0 { if sb.Len() > 0 {
sb.WriteString("; ") sb.WriteString("; ")
@@ -202,12 +212,7 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
sb.WriteString(v) sb.WriteString(v)
} }
} }
req.Header.Add("Cookie", sb.String()) return sb.String()
}
if scheme == "https" {
req.URI().SetScheme("https")
}
} }
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) { func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
+13
View File
@@ -17,6 +17,14 @@ import (
const DefaultResponseDurationAccuracy uint32 = 1 const DefaultResponseDurationAccuracy uint32 = 1
const DefaultResponseColumnMaxWidth = 50 const DefaultResponseColumnMaxWidth = 50
// maxResponseKeys bounds the distinct response keys, so error messages carrying
// per-request text cannot grow the map without limit. It stays above the number of
// HTTP status codes, so real ones are never merged.
const maxResponseKeys = 1000
// otherResponseKey collects everything that arrives after the key limit is reached.
const otherResponseKey = "other"
// Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings. // Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings.
type Duration time.Duration type Duration time.Duration
@@ -95,6 +103,11 @@ func (data *SarinResponseData) Add(responseKey string, responseTime time.Duratio
defer data.Unlock() defer data.Unlock()
response, ok := data.Responses[responseKey] response, ok := data.Responses[responseKey]
if !ok && len(data.Responses) >= maxResponseKeys {
responseKey = otherResponseKey
response, ok = data.Responses[responseKey]
}
if !ok { if !ok {
data.Responses[responseKey] = &Response{ data.Responses[responseKey] = &Response{
durations: map[time.Duration]uint64{ durations: map[time.Duration]uint64{
+39 -43
View File
@@ -199,7 +199,12 @@ 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
responses *SarinResponseData responses *SarinResponseData
fileCache *FileCache fileCache *FileCache
scriptChain *script.Chain scriptChain *script.Chain
@@ -220,6 +225,7 @@ func NewSarin(
totalDuration *time.Duration, totalDuration *time.Duration,
showProgress bool, showProgress bool,
skipCertVerify bool, skipCertVerify bool,
maxResponseBody uint64,
params types.Params, params types.Params,
headers types.Headers, headers types.Headers,
cookies types.Cookies, cookies types.Cookies,
@@ -248,7 +254,12 @@ func NewSarin(
} }
} }
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -266,7 +277,20 @@ func NewSarin(
scriptChain := script.NewChain(luaSources, jsSources) 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{ srn := &sarin{
jobsCtx: jobsCtx,
jobsCancel: jobsCancel,
workers: workers, workers: workers,
requestURL: requestURL, requestURL: requestURL,
methods: methods, methods: methods,
@@ -286,6 +310,7 @@ func NewSarin(
logError: logError, logError: logError,
logFile: logFile, logFile: logFile,
hostClients: hostClients, hostClients: hostClients,
scriptHTTPClients: scriptHTTPClients,
fileCache: NewFileCache(time.Second * 10), fileCache: NewFileCache(time.Second * 10),
scriptChain: scriptChain, scriptChain: scriptChain,
} }
@@ -301,9 +326,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))
@@ -364,18 +387,17 @@ func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
} }
// Start workers // Start workers
s.startWorkers(&workersWG, jobsCh, s.hostClients, &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)
@@ -394,7 +416,7 @@ func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
} }
// newWriterLog builds the loggers that write formatted lines to w (a log file or // 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); sendRespLog // 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. // 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) { func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) {
// log.Logger serializes writes with its own mutex, so concurrent workers // log.Logger serializes writes with its own mutex, so concurrent workers
@@ -432,65 +454,39 @@ func (s sarin) newChannelLog(ch chan<- runtimeLog) (runtimeLogger, respLogger) {
return sendLog, sendRespLog return sendLog, sendRespLog
} }
// newHostClients initializes HTTP clients for the given configuration. func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
// 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 (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
for range max(s.workers, 1) { for range max(s.workers, 1) {
wg.Go(func() { wg.Go(func() {
s.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendLog, sendRespLog) s.Worker(jobs, counter, sendLog, sendRespLog)
}) })
} }
} }
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{}{}
} }
} }
+174
View File
@@ -0,0 +1,174 @@
package sarin
import (
"context"
"crypto/tls"
"errors"
"net"
"net/url"
"time"
"github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types"
)
// scriptHTTPDefaultTimeout is the default timeout for script requests, independent of -T.
const scriptHTTPDefaultTimeout = 30 * time.Second
// scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use.
type scriptHTTPClient struct {
ctx context.Context //nolint:containedctx
client *fasthttp.Client
insecureClient *fasthttp.Client
defaultTimeout time.Duration
}
var _ script.HTTPDoer = (*scriptHTTPClient)(nil)
// newScriptHTTPClients creates one client per proxy, in the same order as NewHostClients.
// It can return the following errors:
// - types.ProxyDialError
func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint, maxResponseBody uint64) ([]*scriptHTTPClient, error) {
if len(proxies) == 0 {
return []*scriptHTTPClient{newScriptHTTPClient(ctx, fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)}, nil
}
clients := make([]*scriptHTTPClient, 0, len(proxies))
for _, proxy := range proxies {
dial, err := NewProxyDialFuncWithTimeout(ctx, &proxy)
if err != nil {
return nil, types.NewProxyDialError(proxy.String(), err)
}
clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns, maxResponseBody))
}
return clients, nil
}
func newScriptHTTPClient(
ctx context.Context,
dial fasthttp.DialFuncWithTimeout,
defaultTimeout time.Duration,
maxConns uint,
maxResponseBody uint64,
) *scriptHTTPClient {
newClient := func(insecure bool) *fasthttp.Client {
// Read and write timeouts stay unset because they would cap the per-call timeout,
// so dialWithDeadline is what bounds the TLS handshake.
return &fasthttp.Client{
DialTimeout: dialWithDeadline(dial),
MaxConnsPerHost: safeUintToInt(maxConns),
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
TLSConfig: &tls.Config{
InsecureSkipVerify: insecure, //nolint:gosec
},
DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true,
}
}
return &scriptHTTPClient{
ctx: ctx,
client: newClient(false),
insecureClient: newClient(true),
defaultTimeout: defaultTimeout,
}
}
// dialWithDeadline dials within the request timeout and keeps it as the connection deadline,
// so the TLS handshake fasthttp runs on the first write cannot outlive the request.
func dialWithDeadline(dial fasthttp.DialFuncWithTimeout) fasthttp.DialFuncWithTimeout {
return func(addr string, timeout time.Duration) (net.Conn, error) {
if timeout <= 0 {
timeout = scriptHTTPDefaultTimeout
}
deadline := time.Now().Add(timeout)
conn, err := dial(addr, timeout)
if err != nil {
return nil, err
}
if err := conn.SetDeadline(deadline); err != nil {
conn.Close() //nolint:errcheck,gosec
return nil, err
}
return conn, nil
}
}
// Do sends the request. With maxRedirects the timeout applies to each hop.
// It can return the following errors:
// - types.ScriptHTTPRequestError
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)
if err != nil {
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
}
if (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" {
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, types.ErrScriptHTTPURLInvalid)
}
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI(r.URL)
req.Header.SetMethod(r.Method)
for key, values := range r.Headers {
for _, value := range values {
req.Header.Add(key, value)
}
}
if len(r.Params) > 0 {
args := req.URI().QueryArgs()
for key, values := range r.Params {
for _, value := range values {
args.Add(key, value)
}
}
}
if len(r.Cookies) > 0 {
req.Header.Add("Cookie", cookieHeaderValue(r.Cookies))
}
req.SetBodyString(r.Body)
timeout := r.Timeout
if timeout <= 0 {
timeout = c.defaultTimeout
}
client := c.client
if r.Insecure {
client = c.insecureClient
}
req.SetTimeout(timeout)
if r.MaxRedirects > 0 {
err = client.DoRedirects(req, resp, r.MaxRedirects)
} else {
err = client.Do(req, resp)
}
if err != nil {
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
err = types.ErrScriptHTTPBodyTooLarge
}
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
}
body := resp.Body()
return &script.HTTPResponse{
Status: resp.StatusCode(),
Headers: collectRespHeaders(resp),
Body: string(body),
}, nil
}
+2 -2
View File
@@ -97,7 +97,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
return string(data), nil return string(data), nil
}, },
// json_Object builds a JSON object from interleaved key-value pairs and returns it // json_Object builds a JSON object from interleaved key-value pairs and returns it
// as a JSON string. Keys must be strings; values may be any JSON-encodable type. // as a JSON string. Keys must be strings, values may be any JSON-encodable type.
// Usage: {{ json_Object "name" "Alice" "age" 30 }} // Usage: {{ json_Object "name" "Alice" "age" 30 }}
"json_Object": func(pairs ...any) (string, error) { "json_Object": func(pairs ...any) (string, error) {
if len(pairs)%2 != 0 { if len(pairs)%2 != 0 {
@@ -751,7 +751,7 @@ func NewDefaultBodyTemplateFuncMap(
switch { switch {
case strings.HasPrefix(val, "@@"): case strings.HasPrefix(val, "@@"):
// Escaped @ - send as literal string without first @ // Escaped @: send the string without the first @
if err := writer.WriteField(key, val[1:]); err != nil { if err := writer.WriteField(key, val[1:]); err != nil {
return "", err return "", err
} }
+82 -24
View File
@@ -1,12 +1,16 @@
package sarin package sarin
import ( import (
"errors"
"net"
"strconv" "strconv"
"strings"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/script" "go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types"
) )
const dryRunResponseKey = "dry-run" const dryRunResponseKey = "dry-run"
@@ -29,9 +33,32 @@ func statusCodeToString(code int) string {
return strconv.Itoa(code) return strconv.Itoa(code)
} }
// responseErrorKey drops the local address, whose port would mint a stats entry per connection.
func responseErrorKey(err error) string {
message := err.Error()
var opErr *net.OpError
if !errors.As(err, &opErr) || opErr.Source == nil {
return message
}
withoutSource := *opErr
withoutSource.Source = nil
return strings.Replace(message, opErr.Error(), withoutSource.Error(), 1)
}
// requestErrorKey keys a generation failure by its source instead of its message.
// A script picks its own URLs and error texts, so the message would mint a new
// stats entry per request. The full message still goes to the runtime log.
func requestErrorKey(err error) string {
if chainErr, ok := errors.AsType[types.ScriptChainError](err); ok {
return chainErr.EngineType + " script[" + strconv.Itoa(chainErr.Index) + "] error"
}
return "request generation error"
}
func (s sarin) Worker( func (s sarin) Worker(
jobs <-chan struct{}, jobs <-chan struct{},
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64, counter *atomic.Uint64,
sendLog runtimeLogger, sendLog runtimeLogger,
sendRespLog respLogger, sendRespLog respLogger,
@@ -53,6 +80,9 @@ func (s sarin) Worker(
defer scriptTransformer.Close() defer scriptTransformer.Close()
} }
nextClientIndex := newClientIndexGenerator(len(s.hostClients))
bindScriptProxy := s.newScriptProxyBinder(scriptTransformer)
requestGenerator, isDynamic := NewRequestGenerator( requestGenerator, isDynamic := NewRequestGenerator(
s.methods, s.requestURL, s.params, s.headers, s.cookies, s.bodies, s.values, s.fileCache, scriptTransformer, s.methods, s.requestURL, s.params, s.headers, s.cookies, s.bodies, s.values, s.fileCache, scriptTransformer,
) )
@@ -60,34 +90,45 @@ func (s sarin) Worker(
if s.dryRun { if s.dryRun {
switch { switch {
case s.collectStats && isDynamic: case s.collectStats && isDynamic:
s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog) s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
case s.collectStats && !isDynamic: case s.collectStats && !isDynamic:
s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog) s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
case !s.collectStats && isDynamic: case !s.collectStats && isDynamic:
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog) s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
default: default:
s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog) s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
} }
} else { } else {
switch { switch {
case s.collectStats && isDynamic: case s.collectStats && isDynamic:
s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog) s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog, sendRespLog)
case s.collectStats && !isDynamic: case s.collectStats && !isDynamic:
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog) s.workerStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
case !s.collectStats && isDynamic: case !s.collectStats && isDynamic:
s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog) s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog, sendRespLog)
default: default:
s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog) s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
} }
} }
} }
// newScriptProxyBinder returns a func that sends the scripts' http.* calls through proxy i.
func (s sarin) newScriptProxyBinder(scriptTransformer *script.Transformer) func(i int) {
if scriptTransformer == nil || scriptTransformer.IsEmpty() {
return func(int) {}
}
return func(i int) {
scriptTransformer.SetHTTPDoer(s.scriptHTTPClients[i])
}
}
func (s sarin) workerStatsWithDynamic( func (s sarin) workerStatsWithDynamic(
jobs <-chan struct{}, jobs <-chan struct{},
req *fasthttp.Request, req *fasthttp.Request,
resp *fasthttp.Response, resp *fasthttp.Response,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator, nextClientIndex clientIndexGenerator,
bindScriptProxy func(int),
counter *atomic.Uint64, counter *atomic.Uint64,
sendLog runtimeLogger, sendLog runtimeLogger,
sendRespLog respLogger, sendRespLog respLogger,
@@ -95,19 +136,22 @@ func (s sarin) workerStatsWithDynamic(
for range jobs { for range jobs {
req.Reset() req.Reset()
// Bound before generation so scripts' http.* calls use the main request's proxy.
clientIndex := nextClientIndex()
bindScriptProxy(clientIndex)
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
s.responses.Add(err.Error(), 0) s.responses.Add(requestErrorKey(err), 0)
sendLog(runtimeLogLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
counter.Add(1) counter.Add(1)
continue continue
} }
startTime := time.Now() startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout) err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
respDuration := time.Since(startTime) respDuration := time.Since(startTime)
if err != nil { if err != nil {
s.responses.Add(err.Error(), respDuration) s.responses.Add(responseErrorKey(err), respDuration)
} else { } else {
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration) s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
sendRespLog(respDuration, resp) sendRespLog(respDuration, resp)
@@ -121,15 +165,16 @@ func (s sarin) workerStatsWithStatic(
req *fasthttp.Request, req *fasthttp.Request,
resp *fasthttp.Response, resp *fasthttp.Response,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator, nextClientIndex clientIndexGenerator,
counter *atomic.Uint64, counter *atomic.Uint64,
sendLog runtimeLogger, sendLog runtimeLogger,
sendRespLog respLogger, sendRespLog respLogger,
) { ) {
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors // Static request generation failed, so record all jobs as errors
errorKey := requestErrorKey(err)
for range jobs { for range jobs {
s.responses.Add(err.Error(), 0) s.responses.Add(errorKey, 0)
sendLog(runtimeLogLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
counter.Add(1) counter.Add(1)
} }
@@ -138,10 +183,10 @@ func (s sarin) workerStatsWithStatic(
for range jobs { for range jobs {
startTime := time.Now() startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout) err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
respDuration := time.Since(startTime) respDuration := time.Since(startTime)
if err != nil { if err != nil {
s.responses.Add(err.Error(), respDuration) s.responses.Add(responseErrorKey(err), respDuration)
} else { } else {
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration) s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
sendRespLog(respDuration, resp) sendRespLog(respDuration, resp)
@@ -155,20 +200,24 @@ func (s sarin) workerNoStatsWithDynamic(
req *fasthttp.Request, req *fasthttp.Request,
resp *fasthttp.Response, resp *fasthttp.Response,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator, nextClientIndex clientIndexGenerator,
bindScriptProxy func(int),
counter *atomic.Uint64, counter *atomic.Uint64,
sendLog runtimeLogger, sendLog runtimeLogger,
sendRespLog respLogger, sendRespLog respLogger,
) { ) {
for range jobs { for range jobs {
req.Reset() req.Reset()
// Bound before generation so scripts' http.* calls use the main request's proxy.
clientIndex := nextClientIndex()
bindScriptProxy(clientIndex)
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
sendLog(runtimeLogLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
counter.Add(1) counter.Add(1)
continue continue
} }
startTime := time.Now() startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout) err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
if err == nil { if err == nil {
sendRespLog(time.Since(startTime), resp) sendRespLog(time.Since(startTime), resp)
} }
@@ -181,7 +230,7 @@ func (s sarin) workerNoStatsWithStatic(
req *fasthttp.Request, req *fasthttp.Request,
resp *fasthttp.Response, resp *fasthttp.Response,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator, nextClientIndex clientIndexGenerator,
counter *atomic.Uint64, counter *atomic.Uint64,
sendLog runtimeLogger, sendLog runtimeLogger,
sendRespLog respLogger, sendRespLog respLogger,
@@ -189,7 +238,7 @@ func (s sarin) workerNoStatsWithStatic(
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
sendLog(runtimeLogLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
// Static request generation failed - just count the jobs without sending // Static request generation failed, so just count the jobs without sending
for range jobs { for range jobs {
counter.Add(1) counter.Add(1)
} }
@@ -198,7 +247,7 @@ func (s sarin) workerNoStatsWithStatic(
for range jobs { for range jobs {
startTime := time.Now() startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout) err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
if err == nil { if err == nil {
sendRespLog(time.Since(startTime), resp) sendRespLog(time.Since(startTime), resp)
} }
@@ -210,14 +259,18 @@ func (s sarin) workerDryRunStatsWithDynamic(
jobs <-chan struct{}, jobs <-chan struct{},
req *fasthttp.Request, req *fasthttp.Request,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
nextClientIndex clientIndexGenerator,
bindScriptProxy func(int),
counter *atomic.Uint64, counter *atomic.Uint64,
sendLog runtimeLogger, sendLog runtimeLogger,
) { ) {
for range jobs { for range jobs {
req.Reset() req.Reset()
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
bindScriptProxy(nextClientIndex())
startTime := time.Now() startTime := time.Now()
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
s.responses.Add(err.Error(), time.Since(startTime)) s.responses.Add(requestErrorKey(err), time.Since(startTime))
sendLog(runtimeLogLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
counter.Add(1) counter.Add(1)
continue continue
@@ -235,9 +288,10 @@ func (s sarin) workerDryRunStatsWithStatic(
sendLog runtimeLogger, sendLog runtimeLogger,
) { ) {
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors // Static request generation failed, so record all jobs as errors
errorKey := requestErrorKey(err)
for range jobs { for range jobs {
s.responses.Add(err.Error(), 0) s.responses.Add(errorKey, 0)
sendLog(runtimeLogLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
counter.Add(1) counter.Add(1)
} }
@@ -254,11 +308,15 @@ func (s sarin) workerDryRunNoStatsWithDynamic(
jobs <-chan struct{}, jobs <-chan struct{},
req *fasthttp.Request, req *fasthttp.Request,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
nextClientIndex clientIndexGenerator,
bindScriptProxy func(int),
counter *atomic.Uint64, counter *atomic.Uint64,
sendLog runtimeLogger, sendLog runtimeLogger,
) { ) {
for range jobs { for range jobs {
req.Reset() req.Reset()
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
bindScriptProxy(nextClientIndex())
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
sendLog(runtimeLogLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
} }
+10
View File
@@ -91,6 +91,16 @@ func (t *Transformer) Transform(req *RequestData) error {
return nil return nil
} }
// SetHTTPDoer sets the doer that the scripts' http.* calls are sent through.
func (t *Transformer) SetHTTPDoer(doer HTTPDoer) {
for _, engine := range t.luaEngines {
engine.SetHTTPDoer(doer)
}
for _, engine := range t.jsEngines {
engine.SetHTTPDoer(doer)
}
}
// Close releases all engine resources. // Close releases all engine resources.
func (t *Transformer) Close() { func (t *Transformer) Close() {
for _, engine := range t.luaEngines { for _, engine := range t.luaEngines {
+133
View File
@@ -0,0 +1,133 @@
package script
import (
"math"
"net/http"
"strconv"
"strings"
"time"
"go.aykhans.me/sarin/internal/types"
)
// HTTPRequest is a request made by a script through the http.* functions.
type HTTPRequest struct {
Method string
URL string
Headers map[string][]string
Params map[string][]string
Cookies map[string][]string
Body string
Timeout time.Duration // zero means the doer's default timeout
Insecure bool
MaxRedirects int
}
// HTTPResponse is the response returned to a script by the http.* functions.
type HTTPResponse struct {
Status int
Headers map[string][]string
Body string
}
// Header returns the first value of the named header, matching the name case-insensitively.
func (r *HTTPResponse) Header(name string) (string, bool) {
for key, values := range r.Headers {
if strings.EqualFold(key, name) && len(values) > 0 {
return values[0], true
}
}
return "", false
}
// Cookie returns the value of the named cookie from the Set-Cookie headers.
func (r *HTTPResponse) Cookie(name string) (string, bool) {
for key, values := range r.Headers {
if !strings.EqualFold(key, "Set-Cookie") {
continue
}
for _, line := range values {
if cookie, err := http.ParseSetCookie(line); err == nil && cookie.Name == name {
return cookie.Value, true
}
}
}
return "", false
}
// HTTPDoer sends the HTTP requests that scripts make.
type HTTPDoer interface {
Do(req *HTTPRequest) (*HTTPResponse, error)
}
// httpDefaultMethod is used when the options set no method.
const httpDefaultMethod = http.MethodGet
// httpMaxRedirectsLimit is the highest maxRedirects a script may ask for.
const httpMaxRedirectsLimit = 100
// Option keys accepted by the http function.
const (
httpOptionMethod = "method"
httpOptionHeaders = "headers"
httpOptionParams = "params"
httpOptionCookies = "cookies"
httpOptionBody = "body"
httpOptionTimeout = "timeout"
httpOptionInsecure = "insecure"
httpOptionMaxRedirects = "maxRedirects"
)
// httpBridge connects http to the doer. Calls only work while transform runs.
type httpBridge struct {
doer HTTPDoer
active bool
}
// It can return the following errors:
// - types.ErrScriptHTTPOutsideTransform
// - types.ErrScriptHTTPUnavailable
// - types.ErrScriptHTTPMethodEmpty
// - any error returned by the doer
func (b *httpBridge) do(req *HTTPRequest) (*HTTPResponse, error) {
if !b.active {
return nil, types.ErrScriptHTTPOutsideTransform
}
if b.doer == nil {
return nil, types.ErrScriptHTTPUnavailable
}
if req.Method == "" {
return nil, types.ErrScriptHTTPMethodEmpty
}
return b.doer.Do(req)
}
// parseHTTPMaxRedirects checks that the redirect limit is a non-negative whole number.
// It can return the following errors:
// - types.ScriptHTTPOptionError
func parseHTTPMaxRedirects(count float64) (int, error) {
if math.IsNaN(count) || count < 0 || count > httpMaxRedirectsLimit || count != math.Trunc(count) {
return 0, types.NewScriptHTTPOptionError(
httpOptionMaxRedirects,
types.NewScriptTypeError(
"a whole number between 0 and "+strconv.Itoa(httpMaxRedirectsLimit),
strconv.FormatFloat(count, 'g', -1, 64),
),
)
}
return int(count), nil
}
// parseHTTPTimeout parses a Go duration string such as "500ms" or "2s".
// It can return the following errors:
// - types.ScriptHTTPOptionError
func parseHTTPTimeout(value string) (time.Duration, error) {
timeout, err := time.ParseDuration(value)
if err != nil {
return 0, types.NewScriptHTTPOptionError(httpOptionTimeout, err)
}
if timeout <= 0 {
return 0, types.NewScriptHTTPOptionError(httpOptionTimeout, types.NewScriptTypeError("a duration greater than 0", value))
}
return timeout, nil
}
+66 -17
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"
@@ -11,6 +12,10 @@ import (
type JsEngine struct { type JsEngine struct {
runtime *goja.Runtime runtime *goja.Runtime
transform goja.Callable transform goja.Callable
jsonParse goja.Callable
// errorConstructor is the built-in Error, captured before the script can replace it.
errorConstructor goja.Value
http httpBridge
} }
// NewJsEngine creates a new JavaScript script engine with the given script content. // NewJsEngine creates a new JavaScript script engine with the given script content.
@@ -29,11 +34,17 @@ type JsEngine struct {
// - types.ScriptExecutionError // - types.ScriptExecutionError
func NewJsEngine(scriptContent string) (*JsEngine, error) { func NewJsEngine(scriptContent string) (*JsEngine, error) {
vm := goja.New() vm := goja.New()
engine := &JsEngine{runtime: vm}
// Register the globals before running the script so its functions can use them
if err := engine.registerHTTP(); err != nil {
return nil, types.NewScriptExecutionError("JavaScript", err)
}
// Execute the script to define the transform function // Execute the script to define the transform function
_, err := vm.RunString(scriptContent) _, err := vm.RunString(scriptContent)
if err != nil { if err != nil {
return nil, types.NewScriptExecutionError("JavaScript", err) return nil, types.NewScriptExecutionError("JavaScript", engine.exceptionError(err))
} }
// Get the transform function // Get the transform function
@@ -47,23 +58,30 @@ func NewJsEngine(scriptContent string) (*JsEngine, error) {
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function")) return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
} }
return &JsEngine{ engine.transform = transform
runtime: vm,
transform: transform, return engine, nil
}, nil }
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
func (e *JsEngine) SetHTTPDoer(doer HTTPDoer) {
e.http.doer = doer
} }
// Transform executes the JavaScript transform function with the given request data. // Transform executes the JavaScript transform function with the given request data.
// It can return the following errors: // It can return the following errors:
// - types.ScriptExecutionError // - types.ScriptExecutionError
func (e *JsEngine) Transform(req *RequestData) error { func (e *JsEngine) Transform(req *RequestData) error {
e.http.active = true
defer func() { e.http.active = false }()
// Convert RequestData to JavaScript object // Convert RequestData to JavaScript object
reqObj := e.requestDataToObject(req) reqObj := e.requestDataToObject(req)
// Call transform(req) // Call transform(req)
result, err := e.transform(goja.Undefined(), reqObj) result, err := e.transform(goja.Undefined(), reqObj)
if err != nil { if err != nil {
return types.NewScriptExecutionError("JavaScript", err) return types.NewScriptExecutionError("JavaScript", e.exceptionError(err))
} }
// Update RequestData from the returned object // Update RequestData from the returned object
@@ -79,6 +97,8 @@ func (e *JsEngine) Close() {
// goja doesn't have an explicit close method, but we can help GC // goja doesn't have an explicit close method, but we can help GC
e.runtime = nil e.runtime = nil
e.transform = nil e.transform = nil
e.jsonParse = nil
e.errorConstructor = nil
} }
// requestDataToObject converts RequestData to a goja Value (JavaScript object). // requestDataToObject converts RequestData to a goja Value (JavaScript object).
@@ -176,23 +196,52 @@ 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 in slice }
result[key] = []string{v.String()}
// Single value, wrap it in a slice
if text, ok := jsStringValue(v); ok {
result[key] = []string{text}
} }
} }
return result return result
} }
// exceptionError keeps the thrown value's message without goja's stack frame.
// Stringifying runs through Runtime.Try because a thrown value with no usable
// toString makes goja panic outside its own execution context.
func (e *JsEngine) exceptionError(err error) error {
var exception *goja.Exception
if !errors.As(err, &exception) {
return err
}
var message string
if thrown := e.runtime.Try(func() { message = exception.Value().String() }); thrown != nil {
message = "unprintable thrown value"
}
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
}
+210
View File
@@ -0,0 +1,210 @@
package script
import (
"errors"
"github.com/dop251/goja"
"go.aykhans.me/sarin/internal/types"
)
// registerHTTP defines the global http function.
func (e *JsEngine) registerHTTP() error {
rt := e.runtime
jsonParse, ok := goja.AssertFunction(rt.Get("JSON").ToObject(rt).Get("parse"))
if !ok {
return errors.New("JSON.parse is not available")
}
e.jsonParse = jsonParse
e.errorConstructor = rt.Get("Error")
return rt.Set("http", func(call goja.FunctionCall) goja.Value {
return e.httpCall(call.Argument(0), call.Argument(1))
})
}
func (e *JsEngine) httpCall(urlValue, optsValue goja.Value) goja.Value {
rt := e.runtime
url, ok := urlValue.Export().(string)
if !ok {
panic(rt.NewTypeError("http: url must be a string, got %s", jsTypeName(urlValue)))
}
req := &HTTPRequest{Method: httpDefaultMethod, URL: url}
if !goja.IsUndefined(optsValue) && !goja.IsNull(optsValue) {
opts, ok := optsValue.(*goja.Object)
if !ok {
panic(rt.NewTypeError("http: options must be an object, got %s", jsTypeName(optsValue)))
}
if err := e.parseHTTPOptions(opts, req); err != nil {
e.throw(err)
}
}
resp, err := e.http.do(req)
if err != nil {
e.throw(err)
}
return e.httpResponseToObject(resp)
}
// parseHTTPOptions fills req from a JavaScript options object.
// It can return the following errors:
// - types.ScriptHTTPOptionError
func (e *JsEngine) parseHTTPOptions(opts *goja.Object, req *HTTPRequest) error {
for _, name := range opts.Keys() {
value := opts.Get(name)
if goja.IsUndefined(value) {
continue
}
switch name {
case httpOptionMethod:
method, ok := value.Export().(string)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", jsTypeName(value)))
}
req.Method = method
case httpOptionHeaders, httpOptionParams, httpOptionCookies:
object, ok := value.(*goja.Object)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("object", jsTypeName(value)))
}
values := e.objectToStringSliceMap(object)
switch name {
case httpOptionHeaders:
req.Headers = values
case httpOptionParams:
req.Params = values
default:
req.Cookies = values
}
case httpOptionBody:
body, ok := value.Export().(string)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", jsTypeName(value)))
}
req.Body = body
case httpOptionTimeout:
timeout, ok := value.Export().(string)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("duration string", jsTypeName(value)))
}
var err error
if req.Timeout, err = parseHTTPTimeout(timeout); err != nil {
return err
}
case httpOptionInsecure:
flag, ok := value.Export().(bool)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", jsTypeName(value)))
}
req.Insecure = flag
case httpOptionMaxRedirects:
count, ok := numberValue(value)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", jsTypeName(value)))
}
var err error
if req.MaxRedirects, err = parseHTTPMaxRedirects(count); err != nil {
return err
}
default:
return types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption)
}
}
return nil
}
// httpResponseToObject converts a response to a JavaScript object.
func (e *JsEngine) httpResponseToObject(resp *HTTPResponse) *goja.Object {
rt := e.runtime
obj := rt.NewObject()
headers := rt.NewObject()
for key, values := range resp.Headers {
_ = headers.Set(key, e.stringSliceToArray(values))
}
lookup := func(find func(string) (string, bool)) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
name, ok := call.Argument(0).Export().(string)
if !ok {
panic(rt.NewTypeError("name must be a string, got %s", jsTypeName(call.Argument(0))))
}
if value, ok := find(name); ok {
return rt.ToValue(value)
}
return goja.Null()
}
}
_ = obj.Set("status", resp.Status)
_ = obj.Set("headers", headers)
_ = obj.Set("body", resp.Body)
_ = obj.Set("header", lookup(resp.Header))
_ = obj.Set("cookie", lookup(resp.Cookie))
_ = obj.Set("json", func(goja.FunctionCall) goja.Value {
value, err := e.jsonParse(goja.Undefined(), rt.ToValue(resp.Body))
if err != nil {
panic(err)
}
return value
})
return obj
}
// jsTypeName returns a short type name for error messages.
func jsTypeName(value goja.Value) string {
switch {
case value == nil || goja.IsUndefined(value):
return "undefined"
case goja.IsNull(value):
return "null"
}
switch value.Export().(type) {
case string:
return "string"
case bool:
return "boolean"
case int64, float64:
return "number"
case []any:
return "array"
default:
if _, ok := goja.AssertFunction(value); ok {
return "function"
}
return "object"
}
}
// throw raises err as a plain JavaScript Error instead of a GoError.
func (e *JsEngine) throw(err error) {
errorValue, newErr := e.runtime.New(e.errorConstructor, e.runtime.ToValue(err.Error()))
if newErr != nil {
panic(e.runtime.NewGoError(err))
}
panic(errorValue)
}
// numberValue reports whether value is a JavaScript number, and returns it.
func numberValue(value goja.Value) (float64, bool) {
switch number := value.Export().(type) {
case int64:
return float64(number), true
case float64:
return number, true
default:
return 0, false
}
}
+53 -44
View File
@@ -11,6 +11,7 @@ import (
type LuaEngine struct { type LuaEngine struct {
state *lua.LState state *lua.LState
transform *lua.LFunction transform *lua.LFunction
http httpBridge
} }
// NewLuaEngine creates a new Lua script engine with the given script content. // NewLuaEngine creates a new Lua script engine with the given script content.
@@ -29,6 +30,11 @@ type LuaEngine struct {
// - types.ScriptExecutionError // - types.ScriptExecutionError
func NewLuaEngine(scriptContent string) (*LuaEngine, error) { func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
L := lua.NewState() L := lua.NewState()
engine := &LuaEngine{state: L}
// Register the globals before running the script so its functions can use them
engine.registerHTTP()
registerLuaJSON(L)
// Execute the script to define the transform function // Execute the script to define the transform function
if err := L.DoString(scriptContent); err != nil { if err := L.DoString(scriptContent); err != nil {
@@ -42,17 +48,23 @@ func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
L.Close() L.Close()
return nil, types.ErrScriptTransformMissing return nil, types.ErrScriptTransformMissing
} }
engine.transform = transform.(*lua.LFunction)
return &LuaEngine{ return engine, nil
state: L, }
transform: transform.(*lua.LFunction),
}, nil // SetHTTPDoer sets the doer that the script's http.* calls are sent through.
func (e *LuaEngine) SetHTTPDoer(doer HTTPDoer) {
e.http.doer = doer
} }
// Transform executes the Lua transform function with the given request data. // Transform executes the Lua transform function with the given request data.
// It can return the following errors: // It can return the following errors:
// - types.ScriptExecutionError // - types.ScriptExecutionError
func (e *LuaEngine) Transform(req *RequestData) error { func (e *LuaEngine) Transform(req *RequestData) error {
e.http.active = true
defer func() { e.http.active = false }()
// Convert RequestData to Lua table // Convert RequestData to Lua table
reqTable := e.requestDataToTable(req) reqTable := e.requestDataToTable(req)
@@ -93,39 +105,23 @@ func (e *LuaEngine) requestDataToTable(req *RequestData) *lua.LTable {
t.RawSetString("path", lua.LString(req.Path)) t.RawSetString("path", lua.LString(req.Path))
t.RawSetString("body", lua.LString(req.Body)) t.RawSetString("body", lua.LString(req.Body))
// Headers (map[string][]string -> table of arrays) t.RawSetString("headers", stringSliceMapToTable(L, req.Headers))
headers := L.NewTable() t.RawSetString("params", stringSliceMapToTable(L, req.Params))
for k, values := range req.Headers { t.RawSetString("cookies", stringSliceMapToTable(L, req.Cookies))
arr := L.NewTable()
return t
}
// stringSliceMapToTable converts a Go map[string][]string to a Lua table of arrays.
func stringSliceMapToTable(state *lua.LState, m map[string][]string) *lua.LTable {
t := state.NewTable()
for k, values := range m {
arr := state.NewTable()
for _, v := range values { for _, v := range values {
arr.Append(lua.LString(v)) arr.Append(lua.LString(v))
} }
headers.RawSetString(k, arr) t.RawSetString(k, arr)
} }
t.RawSetString("headers", headers)
// Params (map[string][]string -> table of arrays)
params := L.NewTable()
for k, values := range req.Params {
arr := L.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
params.RawSetString(k, arr)
}
t.RawSetString("params", params)
// Cookies (map[string][]string -> table of arrays)
cookies := L.NewTable()
for k, values := range req.Cookies {
arr := L.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
cookies.RawSetString(k, arr)
}
t.RawSetString("cookies", cookies)
return t return t
} }
@@ -172,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
}
}
+142
View File
@@ -0,0 +1,142 @@
package script
import (
lua "github.com/yuin/gopher-lua"
"go.aykhans.me/sarin/internal/types"
)
// registerHTTP defines the global http function.
func (e *LuaEngine) registerHTTP() {
L := e.state
L.SetGlobal("http", L.NewFunction(func(L *lua.LState) int {
return e.httpCall(L, 1)
}))
}
// httpCall sends the request whose URL is at stack position urlIndex and pushes the response.
func (e *LuaEngine) httpCall(state *lua.LState, urlIndex int) int {
req := &HTTPRequest{
Method: httpDefaultMethod,
URL: state.CheckString(urlIndex),
}
switch opts := state.Get(urlIndex + 1).(type) {
case *lua.LNilType:
case *lua.LTable:
if err := e.parseHTTPOptions(opts, req); err != nil {
state.RaiseError("%s", err.Error())
}
default:
state.ArgError(urlIndex+1, "options must be a table")
}
resp, err := e.http.do(req)
if err != nil {
state.RaiseError("%s", err.Error())
}
state.Push(httpResponseToTable(state, resp))
return 1
}
// parseHTTPOptions fills req from a Lua options table.
// It can return the following errors:
// - types.ScriptHTTPOptionError
func (e *LuaEngine) parseHTTPOptions(opts *lua.LTable, req *HTTPRequest) error {
var err error
opts.ForEach(func(k, v lua.LValue) {
if err != nil {
return
}
key, ok := k.(lua.LString)
if !ok {
err = types.NewScriptHTTPOptionError(k.String(), types.ErrScriptHTTPUnknownOption)
return
}
switch name := string(key); name {
case httpOptionMethod:
method, ok := v.(lua.LString)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", v.Type().String()))
return
}
req.Method = string(method)
case httpOptionHeaders, httpOptionParams, httpOptionCookies:
table, ok := v.(*lua.LTable)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("table", v.Type().String()))
return
}
values := e.tableToStringSliceMap(table)
switch name {
case httpOptionHeaders:
req.Headers = values
case httpOptionParams:
req.Params = values
default:
req.Cookies = values
}
case httpOptionBody:
body, ok := v.(lua.LString)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", v.Type().String()))
return
}
req.Body = string(body)
case httpOptionTimeout:
timeout, ok := v.(lua.LString)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("duration string", v.Type().String()))
return
}
req.Timeout, err = parseHTTPTimeout(string(timeout))
case httpOptionInsecure:
flag, ok := v.(lua.LBool)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", v.Type().String()))
return
}
req.Insecure = bool(flag)
case httpOptionMaxRedirects:
count, ok := v.(lua.LNumber)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", v.Type().String()))
return
}
req.MaxRedirects, err = parseHTTPMaxRedirects(float64(count))
default:
err = types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption)
}
})
return err
}
// httpResponseToTable converts a response to a Lua table.
func httpResponseToTable(state *lua.LState, resp *HTTPResponse) *lua.LTable {
t := state.NewTable()
t.RawSetString("status", lua.LNumber(resp.Status))
t.RawSetString("headers", stringSliceMapToTable(state, resp.Headers))
t.RawSetString("body", lua.LString(resp.Body))
lookup := func(find func(string) (string, bool)) *lua.LFunction {
return state.NewFunction(func(L *lua.LState) int {
// Last argument, so both res:header() and res.header() work.
top := L.GetTop()
if top == 0 {
L.ArgError(1, "name expected")
}
if value, ok := find(L.CheckString(top)); ok {
L.Push(lua.LString(value))
} else {
L.Push(lua.LNil)
}
return 1
})
}
t.RawSetString("header", lookup(resp.Header))
t.RawSetString("cookie", lookup(resp.Cookie))
return t
}
+148
View File
@@ -0,0 +1,148 @@
package script
import (
"bytes"
"encoding/json"
"math"
"strings"
lua "github.com/yuin/gopher-lua"
"go.aykhans.me/sarin/internal/types"
)
// registerLuaJSON defines the global json table with encode and decode.
func registerLuaJSON(state *lua.LState) {
module := state.NewTable()
module.RawSetString("encode", state.NewFunction(luaJSONEncode))
module.RawSetString("decode", state.NewFunction(luaJSONDecode))
state.SetGlobal("json", module)
}
func luaJSONEncode(state *lua.LState) int {
value, err := luaToGo(state.CheckAny(1), make(map[*lua.LTable]bool))
if err != nil {
state.RaiseError("%s", types.NewScriptJSONError("encode", err).Error())
}
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
state.RaiseError("%s", types.NewScriptJSONError("encode", err).Error())
}
state.Push(lua.LString(strings.TrimSuffix(buf.String(), "\n")))
return 1
}
func luaJSONDecode(state *lua.LState) int {
var value any
if err := json.Unmarshal([]byte(state.CheckString(1)), &value); err != nil {
state.RaiseError("%s", types.NewScriptJSONError("decode", err).Error())
}
state.Push(goToLua(state, value))
return 1
}
// luaToGo converts a Lua value to a JSON-encodable Go value.
// It can return the following errors:
// - types.ErrScriptJSONCycle
// - types.ScriptTypeError
func luaToGo(value lua.LValue, visiting map[*lua.LTable]bool) (any, error) {
switch v := value.(type) {
case *lua.LNilType:
return nil, nil
case lua.LBool:
return bool(v), nil
case lua.LNumber:
return float64(v), nil
case lua.LString:
return string(v), nil
case *lua.LTable:
if visiting[v] {
return nil, types.ErrScriptJSONCycle
}
visiting[v] = true
defer delete(visiting, v)
if length, ok := luaArrayLength(v); ok {
array := make([]any, length)
for i := range length {
item, err := luaToGo(v.RawGetInt(i+1), visiting)
if err != nil {
return nil, err
}
array[i] = item
}
return array, nil
}
object := make(map[string]any)
var err error
v.ForEach(func(key, item lua.LValue) {
if err != nil {
return
}
switch key.(type) {
case lua.LString, lua.LNumber:
default:
err = types.NewScriptTypeError("string or number object key", key.Type().String())
return
}
object[key.String()], err = luaToGo(item, visiting)
})
if err != nil {
return nil, err
}
return object, nil
default:
return nil, types.NewScriptTypeError("nil, boolean, number, string or table", value.Type().String())
}
}
// luaArrayLength reports whether t is a non-empty sequence with keys exactly 1..n.
func luaArrayLength(t *lua.LTable) (int, bool) {
count := 0
isArray := true
maxKey := 0.0
t.ForEach(func(key, _ lua.LValue) {
count++
number, ok := key.(lua.LNumber)
if !ok || float64(number) < 1 || float64(number) != math.Trunc(float64(number)) {
isArray = false
return
}
maxKey = max(maxKey, float64(number))
})
if !isArray || count == 0 || maxKey != float64(count) {
return 0, false
}
return count, true
}
// goToLua converts a value produced by encoding/json to a Lua value.
func goToLua(state *lua.LState, value any) lua.LValue {
switch v := value.(type) {
case bool:
return lua.LBool(v)
case float64:
return lua.LNumber(v)
case string:
return lua.LString(v)
case []any:
t := state.CreateTable(len(v), 0)
for i, item := range v {
t.RawSetInt(i+1, goToLua(state, item))
}
return t
case map[string]any:
t := state.CreateTable(0, len(v))
for key, item := range v {
t.RawSetString(key, goToLua(state, item))
}
return t
default:
return lua.LNil
}
}
+15 -4
View File
@@ -31,6 +31,9 @@ type Engine interface {
// The script should modify the RequestData and return it. // The script should modify the RequestData and return it.
Transform(req *RequestData) error Transform(req *RequestData) error
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
SetHTTPDoer(doer HTTPDoer)
// Close releases any resources held by the engine. // Close releases any resources held by the engine.
Close() Close()
} }
@@ -69,7 +72,7 @@ func LoadSource(ctx context.Context, source string, engineType EngineType) (*Sou
switch { switch {
case strings.HasPrefix(source, "@@"): case strings.HasPrefix(source, "@@"):
// Escaped @ - it's an inline script starting with literal @ // Escaped @: an inline script starting with a literal @
content = source[1:] // Remove first @, keep the rest content = source[1:] // Remove first @, keep the rest
case strings.HasPrefix(source, "@"): case strings.HasPrefix(source, "@"):
// File or URL reference // File or URL reference
@@ -125,7 +128,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
return err return err
} }
// Try to create an engine - this validates syntax and transform function // Creating an engine validates the syntax and the transform function
var engine Engine var engine Engine
switch engineType { switch engineType {
case EngineTypeLua: case EngineTypeLua:
@@ -140,7 +143,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
return err return err
} }
// Clean up the engine - we only needed it for validation // The engine was only needed for validation
engine.Close() engine.Close()
return nil return nil
} }
@@ -168,10 +171,18 @@ func fetchURL(ctx context.Context, url string) (string, error) {
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
} }
data, err := io.ReadAll(resp.Body) // A declared size above the limit is refused before anything is read.
if resp.ContentLength > types.RemoteFetchLimit {
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
if err != nil { if err != nil {
return "", types.NewHTTPFetchError(url, err) return "", types.NewHTTPFetchError(url, err)
} }
if len(data) > types.RemoteFetchLimit {
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
return string(data), nil return string(data), nil
} }
+87
View File
@@ -0,0 +1,87 @@
package types
import (
"math"
"strconv"
"strings"
)
// ByteSizeLimit is the largest size any option may ask for. A limit is also the most
// memory one response may take, so it stays well under what a machine can hold.
const ByteSizeLimit = 1 << 30 // 1 GiB
// RemoteFetchLimit bounds a file sarin downloads for itself, such as a config file or a script.
const RemoteFetchLimit = 64 << 20 // 64 MiB
// byteSizeUnits run from the largest scale down, so formatting picks the largest unit
// that fits. A bare "K" is out: the common libraries disagree on whether it counts
// 1000 or 1024 bytes.
var byteSizeUnits = []struct {
suffix string
scale uint64
}{
{"TiB", 1 << 40},
{"TB", 1000 * 1000 * 1000 * 1000},
{"GiB", 1 << 30},
{"GB", 1000 * 1000 * 1000},
{"MiB", 1 << 20},
{"MB", 1000 * 1000},
{"KiB", 1 << 10},
{"KB", 1000},
{"B", 1},
}
// ParseByteSize reads a byte count, with or without a unit suffix.
// It can return the following errors:
// - ErrByteSizeInvalid
func ParseByteSize(value string) (uint64, error) {
text := strings.TrimSpace(value)
// The suffix is split off by index, so no case mapping can shift the digits.
digitsEnd := len(text)
for digitsEnd > 0 && isASCIILetter(text[digitsEnd-1]) {
digitsEnd--
}
scale := uint64(1)
if suffix := text[digitsEnd:]; suffix != "" {
unitScale, ok := byteSizeScale(suffix)
if !ok {
return 0, ErrByteSizeInvalid
}
scale = unitScale
}
size, err := strconv.ParseUint(strings.TrimSpace(text[:digitsEnd]), 10, 64)
if err != nil {
return 0, ErrByteSizeInvalid
}
if size > math.MaxUint64/scale {
return 0, ErrByteSizeInvalid
}
return size * scale, nil
}
// FormatByteSize renders a byte count so that ParseByteSize reads back the same value.
func FormatByteSize(size uint64) string {
for _, unit := range byteSizeUnits {
if unit.scale > 1 && size >= unit.scale && size%unit.scale == 0 {
return strconv.FormatUint(size/unit.scale, 10) + unit.suffix
}
}
return strconv.FormatUint(size, 10)
}
// byteSizeScale looks up a unit suffix, ignoring case.
func byteSizeScale(suffix string) (uint64, bool) {
for _, unit := range byteSizeUnits {
if strings.EqualFold(suffix, unit.suffix) {
return unit.scale, true
}
}
return 0, false
}
func isASCIILetter(char byte) bool {
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
}
+90
View File
@@ -10,6 +10,13 @@ import (
var ( var (
errNoError = errors.New("no error (internal)") errNoError = errors.New("no error (internal)")
ErrByteSizeInvalid = errors.New(
"invalid value for size, expected a whole number of bytes or a whole number with a unit: " +
"B, KB, MB, GB, TB (1000 based) or KiB, MiB, GiB, TiB (1024 based), e.g. '1048576', '10MiB', '2MB'",
)
ErrRemoteFileTooLarge = errors.New("remote file exceeds " + FormatByteSize(RemoteFetchLimit))
) )
type FieldParseError struct { type FieldParseError struct {
@@ -318,6 +325,8 @@ func (e ConfigFileReadError) Unwrap() error {
// ======================================== Proxy ======================================== // ======================================== Proxy ========================================
var ErrProxyNoContextDialer = errors.New("SOCKS5 dialer does not support timeouts")
type ProxyUnsupportedSchemeError struct { type ProxyUnsupportedSchemeError struct {
Scheme string Scheme string
} }
@@ -401,8 +410,89 @@ var (
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function") ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
ErrScriptTransformReturnObject = errors.New("transform function must return an object") ErrScriptTransformReturnObject = errors.New("transform function must return an object")
ErrScriptURLNoHost = errors.New("script URL must have a host") ErrScriptURLNoHost = errors.New("script URL must have a host")
ErrScriptHTTPOutsideTransform = errors.New("http functions can only be called while transform is running")
ErrScriptHTTPUnavailable = errors.New("http client is not available")
ErrScriptHTTPMethodEmpty = errors.New("http method cannot be empty")
ErrScriptHTTPURLInvalid = errors.New("URL must be an absolute http or https URL")
ErrScriptHTTPUnknownOption = errors.New("unknown option")
ErrScriptHTTPBodyTooLarge = errors.New("response body exceeds the body limit")
ErrScriptJSONCycle = errors.New("cannot encode a table that contains itself")
) )
type ScriptTypeError struct {
Expected string
Got string
}
func NewScriptTypeError(expected, got string) ScriptTypeError {
return ScriptTypeError{expected, got}
}
func (e ScriptTypeError) Error() string {
return fmt.Sprintf("expected %s, got %s", e.Expected, e.Got)
}
type ScriptHTTPOptionError struct {
Option string
Err error
}
func NewScriptHTTPOptionError(option string, err error) ScriptHTTPOptionError {
if err == nil {
err = errNoError
}
return ScriptHTTPOptionError{option, err}
}
func (e ScriptHTTPOptionError) Error() string {
return fmt.Sprintf("http option %q: %v", e.Option, e.Err)
}
func (e ScriptHTTPOptionError) Unwrap() error {
return e.Err
}
type ScriptHTTPRequestError struct {
Method string
URL string
Err error
}
func NewScriptHTTPRequestError(method, url string, err error) ScriptHTTPRequestError {
if err == nil {
err = errNoError
}
return ScriptHTTPRequestError{method, url, err}
}
func (e ScriptHTTPRequestError) Error() string {
return fmt.Sprintf("http %s %s: %v", e.Method, e.URL, e.Err)
}
func (e ScriptHTTPRequestError) Unwrap() error {
return e.Err
}
type ScriptJSONError struct {
Op string
Err error
}
func NewScriptJSONError(op string, err error) ScriptJSONError {
if err == nil {
err = errNoError
}
return ScriptJSONError{op, err}
}
func (e ScriptJSONError) Error() string {
return fmt.Sprintf("json.%s: %v", e.Op, e.Err)
}
func (e ScriptJSONError) Unwrap() error {
return e.Err
}
type ScriptLoadError struct { type ScriptLoadError struct {
Source string Source string
Err error Err error