mirror of
https://github.com/aykhans/sarin.git
synced 2026-10-17 14:53:52 +00:00
Compare commits
18
Commits
v1.4.4
..
9c7b7c26a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7b7c26a0
|
||
|
|
ef5907dc0c
|
||
|
|
1956eb1c44
|
||
|
|
f1d91e3d77
|
||
|
|
412a02fea3
|
||
|
|
8da05bb10c
|
||
|
|
c32949c8e0
|
||
|
|
a30c862936
|
||
|
|
5d91141f80
|
||
|
|
878ca6dfbd
|
||
|
|
9b0e7c4304
|
||
|
|
d4c7dcc42c
|
||
|
|
00226b0eb7
|
||
|
|
a60287b78b
|
||
|
|
529420ee5a
|
||
|
|
8b437b487c
|
||
|
|
5ca9cda2b9
|
||
|
|
6b03ba9579
|
No files matched your search
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.13.2
|
||||
version: v2.14.0
|
||||
@@ -67,7 +67,7 @@ nix run github:aykhans/sarin/release -- -U http://example.com -r 100 -c 10
|
||||
Install into your profile:
|
||||
|
||||
```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:
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ vars:
|
||||
BIN_DIR: ./bin
|
||||
GO_VERSION:
|
||||
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}}"
|
||||
|
||||
tasks:
|
||||
|
||||
+3
-2
@@ -51,7 +51,8 @@ func main() {
|
||||
ctx,
|
||||
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
|
||||
*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.Output != config.ConfigOutputTypeNone,
|
||||
*combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile,
|
||||
@@ -75,7 +76,7 @@ func main() {
|
||||
}),
|
||||
)
|
||||
|
||||
srn.Start(ctx, stopCtrl)
|
||||
srn.Start(stopCtrl)
|
||||
|
||||
switch *combinedConfig.Output {
|
||||
case config.ConfigOutputTypeNone:
|
||||
|
||||
+143
-28
@@ -14,32 +14,33 @@ 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.
|
||||
|
||||
| Name | YAML | CLI | ENV | Default | Description |
|
||||
| --------------------------- | ----------------------------------- | -------------------------------------------- | -------------------------------- | ------- | ---------------------------- |
|
||||
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
|
||||
| [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 |
|
||||
| [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) |
|
||||
| [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) |
|
||||
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
|
||||
| [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 |
|
||||
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
|
||||
| [Log Level](#log-level) | `logLevel`<br>(string) | `-log-level` / `-l`<br>(string) | `SARIN_LOG_LEVEL`<br>(string) | `error` | Runtime log levels to emit |
|
||||
| [Log File](#log-file) | `logFile`<br>(string) | `-log-file` / `-w`<br>(string) | `SARIN_LOG_FILE`<br>(string) | - | Write runtime logs to a file |
|
||||
| [Progress](#progress) | `progress`<br>(string) | `-progress` / `-p`<br>(string) | `SARIN_PROGRESS`<br>(string) | `bar` | Progress display (bar/none) |
|
||||
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
|
||||
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
|
||||
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
|
||||
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
|
||||
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
|
||||
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
|
||||
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
|
||||
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
|
||||
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
|
||||
| [Lua](#lua) | `lua`<br>(string / []string) | `-lua`<br>(string / []string) | `SARIN_LUA`<br>(string) | - | Lua script(s) |
|
||||
| [Js](#js) | `js`<br>(string / []string) | `-js`<br>(string / []string) | `SARIN_JS`<br>(string) | - | JavaScript script(s) |
|
||||
| Name | YAML | CLI | ENV | Default | Description |
|
||||
| --------------------------------------- | ----------------------------------- | -------------------------------------------- | ----------------------------------- | ------- | ----------------------------- |
|
||||
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
|
||||
| [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 |
|
||||
| [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) |
|
||||
| [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) |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [Log Level](#log-level) | `logLevel`<br>(string) | `-log-level` / `-l`<br>(string) | `SARIN_LOG_LEVEL`<br>(string) | `error` | Runtime log levels to emit |
|
||||
| [Log File](#log-file) | `logFile`<br>(string) | `-log-file` / `-w`<br>(string) | `SARIN_LOG_FILE`<br>(string) | - | Write runtime logs to a file |
|
||||
| [Progress](#progress) | `progress`<br>(string) | `-progress` / `-p`<br>(string) | `SARIN_PROGRESS`<br>(string) | `bar` | Progress display (bar/none) |
|
||||
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
|
||||
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
|
||||
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
|
||||
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
|
||||
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
|
||||
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
|
||||
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
|
||||
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
|
||||
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
|
||||
| [Lua](#lua) | `lua`<br>(string / []string) | `-lua`<br>(string / []string) | `SARIN_LUA`<br>(string) | - | Lua script(s) |
|
||||
| [Js](#js) | `js`<br>(string / []string) | `-js`<br>(string / []string) | `SARIN_JS`<br>(string) | - | JavaScript script(s) |
|
||||
|
||||
---
|
||||
|
||||
@@ -168,6 +169,18 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
||||
|
||||
**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
|
||||
|
||||
Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`.
|
||||
@@ -456,7 +469,9 @@ function transform(req)
|
||||
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:**
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -554,3 +571,101 @@ js:
|
||||
```sh
|
||||
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;
|
||||
}
|
||||
```
|
||||
@@ -1087,3 +1087,154 @@ js: "@./scripts/body.js"
|
||||
```
|
||||
|
||||
</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
@@ -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_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_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_PhraseNoun` | Noun phrase | `"a tribe"` |
|
||||
| `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+44
-36
@@ -20,32 +20,33 @@ Simple usage:
|
||||
|
||||
Flags:
|
||||
General Config:
|
||||
-h, -help Help for sarin
|
||||
-v, -version Version for sarin
|
||||
-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)
|
||||
-c, -concurrency uint Number of concurrent requests (default %d)
|
||||
-r, -requests uint Number of total requests
|
||||
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
||||
-l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s)
|
||||
-w, -log-file string Write runtime logs to this file instead of the terminal/stderr
|
||||
-p, -progress string Progress display (possible values: bar, none) (default '%v')
|
||||
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
|
||||
-z, -dry-run bool Run without sending requests (default %v)
|
||||
-h, -help Help for sarin
|
||||
-v, -version Version for sarin
|
||||
-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)
|
||||
-c, -concurrency uint Number of concurrent requests (default %d)
|
||||
-r, -requests uint Number of total requests
|
||||
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
||||
-l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s)
|
||||
-w, -log-file string Write runtime logs to this file instead of the terminal/stderr
|
||||
-p, -progress string Progress display (possible values: bar, none) (default '%v')
|
||||
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
|
||||
-z, -dry-run bool Run without sending requests (default %v)
|
||||
|
||||
Request Config:
|
||||
-U, -url string Target URL for the request
|
||||
-M, -method []string HTTP method for the request (default %s)
|
||||
-B, -body []string Body for the request (e.g. "body text")
|
||||
-P, -param []string URL parameter for the request (e.g. "key1=value1")
|
||||
-H, -header []string Header for the request (e.g. "key1: value1")
|
||||
-C, -cookie []string Cookie for the request (e.g. "key1=value1")
|
||||
-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")
|
||||
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
|
||||
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)
|
||||
-lua []string Lua script for request transformation (inline or @file/@url)
|
||||
-js []string JavaScript script for request transformation (inline or @file/@url)`
|
||||
-U, -url string Target URL for the request
|
||||
-M, -method []string HTTP method for the request (default %s)
|
||||
-B, -body []string Body for the request (e.g. "body text")
|
||||
-P, -param []string URL parameter for the request (e.g. "key1=value1")
|
||||
-H, -header []string Header for the request (e.g. "key1: value1")
|
||||
-C, -cookie []string Cookie for the request (e.g. "key1=value1")
|
||||
-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")
|
||||
-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)
|
||||
-lua []string Lua script for request transformation (inline or @file/@url)
|
||||
-js []string JavaScript script for request transformation (inline or @file/@url)`
|
||||
|
||||
var _ IParser = ConfigCLIParser{}
|
||||
|
||||
@@ -97,18 +98,19 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
||||
dryRun bool
|
||||
|
||||
// Request config
|
||||
urlInput string
|
||||
methods = stringSliceArg{}
|
||||
bodies = stringSliceArg{}
|
||||
params = stringSliceArg{}
|
||||
headers = stringSliceArg{}
|
||||
cookies = stringSliceArg{}
|
||||
proxies = stringSliceArg{}
|
||||
values = stringSliceArg{}
|
||||
timeout time.Duration
|
||||
insecure bool
|
||||
luaScripts = stringSliceArg{}
|
||||
jsScripts = stringSliceArg{}
|
||||
urlInput string
|
||||
methods = stringSliceArg{}
|
||||
bodies = stringSliceArg{}
|
||||
params = stringSliceArg{}
|
||||
headers = stringSliceArg{}
|
||||
cookies = stringSliceArg{}
|
||||
proxies = stringSliceArg{}
|
||||
values = stringSliceArg{}
|
||||
timeout time.Duration
|
||||
maxResponseBody byteSize
|
||||
insecure bool
|
||||
luaScripts = 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, "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, "I", false, "Skip SSL/TLS certificate verification")
|
||||
|
||||
@@ -258,6 +263,8 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
||||
config.Values = append(config.Values, values...)
|
||||
case "timeout", "T":
|
||||
config.Timeout = new(timeout)
|
||||
case "max-response-body", "S":
|
||||
config.MaxResponseBody = new(uint64(maxResponseBody))
|
||||
case "insecure", "I":
|
||||
config.Insecure = new(insecure)
|
||||
case "lua":
|
||||
@@ -286,6 +293,7 @@ func (parser ConfigCLIParser) PrintHelp() {
|
||||
|
||||
Defaults.Method,
|
||||
Defaults.RequestTimeout,
|
||||
types.FormatByteSize(Defaults.MaxResponseBody),
|
||||
Defaults.Insecure,
|
||||
)
|
||||
}
|
||||
+66
-46
@@ -27,27 +27,29 @@ import (
|
||||
)
|
||||
|
||||
var Defaults = struct {
|
||||
UserAgent string
|
||||
Method string
|
||||
RequestTimeout time.Duration
|
||||
Concurrency uint
|
||||
ShowConfig bool
|
||||
Progress ConfigProgressType
|
||||
Insecure bool
|
||||
Output ConfigOutputType
|
||||
DryRun bool
|
||||
LogLevel string
|
||||
UserAgent string
|
||||
Method string
|
||||
RequestTimeout time.Duration
|
||||
MaxResponseBody uint64
|
||||
Concurrency uint
|
||||
ShowConfig bool
|
||||
Progress ConfigProgressType
|
||||
Insecure bool
|
||||
Output ConfigOutputType
|
||||
DryRun bool
|
||||
LogLevel string
|
||||
}{
|
||||
UserAgent: "Sarin/" + version.Version,
|
||||
Method: "GET",
|
||||
RequestTimeout: time.Second * 10,
|
||||
Concurrency: 1,
|
||||
ShowConfig: false,
|
||||
Progress: ConfigProgressTypeBar,
|
||||
Insecure: false,
|
||||
Output: ConfigOutputTypeTable,
|
||||
DryRun: false,
|
||||
LogLevel: "error",
|
||||
UserAgent: "Sarin/" + version.Version,
|
||||
Method: "GET",
|
||||
RequestTimeout: time.Second * 10,
|
||||
MaxResponseBody: 10 << 20, // 10 MiB
|
||||
Concurrency: 1,
|
||||
ShowConfig: false,
|
||||
Progress: ConfigProgressTypeBar,
|
||||
Insecure: false,
|
||||
Output: ConfigOutputTypeTable,
|
||||
DryRun: false,
|
||||
LogLevel: "error",
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -82,28 +84,29 @@ var (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
||||
Files []types.ConfigFile `yaml:"files,omitempty"`
|
||||
Methods []string `yaml:"methods,omitempty"`
|
||||
URL *url.URL `yaml:"url,omitempty"`
|
||||
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
||||
Concurrency *uint `yaml:"concurrency,omitempty"`
|
||||
Requests *uint64 `yaml:"requests,omitempty"`
|
||||
Duration *time.Duration `yaml:"duration,omitempty"`
|
||||
Progress *ConfigProgressType `yaml:"progress,omitempty"`
|
||||
Output *ConfigOutputType `yaml:"output,omitempty"`
|
||||
Insecure *bool `yaml:"insecure,omitempty"`
|
||||
DryRun *bool `yaml:"dryRun,omitempty"`
|
||||
Params types.Params `yaml:"params,omitempty"`
|
||||
Headers types.Headers `yaml:"headers,omitempty"`
|
||||
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
||||
Bodies []string `yaml:"bodies,omitempty"`
|
||||
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
||||
Values []string `yaml:"values,omitempty"`
|
||||
Lua []string `yaml:"lua,omitempty"`
|
||||
Js []string `yaml:"js,omitempty"`
|
||||
LogLevel *string `yaml:"logLevel,omitempty"`
|
||||
LogFile *string `yaml:"logFile,omitempty"`
|
||||
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
||||
Files []types.ConfigFile `yaml:"files,omitempty"`
|
||||
Methods []string `yaml:"methods,omitempty"`
|
||||
URL *url.URL `yaml:"url,omitempty"`
|
||||
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
||||
MaxResponseBody *uint64 `yaml:"maxResponseBody,omitempty"`
|
||||
Concurrency *uint `yaml:"concurrency,omitempty"`
|
||||
Requests *uint64 `yaml:"requests,omitempty"`
|
||||
Duration *time.Duration `yaml:"duration,omitempty"`
|
||||
Progress *ConfigProgressType `yaml:"progress,omitempty"`
|
||||
Output *ConfigOutputType `yaml:"output,omitempty"`
|
||||
Insecure *bool `yaml:"insecure,omitempty"`
|
||||
DryRun *bool `yaml:"dryRun,omitempty"`
|
||||
Params types.Params `yaml:"params,omitempty"`
|
||||
Headers types.Headers `yaml:"headers,omitempty"`
|
||||
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
||||
Bodies []string `yaml:"bodies,omitempty"`
|
||||
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
||||
Values []string `yaml:"values,omitempty"`
|
||||
Lua []string `yaml:"lua,omitempty"`
|
||||
Js []string `yaml:"js,omitempty"`
|
||||
LogLevel *string `yaml:"logLevel,omitempty"`
|
||||
LogFile *string `yaml:"logFile,omitempty"`
|
||||
}
|
||||
|
||||
func (config Config) MarshalYAML() (any, error) {
|
||||
@@ -177,6 +180,9 @@ func (config Config) MarshalYAML() (any, error) {
|
||||
if config.Timeout != nil {
|
||||
addField(content, "timeout", toNode(*config.Timeout), "")
|
||||
}
|
||||
if config.MaxResponseBody != nil {
|
||||
addField(content, "maxResponseBody", toNode(types.FormatByteSize(*config.MaxResponseBody)), "")
|
||||
}
|
||||
if config.Concurrency != nil {
|
||||
addField(content, "concurrency", toNode(*config.Concurrency), "")
|
||||
}
|
||||
@@ -301,6 +307,9 @@ func (config *Config) Merge(newConfig *Config) {
|
||||
if newConfig.Timeout != nil {
|
||||
config.Timeout = newConfig.Timeout
|
||||
}
|
||||
if newConfig.MaxResponseBody != nil {
|
||||
config.MaxResponseBody = newConfig.MaxResponseBody
|
||||
}
|
||||
if newConfig.Concurrency != nil {
|
||||
config.Concurrency = newConfig.Concurrency
|
||||
}
|
||||
@@ -379,6 +388,9 @@ func (config *Config) SetDefaults() {
|
||||
if config.Timeout == nil {
|
||||
config.Timeout = &Defaults.RequestTimeout
|
||||
}
|
||||
if config.MaxResponseBody == nil {
|
||||
config.MaxResponseBody = &Defaults.MaxResponseBody
|
||||
}
|
||||
if config.Concurrency == nil {
|
||||
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")))
|
||||
}
|
||||
|
||||
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 {
|
||||
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
|
||||
}
|
||||
@@ -715,7 +735,7 @@ func validateScriptSource(script string) error {
|
||||
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, "@") {
|
||||
return nil
|
||||
}
|
||||
@@ -725,7 +745,7 @@ func validateScriptSource(script string) error {
|
||||
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
|
||||
|
||||
if source == "" {
|
||||
@@ -744,7 +764,7 @@ func validateScriptSource(script string) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -822,7 +842,7 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
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.
|
||||
newView := func(s string) tea.View {
|
||||
v := tea.NewView(s)
|
||||
|
||||
@@ -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 != "" {
|
||||
insecureParsed, err := utilsParse.ParseString[bool](insecure)
|
||||
if err != nil {
|
||||
|
||||
+35
-23
@@ -79,10 +79,18 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
||||
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 {
|
||||
return nil, types.NewHTTPFetchError(url, err)
|
||||
}
|
||||
if len(data) > types.RemoteFetchLimit {
|
||||
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -192,28 +200,29 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
||||
}
|
||||
|
||||
type configYAML struct {
|
||||
ShowConfig *bool `yaml:"showConfig"`
|
||||
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
||||
Concurrency *uint `yaml:"concurrency"`
|
||||
RequestCount *uint64 `yaml:"requests"`
|
||||
Duration *time.Duration `yaml:"duration"`
|
||||
LogLevel *string `yaml:"logLevel"`
|
||||
LogFile *string `yaml:"logFile"`
|
||||
Progress *string `yaml:"progress"`
|
||||
Output *string `yaml:"output"`
|
||||
DryRun *bool `yaml:"dryRun"`
|
||||
URL *string `yaml:"url"`
|
||||
Method stringOrSliceField `yaml:"method"`
|
||||
Bodies stringOrSliceField `yaml:"body"`
|
||||
Params keyValuesField `yaml:"params"`
|
||||
Headers keyValuesField `yaml:"headers"`
|
||||
Cookies keyValuesField `yaml:"cookies"`
|
||||
Proxies stringOrSliceField `yaml:"proxy"`
|
||||
Values stringOrSliceField `yaml:"values"`
|
||||
Timeout *time.Duration `yaml:"timeout"`
|
||||
Insecure *bool `yaml:"insecure"`
|
||||
Lua stringOrSliceField `yaml:"lua"`
|
||||
Js stringOrSliceField `yaml:"js"`
|
||||
ShowConfig *bool `yaml:"showConfig"`
|
||||
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
||||
Concurrency *uint `yaml:"concurrency"`
|
||||
RequestCount *uint64 `yaml:"requests"`
|
||||
Duration *time.Duration `yaml:"duration"`
|
||||
LogLevel *string `yaml:"logLevel"`
|
||||
LogFile *string `yaml:"logFile"`
|
||||
Progress *string `yaml:"progress"`
|
||||
Output *string `yaml:"output"`
|
||||
DryRun *bool `yaml:"dryRun"`
|
||||
URL *string `yaml:"url"`
|
||||
Method stringOrSliceField `yaml:"method"`
|
||||
Bodies stringOrSliceField `yaml:"body"`
|
||||
Params keyValuesField `yaml:"params"`
|
||||
Headers keyValuesField `yaml:"headers"`
|
||||
Cookies keyValuesField `yaml:"cookies"`
|
||||
Proxies stringOrSliceField `yaml:"proxy"`
|
||||
Values stringOrSliceField `yaml:"values"`
|
||||
Timeout *time.Duration `yaml:"timeout"`
|
||||
MaxResponseBody *byteSize `yaml:"maxResponseBody"`
|
||||
Insecure *bool `yaml:"insecure"`
|
||||
Lua stringOrSliceField `yaml:"lua"`
|
||||
Js stringOrSliceField `yaml:"js"`
|
||||
}
|
||||
|
||||
// ParseYAML parses YAML config file arguments into a Config object.
|
||||
@@ -290,6 +299,9 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
||||
|
||||
config.Values = append(config.Values, parsedData.Values...)
|
||||
config.Timeout = parsedData.Timeout
|
||||
if parsedData.MaxResponseBody != nil {
|
||||
config.MaxResponseBody = new(uint64(*parsedData.MaxResponseBody))
|
||||
}
|
||||
config.Insecure = parsedData.Insecure
|
||||
config.Lua = append(config.Lua, parsedData.Lua...)
|
||||
config.Js = append(config.Js, parsedData.Js...)
|
||||
|
||||
@@ -193,7 +193,7 @@ func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.F
|
||||
|
||||
func ValidateTemplates(config *Config) []types.FieldValidationError {
|
||||
// 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()
|
||||
funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
|
||||
|
||||
|
||||
+119
-98
@@ -5,27 +5,19 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
utilsSlice "go.aykhans.me/utils/slice"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
type HostClientGenerator func() *fasthttp.HostClient
|
||||
|
||||
func safeUintToInt(u uint) int {
|
||||
if u > math.MaxInt {
|
||||
return math.MaxInt
|
||||
}
|
||||
return int(u)
|
||||
}
|
||||
// clientIndexGenerator returns the index of the host client (and proxy) to use next.
|
||||
type clientIndexGenerator func() int
|
||||
|
||||
// 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.
|
||||
@@ -38,6 +30,7 @@ func NewHostClients(
|
||||
maxConns uint,
|
||||
requestURL *url.URL,
|
||||
skipVerify bool,
|
||||
maxResponseBody uint64,
|
||||
) ([]*fasthttp.HostClient, error) {
|
||||
isTLS := requestURL.Scheme == "https"
|
||||
|
||||
@@ -66,6 +59,7 @@ func NewHostClients(
|
||||
MaxConnDuration: timeout,
|
||||
WriteTimeout: timeout,
|
||||
ReadTimeout: timeout,
|
||||
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||
DisableHeaderNamesNormalizing: true,
|
||||
DisablePathNormalizing: true,
|
||||
NoDefaultUserAgentHeader: true,
|
||||
@@ -77,8 +71,9 @@ func NewHostClients(
|
||||
}
|
||||
|
||||
client := &fasthttp.HostClient{
|
||||
MaxConns: safeUintToInt(maxConns),
|
||||
IsTLS: isTLS,
|
||||
MaxConns: safeUintToInt(maxConns),
|
||||
IsTLS: isTLS,
|
||||
DialDualStack: true,
|
||||
TLSConfig: &tls.Config{
|
||||
InsecureSkipVerify: skipVerify, //nolint:gosec
|
||||
},
|
||||
@@ -87,6 +82,7 @@ func NewHostClients(
|
||||
MaxConnDuration: timeout,
|
||||
WriteTimeout: timeout,
|
||||
ReadTimeout: timeout,
|
||||
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||
DisableHeaderNamesNormalizing: true,
|
||||
DisablePathNormalizing: true,
|
||||
NoDefaultUserAgentHeader: true,
|
||||
@@ -94,40 +90,45 @@ func NewHostClients(
|
||||
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:
|
||||
// - types.ProxyUnsupportedSchemeError
|
||||
// - types.ErrProxyNoContextDialer
|
||||
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
||||
var (
|
||||
dialer fasthttp.DialFunc
|
||||
err error
|
||||
)
|
||||
dial, err := NewProxyDialFuncWithTimeout(ctx, proxyURL)
|
||||
if err != nil {
|
||||
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 {
|
||||
case "socks5":
|
||||
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fasthttpSocksDialer(ctx, proxyURL, true)
|
||||
case "socks5h":
|
||||
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fasthttpSocksDialer(ctx, proxyURL, false)
|
||||
case "http":
|
||||
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxyURL.String(), timeout)
|
||||
return fasthttpConnectDialer(proxyURL, false), nil
|
||||
case "https":
|
||||
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
|
||||
return fasthttpConnectDialer(proxyURL, true), nil
|
||||
default:
|
||||
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
|
||||
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{}
|
||||
|
||||
// Parse auth from proxy URL if present
|
||||
@@ -149,66 +150,92 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
||||
|
||||
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)
|
||||
if !ok {
|
||||
// Fallback without timeout (should not happen with net.Dialer)
|
||||
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 nil, types.ErrProxyNoContextDialer
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
addrs := []string{addr}
|
||||
if resolveLocally {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
var err error
|
||||
addrs, err = resolveIPv4First(ctx, addr, timeout)
|
||||
if err != nil {
|
||||
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))
|
||||
// Try every resolved address, so one unreachable family does not fail the dial.
|
||||
var lastErr error
|
||||
for _, candidate := range addrs {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// Use the first resolved IP
|
||||
addr = net.JoinHostPort(ips[0].String(), port)
|
||||
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
||||
conn, err := contextDialer.DialContext(dialCtx, "tcp", candidate)
|
||||
dialCancel()
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// Use remaining time for dial
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
||||
defer dialCancel()
|
||||
|
||||
conn, err := contextDialer.DialContext(dialCtx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
return conn, nil
|
||||
return nil, types.NewProxyDialError(proxyStr, lastErr)
|
||||
}, 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:
|
||||
// - 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
|
||||
if proxyURL.Port() == "" {
|
||||
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), "443")
|
||||
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), defaultPort)
|
||||
}
|
||||
|
||||
// Build Proxy-Authorization header if auth is present
|
||||
@@ -222,7 +249,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
||||
|
||||
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
|
||||
start := time.Now()
|
||||
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
||||
@@ -242,13 +269,15 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
|
||||
// Upgrade to TLS
|
||||
tlsConn := tls.Client(conn, &tls.Config{
|
||||
ServerName: proxyURL.Hostname(),
|
||||
})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
tlsConn.Close() //nolint:errcheck,gosec
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
if useTLS {
|
||||
tlsConn := tls.Client(conn, &tls.Config{
|
||||
ServerName: proxyURL.Hostname(),
|
||||
})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
tlsConn.Close() //nolint:errcheck,gosec
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
conn = tlsConn
|
||||
}
|
||||
|
||||
// Build and send CONNECT request
|
||||
@@ -262,35 +291,35 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
||||
connectReq.Header.Set("Proxy-Authorization", proxyAuth)
|
||||
}
|
||||
|
||||
if err := connectReq.Write(tlsConn); err != nil {
|
||||
tlsConn.Close() //nolint:errcheck,gosec
|
||||
if err := connectReq.Write(conn); err != nil {
|
||||
conn.Close() //nolint:errcheck,gosec
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
|
||||
// Read response using buffered reader, but return wrapped connection
|
||||
// to preserve any buffered data
|
||||
bufReader := bufio.NewReader(tlsConn)
|
||||
bufReader := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(bufReader, connectReq)
|
||||
if err != nil {
|
||||
tlsConn.Close() //nolint:errcheck,gosec
|
||||
conn.Close() //nolint:errcheck,gosec
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
resp.Body.Close() //nolint:errcheck,gosec
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
tlsConn.Close() //nolint:errcheck,gosec
|
||||
conn.Close() //nolint:errcheck,gosec
|
||||
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
|
||||
}
|
||||
|
||||
// Clear deadline for the tunneled connection
|
||||
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
|
||||
tlsConn.Close() //nolint:errcheck,gosec
|
||||
if err := conn.SetDeadline(time.Time{}); err != nil {
|
||||
conn.Close() //nolint:errcheck,gosec
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
|
||||
// Return wrapped connection that uses the buffered reader
|
||||
// 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)
|
||||
}
|
||||
|
||||
func NewHostClientGenerator(clients ...*fasthttp.HostClient) HostClientGenerator {
|
||||
switch len(clients) {
|
||||
case 0:
|
||||
hostClient := &fasthttp.HostClient{}
|
||||
return func() *fasthttp.HostClient {
|
||||
return hostClient
|
||||
}
|
||||
case 1:
|
||||
return func() *fasthttp.HostClient {
|
||||
return clients[0]
|
||||
}
|
||||
default:
|
||||
return utilsSlice.RandomCycle(nil, clients...)
|
||||
func newClientIndexGenerator(count int) clientIndexGenerator {
|
||||
indexes := make([]int, count)
|
||||
for i := range indexes {
|
||||
indexes[i] = i
|
||||
}
|
||||
return utilsSlice.RandomCycle(nil, indexes...)
|
||||
}
|
||||
@@ -58,8 +58,7 @@ func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
|
||||
|
||||
file := &CachedFile{Content: content, Filename: filename}
|
||||
|
||||
// LoadOrStore handles race condition - if another goroutine
|
||||
// cached it first, we get theirs (no duplicate storage)
|
||||
// If another goroutine cached it first, LoadOrStore returns theirs
|
||||
actual, _ := fc.cache.LoadOrStore(source, file)
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, "", types.NewHTTPFetchError(url, err)
|
||||
}
|
||||
if len(content) > types.RemoteFetchLimit {
|
||||
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||
}
|
||||
|
||||
// Extract filename from URL path
|
||||
filename := path.Base(url)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
@@ -19,3 +20,17 @@ func firstOrEmpty(values []string) string {
|
||||
}
|
||||
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)
|
||||
}
|
||||
+17
-12
@@ -191,18 +191,7 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
|
||||
}
|
||||
|
||||
if len(reqData.Cookies) > 0 {
|
||||
var sb strings.Builder
|
||||
for k, values := range reqData.Cookies {
|
||||
for _, v := range values {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("; ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(v)
|
||||
}
|
||||
}
|
||||
req.Header.Add("Cookie", sb.String())
|
||||
req.Header.Add("Cookie", cookieHeaderValue(reqData.Cookies))
|
||||
}
|
||||
|
||||
if scheme == "https" {
|
||||
@@ -210,6 +199,22 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
|
||||
}
|
||||
}
|
||||
|
||||
// cookieHeaderValue joins cookies into a single Cookie header value ("a=1; b=2").
|
||||
func cookieHeaderValue(cookies map[string][]string) string {
|
||||
var sb strings.Builder
|
||||
for k, values := range cookies {
|
||||
for _, v := range values {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("; ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(v)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot)
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ import (
|
||||
const DefaultResponseDurationAccuracy uint32 = 1
|
||||
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.
|
||||
type Duration time.Duration
|
||||
|
||||
@@ -95,6 +103,11 @@ func (data *SarinResponseData) Add(responseKey string, responseTime time.Duratio
|
||||
defer data.Unlock()
|
||||
|
||||
response, ok := data.Responses[responseKey]
|
||||
if !ok && len(data.Responses) >= maxResponseKeys {
|
||||
responseKey = otherResponseKey
|
||||
response, ok = data.Responses[responseKey]
|
||||
}
|
||||
|
||||
if !ok {
|
||||
data.Responses[responseKey] = &Response{
|
||||
durations: map[time.Duration]uint64{
|
||||
|
||||
+64
-68
@@ -199,10 +199,15 @@ type sarin struct {
|
||||
logError bool
|
||||
logFile string
|
||||
|
||||
hostClients []*fasthttp.HostClient
|
||||
responses *SarinResponseData
|
||||
fileCache *FileCache
|
||||
scriptChain *script.Chain
|
||||
// jobsCtx ends the run, either when the duration expires or on Ctrl+C.
|
||||
jobsCtx context.Context //nolint:containedctx
|
||||
jobsCancel context.CancelFunc
|
||||
|
||||
hostClients []*fasthttp.HostClient
|
||||
scriptHTTPClients []*scriptHTTPClient
|
||||
responses *SarinResponseData
|
||||
fileCache *FileCache
|
||||
scriptChain *script.Chain
|
||||
}
|
||||
|
||||
// NewSarin creates a new sarin instance for load testing.
|
||||
@@ -220,6 +225,7 @@ func NewSarin(
|
||||
totalDuration *time.Duration,
|
||||
showProgress bool,
|
||||
skipCertVerify bool,
|
||||
maxResponseBody uint64,
|
||||
params types.Params,
|
||||
headers types.Headers,
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -266,28 +277,42 @@ func NewSarin(
|
||||
|
||||
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{
|
||||
workers: workers,
|
||||
requestURL: requestURL,
|
||||
methods: methods,
|
||||
params: params,
|
||||
headers: headers,
|
||||
cookies: cookies,
|
||||
bodies: bodies,
|
||||
totalRequests: totalRequests,
|
||||
totalDuration: totalDuration,
|
||||
timeout: timeout,
|
||||
showProgress: showProgress,
|
||||
skipCertVerify: skipCertVerify,
|
||||
values: values,
|
||||
collectStats: collectStats,
|
||||
dryRun: dryRun,
|
||||
logInfo: logInfo,
|
||||
logError: logError,
|
||||
logFile: logFile,
|
||||
hostClients: hostClients,
|
||||
fileCache: NewFileCache(time.Second * 10),
|
||||
scriptChain: scriptChain,
|
||||
jobsCtx: jobsCtx,
|
||||
jobsCancel: jobsCancel,
|
||||
workers: workers,
|
||||
requestURL: requestURL,
|
||||
methods: methods,
|
||||
params: params,
|
||||
headers: headers,
|
||||
cookies: cookies,
|
||||
bodies: bodies,
|
||||
totalRequests: totalRequests,
|
||||
totalDuration: totalDuration,
|
||||
timeout: timeout,
|
||||
showProgress: showProgress,
|
||||
skipCertVerify: skipCertVerify,
|
||||
values: values,
|
||||
collectStats: collectStats,
|
||||
dryRun: dryRun,
|
||||
logInfo: logInfo,
|
||||
logError: logError,
|
||||
logFile: logFile,
|
||||
hostClients: hostClients,
|
||||
scriptHTTPClients: scriptHTTPClients,
|
||||
fileCache: NewFileCache(time.Second * 10),
|
||||
scriptChain: scriptChain,
|
||||
}
|
||||
|
||||
if collectStats {
|
||||
@@ -301,9 +326,7 @@ func (s sarin) GetResponses() *SarinResponseData {
|
||||
return s.responses
|
||||
}
|
||||
|
||||
func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
||||
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
||||
|
||||
func (s sarin) Start(stopCtrl *StopController) {
|
||||
var workersWG sync.WaitGroup
|
||||
jobsCh := make(chan struct{}, max(s.workers, 1))
|
||||
|
||||
@@ -364,18 +387,17 @@ func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
||||
}
|
||||
|
||||
// Start workers
|
||||
s.startWorkers(&workersWG, jobsCh, s.hostClients, &counter, sendLog, sendRespLog)
|
||||
s.startWorkers(&workersWG, jobsCh, &counter, sendLog, sendRespLog)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Setup duration-based cancellation
|
||||
s.setupDurationTimeout(ctx, jobsCancel)
|
||||
s.setupDurationTimeout()
|
||||
// Distribute jobs to workers.
|
||||
// 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(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
|
||||
// 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.
|
||||
func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) {
|
||||
// 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
|
||||
}
|
||||
|
||||
// newHostClients initializes HTTP clients for the given configuration.
|
||||
// It can return the following errors:
|
||||
// - types.ProxyDialError
|
||||
func newHostClients(
|
||||
ctx context.Context,
|
||||
timeout time.Duration,
|
||||
proxies types.Proxies,
|
||||
workers uint,
|
||||
requestURL *url.URL,
|
||||
skipCertVerify bool,
|
||||
) ([]*fasthttp.HostClient, error) {
|
||||
proxiesRaw := make([]url.URL, len(proxies))
|
||||
for i, proxy := range proxies {
|
||||
proxiesRaw[i] = url.URL(proxy)
|
||||
}
|
||||
|
||||
return NewHostClients(
|
||||
ctx,
|
||||
timeout,
|
||||
proxiesRaw,
|
||||
workers,
|
||||
requestURL,
|
||||
skipCertVerify,
|
||||
)
|
||||
}
|
||||
|
||||
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
|
||||
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
|
||||
for range max(s.workers, 1) {
|
||||
wg.Go(func() {
|
||||
s.Worker(jobs, 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 {
|
||||
go func() {
|
||||
timer := time.NewTimer(*s.totalDuration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
s.jobsCancel()
|
||||
case <-s.jobsCtx.Done():
|
||||
// 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 {
|
||||
for range *s.totalRequests {
|
||||
if ctx.Err() != nil {
|
||||
if s.jobsCtx.Err() != nil {
|
||||
break
|
||||
}
|
||||
jobs <- struct{}{}
|
||||
}
|
||||
} else {
|
||||
for ctx.Err() == nil {
|
||||
for s.jobsCtx.Err() == nil {
|
||||
jobs <- struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
return string(data), nil
|
||||
},
|
||||
// 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 }}
|
||||
"json_Object": func(pairs ...any) (string, error) {
|
||||
if len(pairs)%2 != 0 {
|
||||
@@ -751,7 +751,7 @@ func NewDefaultBodyTemplateFuncMap(
|
||||
|
||||
switch {
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
|
||||
+82
-24
@@ -1,12 +1,16 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
"go.aykhans.me/sarin/internal/script"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
)
|
||||
|
||||
const dryRunResponseKey = "dry-run"
|
||||
@@ -29,9 +33,32 @@ func statusCodeToString(code int) string {
|
||||
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(
|
||||
jobs <-chan struct{},
|
||||
hostClientGenerator HostClientGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
@@ -53,6 +80,9 @@ func (s sarin) Worker(
|
||||
defer scriptTransformer.Close()
|
||||
}
|
||||
|
||||
nextClientIndex := newClientIndexGenerator(len(s.hostClients))
|
||||
bindScriptProxy := s.newScriptProxyBinder(scriptTransformer)
|
||||
|
||||
requestGenerator, isDynamic := NewRequestGenerator(
|
||||
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 {
|
||||
switch {
|
||||
case s.collectStats && isDynamic:
|
||||
s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
|
||||
s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
|
||||
case s.collectStats && !isDynamic:
|
||||
s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
||||
case !s.collectStats && isDynamic:
|
||||
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
|
||||
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
|
||||
default:
|
||||
s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
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:
|
||||
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
||||
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
|
||||
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:
|
||||
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(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
nextClientIndex clientIndexGenerator,
|
||||
bindScriptProxy func(int),
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
@@ -95,19 +136,22 @@ func (s sarin) workerStatsWithDynamic(
|
||||
for range jobs {
|
||||
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 {
|
||||
s.responses.Add(err.Error(), 0)
|
||||
s.responses.Add(requestErrorKey(err), 0)
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||
err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
|
||||
respDuration := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
s.responses.Add(err.Error(), respDuration)
|
||||
s.responses.Add(responseErrorKey(err), respDuration)
|
||||
} else {
|
||||
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
||||
sendRespLog(respDuration, resp)
|
||||
@@ -121,15 +165,16 @@ func (s sarin) workerStatsWithStatic(
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
nextClientIndex clientIndexGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
) {
|
||||
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 {
|
||||
s.responses.Add(err.Error(), 0)
|
||||
s.responses.Add(errorKey, 0)
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
}
|
||||
@@ -138,10 +183,10 @@ func (s sarin) workerStatsWithStatic(
|
||||
|
||||
for range jobs {
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||
err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
|
||||
respDuration := time.Since(startTime)
|
||||
if err != nil {
|
||||
s.responses.Add(err.Error(), respDuration)
|
||||
s.responses.Add(responseErrorKey(err), respDuration)
|
||||
} else {
|
||||
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
||||
sendRespLog(respDuration, resp)
|
||||
@@ -155,20 +200,24 @@ func (s sarin) workerNoStatsWithDynamic(
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
nextClientIndex clientIndexGenerator,
|
||||
bindScriptProxy func(int),
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
) {
|
||||
for range jobs {
|
||||
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 {
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||
err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
|
||||
if err == nil {
|
||||
sendRespLog(time.Since(startTime), resp)
|
||||
}
|
||||
@@ -181,7 +230,7 @@ func (s sarin) workerNoStatsWithStatic(
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
hostClientGenerator HostClientGenerator,
|
||||
nextClientIndex clientIndexGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
@@ -189,7 +238,7 @@ func (s sarin) workerNoStatsWithStatic(
|
||||
if err := requestGenerator(req); err != nil {
|
||||
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 {
|
||||
counter.Add(1)
|
||||
}
|
||||
@@ -198,7 +247,7 @@ func (s sarin) workerNoStatsWithStatic(
|
||||
|
||||
for range jobs {
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||
err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
|
||||
if err == nil {
|
||||
sendRespLog(time.Since(startTime), resp)
|
||||
}
|
||||
@@ -210,14 +259,18 @@ func (s sarin) workerDryRunStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
nextClientIndex clientIndexGenerator,
|
||||
bindScriptProxy func(int),
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
|
||||
bindScriptProxy(nextClientIndex())
|
||||
startTime := time.Now()
|
||||
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())
|
||||
counter.Add(1)
|
||||
continue
|
||||
@@ -235,9 +288,10 @@ func (s sarin) workerDryRunStatsWithStatic(
|
||||
sendLog runtimeLogger,
|
||||
) {
|
||||
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 {
|
||||
s.responses.Add(err.Error(), 0)
|
||||
s.responses.Add(errorKey, 0)
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
}
|
||||
@@ -254,11 +308,15 @@ func (s sarin) workerDryRunNoStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
requestGenerator RequestGenerator,
|
||||
nextClientIndex clientIndexGenerator,
|
||||
bindScriptProxy func(int),
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
|
||||
bindScriptProxy(nextClientIndex())
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
}
|
||||
|
||||
@@ -91,6 +91,16 @@ func (t *Transformer) Transform(req *RequestData) error {
|
||||
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.
|
||||
func (t *Transformer) Close() {
|
||||
for _, engine := range t.luaEngines {
|
||||
|
||||
@@ -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
@@ -2,6 +2,7 @@ package script
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
@@ -11,6 +12,10 @@ import (
|
||||
type JsEngine struct {
|
||||
runtime *goja.Runtime
|
||||
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.
|
||||
@@ -29,11 +34,17 @@ type JsEngine struct {
|
||||
// - types.ScriptExecutionError
|
||||
func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
||||
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
|
||||
_, err := vm.RunString(scriptContent)
|
||||
if err != nil {
|
||||
return nil, types.NewScriptExecutionError("JavaScript", err)
|
||||
return nil, types.NewScriptExecutionError("JavaScript", engine.exceptionError(err))
|
||||
}
|
||||
|
||||
// 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 &JsEngine{
|
||||
runtime: vm,
|
||||
transform: transform,
|
||||
}, nil
|
||||
engine.transform = transform
|
||||
|
||||
return engine, 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.
|
||||
// It can return the following errors:
|
||||
// - types.ScriptExecutionError
|
||||
func (e *JsEngine) Transform(req *RequestData) error {
|
||||
e.http.active = true
|
||||
defer func() { e.http.active = false }()
|
||||
|
||||
// Convert RequestData to JavaScript object
|
||||
reqObj := e.requestDataToObject(req)
|
||||
|
||||
// Call transform(req)
|
||||
result, err := e.transform(goja.Undefined(), reqObj)
|
||||
if err != nil {
|
||||
return types.NewScriptExecutionError("JavaScript", err)
|
||||
return types.NewScriptExecutionError("JavaScript", e.exceptionError(err))
|
||||
}
|
||||
|
||||
// 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
|
||||
e.runtime = nil
|
||||
e.transform = nil
|
||||
e.jsonParse = nil
|
||||
e.errorConstructor = nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
for _, key := range obj.Keys() {
|
||||
v := obj.Get(key)
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's an array
|
||||
if arr, ok := v.Export().([]any); ok {
|
||||
var values []string
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
values = append(values, s)
|
||||
if arr, ok := v.(*goja.Object); ok && arr.ClassName() == "Array" {
|
||||
length := int(arr.Get("length").ToInteger())
|
||||
values := make([]string, 0, length)
|
||||
for i := range length {
|
||||
if text, ok := jsStringValue(arr.Get(strconv.Itoa(i))); ok {
|
||||
values = append(values, text)
|
||||
}
|
||||
}
|
||||
result[key] = values
|
||||
} else {
|
||||
// Single value - wrap in slice
|
||||
result[key] = []string{v.String()}
|
||||
continue
|
||||
}
|
||||
|
||||
// Single value, wrap it in a slice
|
||||
if text, ok := jsStringValue(v); ok {
|
||||
result[key] = []string{text}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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
@@ -11,6 +11,7 @@ import (
|
||||
type LuaEngine struct {
|
||||
state *lua.LState
|
||||
transform *lua.LFunction
|
||||
http httpBridge
|
||||
}
|
||||
|
||||
// NewLuaEngine creates a new Lua script engine with the given script content.
|
||||
@@ -29,6 +30,11 @@ type LuaEngine struct {
|
||||
// - types.ScriptExecutionError
|
||||
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
||||
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
|
||||
if err := L.DoString(scriptContent); err != nil {
|
||||
@@ -42,17 +48,23 @@ func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
||||
L.Close()
|
||||
return nil, types.ErrScriptTransformMissing
|
||||
}
|
||||
engine.transform = transform.(*lua.LFunction)
|
||||
|
||||
return &LuaEngine{
|
||||
state: L,
|
||||
transform: transform.(*lua.LFunction),
|
||||
}, nil
|
||||
return engine, 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.
|
||||
// It can return the following errors:
|
||||
// - types.ScriptExecutionError
|
||||
func (e *LuaEngine) Transform(req *RequestData) error {
|
||||
e.http.active = true
|
||||
defer func() { e.http.active = false }()
|
||||
|
||||
// Convert RequestData to Lua table
|
||||
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("body", lua.LString(req.Body))
|
||||
|
||||
// Headers (map[string][]string -> table of arrays)
|
||||
headers := L.NewTable()
|
||||
for k, values := range req.Headers {
|
||||
arr := L.NewTable()
|
||||
t.RawSetString("headers", stringSliceMapToTable(L, req.Headers))
|
||||
t.RawSetString("params", stringSliceMapToTable(L, req.Params))
|
||||
t.RawSetString("cookies", stringSliceMapToTable(L, req.Cookies))
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -172,20 +168,33 @@ func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string {
|
||||
}
|
||||
key := string(k.(lua.LString))
|
||||
|
||||
switch v.Type() {
|
||||
case lua.LTString:
|
||||
// Single string value
|
||||
result[key] = []string{string(v.(lua.LString))}
|
||||
case lua.LTTable:
|
||||
// Array of strings
|
||||
var values []string
|
||||
v.(*lua.LTable).ForEach(func(_, item lua.LValue) {
|
||||
if item.Type() == lua.LTString {
|
||||
values = append(values, string(item.(lua.LString)))
|
||||
if v.Type() == lua.LTTable {
|
||||
// Array of values, keys outside 1..n are skipped
|
||||
arr := v.(*lua.LTable)
|
||||
length := arr.Len()
|
||||
values := make([]string, 0, length)
|
||||
for i := 1; i <= length; i++ {
|
||||
if text, ok := luaScalarString(arr.RawGetInt(i)); ok {
|
||||
values = append(values, text)
|
||||
}
|
||||
})
|
||||
}
|
||||
result[key] = values
|
||||
return
|
||||
}
|
||||
|
||||
if text, ok := luaScalarString(v); ok {
|
||||
result[key] = []string{text}
|
||||
}
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ type Engine interface {
|
||||
// The script should modify the RequestData and return it.
|
||||
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()
|
||||
}
|
||||
@@ -69,7 +72,7 @@ func LoadSource(ctx context.Context, source string, engineType EngineType) (*Sou
|
||||
|
||||
switch {
|
||||
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
|
||||
case strings.HasPrefix(source, "@"):
|
||||
// File or URL reference
|
||||
@@ -125,7 +128,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
|
||||
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
|
||||
switch engineType {
|
||||
case EngineTypeLua:
|
||||
@@ -140,7 +143,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up the engine - we only needed it for validation
|
||||
// The engine was only needed for validation
|
||||
engine.Close()
|
||||
return nil
|
||||
}
|
||||
@@ -168,10 +171,18 @@ func fetchURL(ctx context.Context, url string) (string, error) {
|
||||
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 {
|
||||
return "", types.NewHTTPFetchError(url, err)
|
||||
}
|
||||
if len(data) > types.RemoteFetchLimit {
|
||||
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -10,6 +10,13 @@ import (
|
||||
|
||||
var (
|
||||
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 {
|
||||
@@ -318,6 +325,8 @@ func (e ConfigFileReadError) Unwrap() error {
|
||||
|
||||
// ======================================== Proxy ========================================
|
||||
|
||||
var ErrProxyNoContextDialer = errors.New("SOCKS5 dialer does not support timeouts")
|
||||
|
||||
type ProxyUnsupportedSchemeError struct {
|
||||
Scheme string
|
||||
}
|
||||
@@ -401,8 +410,89 @@ var (
|
||||
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
|
||||
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
|
||||
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 {
|
||||
Source string
|
||||
Err error
|
||||
|
||||
Reference in new issue
Block a user