mirror of
https://github.com/aykhans/sarin.git
synced 2026-10-17 14:53:52 +00:00
Compare commits
27
Commits
v1.4.4
..
e85a6d4613
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e85a6d4613
|
||
|
|
4a14d57d15
|
||
|
|
633f548e7b
|
||
|
|
68de415645
|
||
|
|
fb8443fd25
|
||
|
|
e0f6d4aeba
|
||
|
|
17dfa16051
|
||
|
|
a327c4978b
|
||
|
|
9d6e4e6bf8
|
||
|
|
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
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.13.2
|
version: v2.14.0
|
||||||
@@ -67,7 +67,7 @@ nix run github:aykhans/sarin/release -- -U http://example.com -r 100 -c 10
|
|||||||
Install into your profile:
|
Install into your profile:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix profile install github:aykhans/sarin/release
|
nix profile add github:aykhans/sarin/release
|
||||||
```
|
```
|
||||||
|
|
||||||
Or add it to your own flake via the overlay:
|
Or add it to your own flake via the overlay:
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ vars:
|
|||||||
BIN_DIR: ./bin
|
BIN_DIR: ./bin
|
||||||
GO_VERSION:
|
GO_VERSION:
|
||||||
sh: sed -n 's/^go //p' go.mod
|
sh: sed -n 's/^go //p' go.mod
|
||||||
GOLANGCI_LINT_VERSION: v2.13.2
|
GOLANGCI_LINT_VERSION: v2.14.0
|
||||||
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
|
|||||||
+3
-2
@@ -51,7 +51,8 @@ func main() {
|
|||||||
ctx,
|
ctx,
|
||||||
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
|
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
|
||||||
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
|
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
|
||||||
*combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers,
|
*combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, *combinedConfig.MaxResponseBody,
|
||||||
|
combinedConfig.Params, combinedConfig.Headers,
|
||||||
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
|
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
|
||||||
*combinedConfig.Output != config.ConfigOutputTypeNone,
|
*combinedConfig.Output != config.ConfigOutputTypeNone,
|
||||||
*combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile,
|
*combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile,
|
||||||
@@ -75,7 +76,7 @@ func main() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
srn.Start(ctx, stopCtrl)
|
srn.Start(stopCtrl)
|
||||||
|
|
||||||
switch *combinedConfig.Output {
|
switch *combinedConfig.Output {
|
||||||
case config.ConfigOutputTypeNone:
|
case config.ConfigOutputTypeNone:
|
||||||
|
|||||||
+146
-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.
|
> **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values.
|
||||||
|
|
||||||
| Name | YAML | CLI | ENV | Default | Description |
|
| Name | YAML | CLI | ENV | Default | Description |
|
||||||
| --------------------------- | ----------------------------------- | -------------------------------------------- | -------------------------------- | ------- | ---------------------------- |
|
| --------------------------------------- | ----------------------------------- | -------------------------------------------- | ----------------------------------- | ------- | ----------------------------- |
|
||||||
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
|
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
|
||||||
| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info |
|
| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info |
|
||||||
| [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration |
|
| [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration |
|
||||||
| [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) |
|
| [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) |
|
| [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) |
|
||||||
| [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) |
|
| [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) |
|
||||||
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
|
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
|
||||||
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
|
| [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 |
|
||||||
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
|
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
|
||||||
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
|
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
|
||||||
| [Log Level](#log-level) | `logLevel`<br>(string) | `-log-level` / `-l`<br>(string) | `SARIN_LOG_LEVEL`<br>(string) | `error` | Runtime log levels to emit |
|
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
|
||||||
| [Log File](#log-file) | `logFile`<br>(string) | `-log-file` / `-w`<br>(string) | `SARIN_LOG_FILE`<br>(string) | - | Write runtime logs to a file |
|
| [Log Level](#log-level) | `logLevel`<br>(string) | `-log-level` / `-l`<br>(string) | `SARIN_LOG_LEVEL`<br>(string) | `error` | Runtime log levels to emit |
|
||||||
| [Progress](#progress) | `progress`<br>(string) | `-progress` / `-p`<br>(string) | `SARIN_PROGRESS`<br>(string) | `bar` | Progress display (bar/none) |
|
| [Log File](#log-file) | `logFile`<br>(string) | `-log-file` / `-w`<br>(string) | `SARIN_LOG_FILE`<br>(string) | - | Write runtime logs to a file |
|
||||||
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
|
| [Progress](#progress) | `progress`<br>(string) | `-progress` / `-p`<br>(string) | `SARIN_PROGRESS`<br>(string) | `bar` | Progress display (bar/none) |
|
||||||
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
|
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
|
||||||
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
|
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
|
||||||
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
|
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
|
||||||
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
|
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
|
||||||
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
|
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
|
||||||
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
|
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
|
||||||
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
|
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
|
||||||
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
|
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
|
||||||
| [Lua](#lua) | `lua`<br>(string / []string) | `-lua`<br>(string / []string) | `SARIN_LUA`<br>(string) | - | Lua script(s) |
|
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
|
||||||
| [Js](#js) | `js`<br>(string / []string) | `-js`<br>(string / []string) | `SARIN_JS`<br>(string) | - | JavaScript script(s) |
|
| [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`
|
**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 `response body exceeds the body limit` and is counted like any other error.
|
||||||
|
|
||||||
|
The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. It covers the requests scripts make as well.
|
||||||
|
|
||||||
## Concurrency
|
## Concurrency
|
||||||
|
|
||||||
Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`.
|
Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`.
|
||||||
@@ -456,7 +469,9 @@ function transform(req)
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`).
|
> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`). Numbers and booleans are converted to text, other values are skipped.
|
||||||
|
|
||||||
|
> **Note:** Lua scripts can send their own HTTP requests with `http` and work with JSON through the global `json` table. See [HTTP Requests in Scripts](#http-requests-in-scripts).
|
||||||
|
|
||||||
**YAML example:**
|
**YAML example:**
|
||||||
|
|
||||||
@@ -521,7 +536,9 @@ function transform(req) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`).
|
> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`). Numbers and booleans are converted to text, other values are skipped.
|
||||||
|
|
||||||
|
> **Note:** JavaScript scripts can send their own HTTP requests with `http`. See [HTTP Requests in Scripts](#http-requests-in-scripts).
|
||||||
|
|
||||||
**YAML example:**
|
**YAML example:**
|
||||||
|
|
||||||
@@ -554,3 +571,104 @@ js:
|
|||||||
```sh
|
```sh
|
||||||
SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
|
SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## HTTP Requests in Scripts
|
||||||
|
|
||||||
|
Lua and JavaScript scripts can send their own HTTP requests and use the response in the main request, for example to fetch a fresh token or CSRF value from another endpoint.
|
||||||
|
|
||||||
|
**Function:**
|
||||||
|
|
||||||
|
```
|
||||||
|
http(url, opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
`url` must be an absolute `http` or `https` URL. `opts` is optional.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
| -------------- | --------------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `method` | string | `GET` | HTTP method, sent as given |
|
||||||
|
| `headers` | table/object | - | Request headers, same shape as `req.headers` |
|
||||||
|
| `params` | table/object | - | Query parameters, added to any already in the URL |
|
||||||
|
| `cookies` | table/object | - | Cookies, sent as a single `Cookie` header |
|
||||||
|
| `body` | string | `""` | Request body |
|
||||||
|
| `timeout` | duration string (`"500ms"`) | `30s` | Request timeout (the `timeout` config does not apply), applied to each hop when redirects are followed |
|
||||||
|
| `insecure` | boolean | `false` | Skip TLS verification (the `insecure` config does not apply) |
|
||||||
|
| `maxRedirects` | number | `0` | How many redirects to follow, `0` follows none, maximum 100 |
|
||||||
|
|
||||||
|
Unknown options and values of the wrong type raise an error.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
| Field / Method | Description |
|
||||||
|
| -------------- | ------------------------------------------------------------------ |
|
||||||
|
| `status` | Status code (number) |
|
||||||
|
| `headers` | Response headers, each key holding an array of values |
|
||||||
|
| `body` | Response body (string) |
|
||||||
|
| `header(name)` | First value of a header, case-insensitive; `nil`/`null` if missing |
|
||||||
|
| `cookie(name)` | Value of a cookie from `Set-Cookie`; `nil`/`null` if missing |
|
||||||
|
| `json()` | JavaScript only: parses the body as JSON |
|
||||||
|
| `bytes()` | JavaScript only: the body as a `Uint8Array` |
|
||||||
|
|
||||||
|
In Lua, call the helpers as `res:header("name")` or `res.header("name")`.
|
||||||
|
|
||||||
|
> **Note:** JavaScript strings hold UTF-16, so `body` replaces bytes that are not valid UTF-8 and `body.length` counts code units rather than bytes. Use `bytes()` for binary responses and for the real byte count. Lua strings keep the bytes as they arrived.
|
||||||
|
|
||||||
|
**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,139 @@ js: "@./scripts/body.js"
|
|||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
**Fetch a value from another endpoint before each request:**
|
||||||
|
|
||||||
|
Scripts can send their own requests with `http`. Here each request first fetches a CSRF token and sends it in a header and cookie. See [HTTP Requests in Scripts](configuration.md#http-requests-in-scripts) for all options.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api/orders -r 1000 -c 10 \
|
||||||
|
-M POST \
|
||||||
|
-lua 'function transform(req)
|
||||||
|
local res = http("http://example.com/csrf")
|
||||||
|
req.headers["X-CSRF-Token"] = json.decode(res.body).token
|
||||||
|
req.cookies["session"] = res:cookie("session")
|
||||||
|
return req
|
||||||
|
end'
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api/orders
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
method: POST
|
||||||
|
lua: |
|
||||||
|
function transform(req)
|
||||||
|
local res = http("http://example.com/csrf")
|
||||||
|
req.headers["X-CSRF-Token"] = json.decode(res.body).token
|
||||||
|
req.cookies["session"] = res:cookie("session")
|
||||||
|
return req
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Log in once per worker and reuse the token (JavaScript):**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api/profile
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
js: |
|
||||||
|
let token;
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
const res = http("http://example.com/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ user: "test", password: "secret" }),
|
||||||
|
});
|
||||||
|
if (res.status !== 200) throw new Error("login failed: " + res.status);
|
||||||
|
return res.json().token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transform(req) {
|
||||||
|
if (!token) token = login();
|
||||||
|
req.headers["Authorization"] = "Bearer " + token;
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chain two calls, then use the result in the URL path (Lua):**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api/placeholder
|
||||||
|
requests: 500
|
||||||
|
concurrency: 10
|
||||||
|
lua: |
|
||||||
|
function transform(req)
|
||||||
|
local token = json.decode(http("http://example.com/login", {
|
||||||
|
method = "POST",
|
||||||
|
body = json.encode({ user = "test", password = "secret" }),
|
||||||
|
headers = { ["Content-Type"] = "application/json" },
|
||||||
|
}).body).token
|
||||||
|
|
||||||
|
local orders = json.decode(http("http://example.com/api/orders", {
|
||||||
|
headers = { Authorization = "Bearer " .. token },
|
||||||
|
}).body)
|
||||||
|
|
||||||
|
req.path = "/api/orders/" .. orders[1].id
|
||||||
|
req.headers["Authorization"] = "Bearer " .. token
|
||||||
|
return req
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Refresh a token only when it expires (JavaScript):**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api/profile
|
||||||
|
requests: 5000
|
||||||
|
concurrency: 20
|
||||||
|
js: |
|
||||||
|
let token;
|
||||||
|
let expiresAt = 0;
|
||||||
|
|
||||||
|
function transform(req) {
|
||||||
|
if (Date.now() >= expiresAt) {
|
||||||
|
const res = http("http://example.com/login", { method: "POST", body: "{}" });
|
||||||
|
token = res.json().token;
|
||||||
|
expiresAt = Date.now() + 60000; // refresh once a minute per worker
|
||||||
|
}
|
||||||
|
req.headers["Authorization"] = "Bearer " + token;
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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;
|
||||||
|
}'
|
||||||
|
```
|
||||||
+2
-2
@@ -4,7 +4,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
|
|||||||
|
|
||||||
> **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates.
|
> **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates.
|
||||||
|
|
||||||
> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take.
|
> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take. Downloads have their own limits: 30 seconds and 64MiB per file.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
@@ -521,7 +521,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
|
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
|
||||||
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
|
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
|
||||||
| `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` |
|
| `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` |
|
||||||
| `fakeit_Quote` | Random quote | `"Energy is contagious—so is fear"` |
|
| `fakeit_Quote` | Random quote | `"Energy is contagious so is fear"` |
|
||||||
| `fakeit_Phrase` | Random phrase | `"how many siblings do you have"` |
|
| `fakeit_Phrase` | Random phrase | `"how many siblings do you have"` |
|
||||||
| `fakeit_PhraseNoun` | Noun phrase | `"a tribe"` |
|
| `fakeit_PhraseNoun` | Noun phrase | `"a tribe"` |
|
||||||
| `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` |
|
| `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` |
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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 reads the raw scalar so a size parses the same way here as on the command line.
|
||||||
|
func (size *byteSize) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
if node.Kind != yaml.ScalarNode {
|
||||||
|
return fmt.Errorf("expected a size, but got %s", yamlNodeName(node))
|
||||||
|
}
|
||||||
|
|
||||||
|
return size.Set(node.Value)
|
||||||
|
}
|
||||||
+44
-36
@@ -20,32 +20,33 @@ Simple usage:
|
|||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
General Config:
|
General Config:
|
||||||
-h, -help Help for sarin
|
-h, -help Help for sarin
|
||||||
-v, -version Version for sarin
|
-v, -version Version for sarin
|
||||||
-s, -show-config bool Show the final config after parsing all sources (default %v)
|
-s, -show-config bool Show the final config after parsing all sources (default %v)
|
||||||
-f, -config-file string Path to the config file (local file / http URL)
|
-f, -config-file []string Path to the config file (local file / http URL)
|
||||||
-c, -concurrency uint Number of concurrent requests (default %d)
|
-c, -concurrency uint Number of concurrent requests (default %d)
|
||||||
-r, -requests uint Number of total requests
|
-r, -requests uint Number of total requests
|
||||||
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
||||||
-l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s)
|
-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
|
-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')
|
-p, -progress string Progress display (possible values: bar, none) (default '%v')
|
||||||
-o, -output string Output format (possible values: table, json, yaml, 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)
|
-z, -dry-run bool Run without sending requests (default %v)
|
||||||
|
|
||||||
Request Config:
|
Request Config:
|
||||||
-U, -url string Target URL for the request
|
-U, -url string Target URL for the request
|
||||||
-M, -method []string HTTP method for the request (default %s)
|
-M, -method []string HTTP method for the request (default %s)
|
||||||
-B, -body []string Body for the request (e.g. "body text")
|
-B, -body []string Body for the request (e.g. "body text")
|
||||||
-P, -param []string URL parameter for the request (e.g. "key1=value1")
|
-P, -param []string URL parameter for the request (e.g. "key1=value1")
|
||||||
-H, -header []string Header 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")
|
-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")
|
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
|
||||||
-V, -values []string List of values for templating (e.g. "key1=value1")
|
-V, -values []string List of values for templating (e.g. "key1=value1")
|
||||||
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
|
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
|
||||||
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)
|
-S, -max-response-body size Largest response body to read (e.g. 10MiB, 2MB), 0 for no limit (default %s)
|
||||||
-lua []string Lua script for request transformation (inline or @file/@url)
|
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)
|
||||||
-js []string JavaScript script for request transformation (inline or @file/@url)`
|
-lua []string Lua script for request transformation (inline or @file/@url)
|
||||||
|
-js []string JavaScript script for request transformation (inline or @file/@url)`
|
||||||
|
|
||||||
var _ IParser = ConfigCLIParser{}
|
var _ IParser = ConfigCLIParser{}
|
||||||
|
|
||||||
@@ -97,18 +98,19 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
dryRun bool
|
dryRun bool
|
||||||
|
|
||||||
// Request config
|
// Request config
|
||||||
urlInput string
|
urlInput string
|
||||||
methods = stringSliceArg{}
|
methods = stringSliceArg{}
|
||||||
bodies = stringSliceArg{}
|
bodies = stringSliceArg{}
|
||||||
params = stringSliceArg{}
|
params = stringSliceArg{}
|
||||||
headers = stringSliceArg{}
|
headers = stringSliceArg{}
|
||||||
cookies = stringSliceArg{}
|
cookies = stringSliceArg{}
|
||||||
proxies = stringSliceArg{}
|
proxies = stringSliceArg{}
|
||||||
values = stringSliceArg{}
|
values = stringSliceArg{}
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
insecure bool
|
maxResponseBody byteSize
|
||||||
luaScripts = stringSliceArg{}
|
insecure bool
|
||||||
jsScripts = stringSliceArg{}
|
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, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
|
||||||
flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
|
flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
|
||||||
|
|
||||||
|
flagSet.Var(&maxResponseBody, "max-response-body", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit")
|
||||||
|
flagSet.Var(&maxResponseBody, "S", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit")
|
||||||
|
|
||||||
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
|
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
|
||||||
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
|
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
|
||||||
|
|
||||||
@@ -258,6 +263,8 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
config.Values = append(config.Values, values...)
|
config.Values = append(config.Values, values...)
|
||||||
case "timeout", "T":
|
case "timeout", "T":
|
||||||
config.Timeout = new(timeout)
|
config.Timeout = new(timeout)
|
||||||
|
case "max-response-body", "S":
|
||||||
|
config.MaxResponseBody = new(uint64(maxResponseBody))
|
||||||
case "insecure", "I":
|
case "insecure", "I":
|
||||||
config.Insecure = new(insecure)
|
config.Insecure = new(insecure)
|
||||||
case "lua":
|
case "lua":
|
||||||
@@ -286,6 +293,7 @@ func (parser ConfigCLIParser) PrintHelp() {
|
|||||||
|
|
||||||
Defaults.Method,
|
Defaults.Method,
|
||||||
Defaults.RequestTimeout,
|
Defaults.RequestTimeout,
|
||||||
|
types.FormatByteSize(Defaults.MaxResponseBody),
|
||||||
Defaults.Insecure,
|
Defaults.Insecure,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+78
-51
@@ -27,27 +27,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var Defaults = struct {
|
var Defaults = struct {
|
||||||
UserAgent string
|
UserAgent string
|
||||||
Method string
|
Method string
|
||||||
RequestTimeout time.Duration
|
RequestTimeout time.Duration
|
||||||
Concurrency uint
|
MaxResponseBody uint64
|
||||||
ShowConfig bool
|
Concurrency uint
|
||||||
Progress ConfigProgressType
|
ShowConfig bool
|
||||||
Insecure bool
|
Progress ConfigProgressType
|
||||||
Output ConfigOutputType
|
Insecure bool
|
||||||
DryRun bool
|
Output ConfigOutputType
|
||||||
LogLevel string
|
DryRun bool
|
||||||
|
LogLevel string
|
||||||
}{
|
}{
|
||||||
UserAgent: "Sarin/" + version.Version,
|
UserAgent: "Sarin/" + version.Version,
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
RequestTimeout: time.Second * 10,
|
RequestTimeout: time.Second * 10,
|
||||||
Concurrency: 1,
|
MaxResponseBody: 10 << 20, // 10 MiB
|
||||||
ShowConfig: false,
|
Concurrency: 1,
|
||||||
Progress: ConfigProgressTypeBar,
|
ShowConfig: false,
|
||||||
Insecure: false,
|
Progress: ConfigProgressTypeBar,
|
||||||
Output: ConfigOutputTypeTable,
|
Insecure: false,
|
||||||
DryRun: false,
|
Output: ConfigOutputTypeTable,
|
||||||
LogLevel: "error",
|
DryRun: false,
|
||||||
|
LogLevel: "error",
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -82,28 +84,29 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
||||||
Files []types.ConfigFile `yaml:"files,omitempty"`
|
Files []types.ConfigFile `yaml:"files,omitempty"`
|
||||||
Methods []string `yaml:"methods,omitempty"`
|
Methods []string `yaml:"methods,omitempty"`
|
||||||
URL *url.URL `yaml:"url,omitempty"`
|
URL *url.URL `yaml:"url,omitempty"`
|
||||||
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
||||||
Concurrency *uint `yaml:"concurrency,omitempty"`
|
MaxResponseBody *uint64 `yaml:"maxResponseBody,omitempty"`
|
||||||
Requests *uint64 `yaml:"requests,omitempty"`
|
Concurrency *uint `yaml:"concurrency,omitempty"`
|
||||||
Duration *time.Duration `yaml:"duration,omitempty"`
|
Requests *uint64 `yaml:"requests,omitempty"`
|
||||||
Progress *ConfigProgressType `yaml:"progress,omitempty"`
|
Duration *time.Duration `yaml:"duration,omitempty"`
|
||||||
Output *ConfigOutputType `yaml:"output,omitempty"`
|
Progress *ConfigProgressType `yaml:"progress,omitempty"`
|
||||||
Insecure *bool `yaml:"insecure,omitempty"`
|
Output *ConfigOutputType `yaml:"output,omitempty"`
|
||||||
DryRun *bool `yaml:"dryRun,omitempty"`
|
Insecure *bool `yaml:"insecure,omitempty"`
|
||||||
Params types.Params `yaml:"params,omitempty"`
|
DryRun *bool `yaml:"dryRun,omitempty"`
|
||||||
Headers types.Headers `yaml:"headers,omitempty"`
|
Params types.Params `yaml:"params,omitempty"`
|
||||||
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
Headers types.Headers `yaml:"headers,omitempty"`
|
||||||
Bodies []string `yaml:"bodies,omitempty"`
|
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
||||||
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
Bodies []string `yaml:"bodies,omitempty"`
|
||||||
Values []string `yaml:"values,omitempty"`
|
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
||||||
Lua []string `yaml:"lua,omitempty"`
|
Values []string `yaml:"values,omitempty"`
|
||||||
Js []string `yaml:"js,omitempty"`
|
Lua []string `yaml:"lua,omitempty"`
|
||||||
LogLevel *string `yaml:"logLevel,omitempty"`
|
Js []string `yaml:"js,omitempty"`
|
||||||
LogFile *string `yaml:"logFile,omitempty"`
|
LogLevel *string `yaml:"logLevel,omitempty"`
|
||||||
|
LogFile *string `yaml:"logFile,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config Config) MarshalYAML() (any, error) {
|
func (config Config) MarshalYAML() (any, error) {
|
||||||
@@ -177,6 +180,9 @@ func (config Config) MarshalYAML() (any, error) {
|
|||||||
if config.Timeout != nil {
|
if config.Timeout != nil {
|
||||||
addField(content, "timeout", toNode(*config.Timeout), "")
|
addField(content, "timeout", toNode(*config.Timeout), "")
|
||||||
}
|
}
|
||||||
|
if config.MaxResponseBody != nil {
|
||||||
|
addField(content, "maxResponseBody", toNode(types.FormatByteSize(*config.MaxResponseBody)), "")
|
||||||
|
}
|
||||||
if config.Concurrency != nil {
|
if config.Concurrency != nil {
|
||||||
addField(content, "concurrency", toNode(*config.Concurrency), "")
|
addField(content, "concurrency", toNode(*config.Concurrency), "")
|
||||||
}
|
}
|
||||||
@@ -301,6 +307,9 @@ func (config *Config) Merge(newConfig *Config) {
|
|||||||
if newConfig.Timeout != nil {
|
if newConfig.Timeout != nil {
|
||||||
config.Timeout = newConfig.Timeout
|
config.Timeout = newConfig.Timeout
|
||||||
}
|
}
|
||||||
|
if newConfig.MaxResponseBody != nil {
|
||||||
|
config.MaxResponseBody = newConfig.MaxResponseBody
|
||||||
|
}
|
||||||
if newConfig.Concurrency != nil {
|
if newConfig.Concurrency != nil {
|
||||||
config.Concurrency = newConfig.Concurrency
|
config.Concurrency = newConfig.Concurrency
|
||||||
}
|
}
|
||||||
@@ -379,6 +388,9 @@ func (config *Config) SetDefaults() {
|
|||||||
if config.Timeout == nil {
|
if config.Timeout == nil {
|
||||||
config.Timeout = &Defaults.RequestTimeout
|
config.Timeout = &Defaults.RequestTimeout
|
||||||
}
|
}
|
||||||
|
if config.MaxResponseBody == nil {
|
||||||
|
config.MaxResponseBody = &Defaults.MaxResponseBody
|
||||||
|
}
|
||||||
if config.Concurrency == nil {
|
if config.Concurrency == nil {
|
||||||
config.Concurrency = new(Defaults.Concurrency)
|
config.Concurrency = new(Defaults.Concurrency)
|
||||||
}
|
}
|
||||||
@@ -454,6 +466,14 @@ func (config Config) Validate() error {
|
|||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.MaxResponseBody != nil && *config.MaxResponseBody > types.ByteSizeLimit {
|
||||||
|
validationErrors = append(validationErrors, types.NewFieldValidationError(
|
||||||
|
"MaxResponseBody",
|
||||||
|
types.FormatByteSize(*config.MaxResponseBody),
|
||||||
|
errors.New("max response body must not exceed "+types.FormatByteSize(types.ByteSizeLimit)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
if config.ShowConfig == nil {
|
if config.ShowConfig == nil {
|
||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
|
||||||
}
|
}
|
||||||
@@ -632,12 +652,19 @@ func ReadAllConfigs() *Config {
|
|||||||
|
|
||||||
for _, configFile := range append(envConfig.Files, cliConf.Files...) {
|
for _, configFile := range append(envConfig.Files, cliConf.Files...) {
|
||||||
fileConfig, err := parseConfigFile(configFile, 10)
|
fileConfig, err := parseConfigFile(configFile, 10)
|
||||||
|
|
||||||
|
// A nested configFile fails under its own name, not the one given on the command line.
|
||||||
|
path := configFile.Path()
|
||||||
|
if configFileErr, ok := errors.AsType[types.ConfigFileError](err); ok {
|
||||||
|
path = configFileErr.Path
|
||||||
|
}
|
||||||
|
|
||||||
_ = utilsErr.MustHandle(err,
|
_ = utilsErr.MustHandle(err,
|
||||||
utilsErr.OnType(func(err types.ConfigFileReadError) error {
|
utilsErr.OnType(func(err types.ConfigFileReadError) error {
|
||||||
cliParser.PrintHelp()
|
cliParser.PrintHelp()
|
||||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||||
StyleYellow.Render(
|
StyleYellow.Render(
|
||||||
fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(),
|
fmt.Sprintf("\nFailed to read config file (%s): ", path)+err.Error(),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -646,14 +673,14 @@ func ReadAllConfigs() *Config {
|
|||||||
utilsErr.OnType(func(err types.UnmarshalError) error {
|
utilsErr.OnType(func(err types.UnmarshalError) error {
|
||||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||||
StyleYellow.Render(
|
StyleYellow.Render(
|
||||||
fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(),
|
fmt.Sprintf("\nFailed to parse config file (%s): ", path)+err.Error(),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
utilsErr.OnType(func(err types.FieldParseErrors) error {
|
utilsErr.OnType(func(err types.FieldParseErrors) error {
|
||||||
printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", configFile.Path()), err.Errors...)
|
printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", path), err.Errors...)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
@@ -669,7 +696,7 @@ func ReadAllConfigs() *Config {
|
|||||||
|
|
||||||
// parseConfigFile recursively parses a config file and its nested files up to maxDepth levels.
|
// parseConfigFile recursively parses a config file and its nested files up to maxDepth levels.
|
||||||
// Returns the merged configuration or an error if parsing fails.
|
// Returns the merged configuration or an error if parsing fails.
|
||||||
// It can return the following errors:
|
// It can return the following errors, each wrapped in a types.ConfigFileError naming the file:
|
||||||
// - types.ConfigFileReadError
|
// - types.ConfigFileReadError
|
||||||
// - types.UnmarshalError
|
// - types.UnmarshalError
|
||||||
// - types.FieldParseErrors
|
// - types.FieldParseErrors
|
||||||
@@ -677,7 +704,7 @@ func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error)
|
|||||||
configFileParser := NewConfigFileParser(configFile)
|
configFileParser := NewConfigFileParser(configFile)
|
||||||
fileConfig, err := configFileParser.Parse()
|
fileConfig, err := configFileParser.Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, types.NewConfigFileError(configFile.Path(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if maxDepth <= 0 {
|
if maxDepth <= 0 {
|
||||||
@@ -715,7 +742,7 @@ func validateScriptSource(script string) error {
|
|||||||
return types.ErrScriptEmpty
|
return types.ErrScriptEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not a file/URL reference - it's an inline script
|
// Not a file/URL reference, so it's an inline script
|
||||||
if !strings.HasPrefix(script, "@") {
|
if !strings.HasPrefix(script, "@") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -725,7 +752,7 @@ func validateScriptSource(script string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// It's a file or URL reference - validate the source
|
// It's a file or URL reference, so validate the source
|
||||||
source := script[1:] // Remove the @ prefix
|
source := script[1:] // Remove the @ prefix
|
||||||
|
|
||||||
if source == "" {
|
if source == "" {
|
||||||
@@ -744,7 +771,7 @@ func validateScriptSource(script string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// It's a file path - basic validation (not empty, checked above)
|
// It's a file path, already checked to be non-empty above
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,7 +849,7 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m printConfigModel) View() tea.View {
|
func (m printConfigModel) View() tea.View {
|
||||||
// AltScreen and MouseMode were program options in bubbletea v1; in v2 they
|
// AltScreen and MouseMode were program options in bubbletea v1, in v2 they
|
||||||
// are view properties, so every return path has to declare them.
|
// are view properties, so every return path has to declare them.
|
||||||
newView := func(s string) tea.View {
|
newView := func(s string) tea.View {
|
||||||
v := tea.NewView(s)
|
v := tea.NewView(s)
|
||||||
|
|||||||
@@ -195,6 +195,22 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if maxResponseBody := parser.getEnv("MAX_RESPONSE_BODY"); maxResponseBody != "" {
|
||||||
|
maxResponseBodyParsed, err := types.ParseByteSize(maxResponseBody)
|
||||||
|
if err != nil {
|
||||||
|
fieldParseErrors = append(
|
||||||
|
fieldParseErrors,
|
||||||
|
types.NewFieldParseError(
|
||||||
|
parser.getFullEnvName("MAX_RESPONSE_BODY"),
|
||||||
|
maxResponseBody,
|
||||||
|
err,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
config.MaxResponseBody = &maxResponseBodyParsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
||||||
insecureParsed, err := utilsParse.ParseString[bool](insecure)
|
insecureParsed, err := utilsParse.ParseString[bool](insecure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+248
-32
@@ -1,13 +1,17 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -79,10 +83,18 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
|||||||
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
// A declared size above the limit is refused before anything is read.
|
||||||
|
if resp.ContentLength > types.RemoteFetchLimit {
|
||||||
|
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewHTTPFetchError(url, err)
|
return nil, types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
if len(data) > types.RemoteFetchLimit {
|
||||||
|
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
@@ -126,7 +138,7 @@ func (ss *stringOrSliceField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
*ss = slice
|
*ss = slice
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("expected a string or a sequence of strings, but got %v", node.Kind)
|
return fmt.Errorf("expected a string or a sequence of strings, but got %s", yamlNodeName(node))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +159,7 @@ func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
// Handle sequence of maps: [{key1: value1}, {key2: value2}]
|
// Handle sequence of maps: [{key1: value1}, {key2: value2}]
|
||||||
for _, item := range node.Content {
|
for _, item := range node.Content {
|
||||||
if item.Kind != yaml.MappingNode {
|
if item.Kind != yaml.MappingNode {
|
||||||
return fmt.Errorf("expected a mapping in sequence, but got %v", item.Kind)
|
return fmt.Errorf("expected a mapping in sequence, but got %s", yamlNodeName(item))
|
||||||
}
|
}
|
||||||
if err := kv.unmarshalMapping(item); err != nil {
|
if err := kv.unmarshalMapping(item); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -155,7 +167,7 @@ func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("expected a mapping or sequence of mappings, but got %v", node.Kind)
|
return fmt.Errorf("expected a mapping or sequence of mappings, but got %s", yamlNodeName(node))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +178,7 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
|||||||
valueNode := node.Content[i+1]
|
valueNode := node.Content[i+1]
|
||||||
|
|
||||||
if keyNode.Kind != yaml.ScalarNode {
|
if keyNode.Kind != yaml.ScalarNode {
|
||||||
return fmt.Errorf("expected a string key, but got %v", keyNode.Kind)
|
return fmt.Errorf("expected a string key, but got %s", yamlNodeName(keyNode))
|
||||||
}
|
}
|
||||||
|
|
||||||
key := keyNode.Value
|
key := keyNode.Value
|
||||||
@@ -178,12 +190,12 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
|||||||
case yaml.SequenceNode:
|
case yaml.SequenceNode:
|
||||||
for _, v := range valueNode.Content {
|
for _, v := range valueNode.Content {
|
||||||
if v.Kind != yaml.ScalarNode {
|
if v.Kind != yaml.ScalarNode {
|
||||||
return fmt.Errorf("expected string values in array for key %q, but got %v", key, v.Kind)
|
return fmt.Errorf("expected string values in array for key %q, but got %s", key, yamlNodeName(v))
|
||||||
}
|
}
|
||||||
values = append(values, v.Value)
|
values = append(values, v.Value)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("expected a string or array of strings for key %q, but got %v", key, valueNode.Kind)
|
return fmt.Errorf("expected a string or array of strings for key %q, but got %s", key, yamlNodeName(valueNode))
|
||||||
}
|
}
|
||||||
|
|
||||||
*kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values})
|
*kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values})
|
||||||
@@ -192,28 +204,29 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type configYAML struct {
|
type configYAML struct {
|
||||||
ShowConfig *bool `yaml:"showConfig"`
|
ShowConfig *bool `yaml:"showConfig"`
|
||||||
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
||||||
Concurrency *uint `yaml:"concurrency"`
|
Concurrency *uint `yaml:"concurrency"`
|
||||||
RequestCount *uint64 `yaml:"requests"`
|
RequestCount *uint64 `yaml:"requests"`
|
||||||
Duration *time.Duration `yaml:"duration"`
|
Duration *time.Duration `yaml:"duration"`
|
||||||
LogLevel *string `yaml:"logLevel"`
|
LogLevel *string `yaml:"logLevel"`
|
||||||
LogFile *string `yaml:"logFile"`
|
LogFile *string `yaml:"logFile"`
|
||||||
Progress *string `yaml:"progress"`
|
Progress *string `yaml:"progress"`
|
||||||
Output *string `yaml:"output"`
|
Output *string `yaml:"output"`
|
||||||
DryRun *bool `yaml:"dryRun"`
|
DryRun *bool `yaml:"dryRun"`
|
||||||
URL *string `yaml:"url"`
|
URL *string `yaml:"url"`
|
||||||
Method stringOrSliceField `yaml:"method"`
|
Method stringOrSliceField `yaml:"method"`
|
||||||
Bodies stringOrSliceField `yaml:"body"`
|
Bodies stringOrSliceField `yaml:"body"`
|
||||||
Params keyValuesField `yaml:"params"`
|
Params keyValuesField `yaml:"params"`
|
||||||
Headers keyValuesField `yaml:"headers"`
|
Headers keyValuesField `yaml:"headers"`
|
||||||
Cookies keyValuesField `yaml:"cookies"`
|
Cookies keyValuesField `yaml:"cookies"`
|
||||||
Proxies stringOrSliceField `yaml:"proxy"`
|
Proxies stringOrSliceField `yaml:"proxy"`
|
||||||
Values stringOrSliceField `yaml:"values"`
|
Values stringOrSliceField `yaml:"values"`
|
||||||
Timeout *time.Duration `yaml:"timeout"`
|
Timeout *time.Duration `yaml:"timeout"`
|
||||||
Insecure *bool `yaml:"insecure"`
|
MaxResponseBody *byteSize `yaml:"maxResponseBody"`
|
||||||
Lua stringOrSliceField `yaml:"lua"`
|
Insecure *bool `yaml:"insecure"`
|
||||||
Js stringOrSliceField `yaml:"js"`
|
Lua stringOrSliceField `yaml:"lua"`
|
||||||
|
Js stringOrSliceField `yaml:"js"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseYAML parses YAML config file arguments into a Config object.
|
// ParseYAML parses YAML config file arguments into a Config object.
|
||||||
@@ -226,9 +239,26 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
parsedData = &configYAML{}
|
parsedData = &configYAML{}
|
||||||
)
|
)
|
||||||
|
|
||||||
err := yaml.Unmarshal(data, &parsedData)
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
if err != nil {
|
decoder.KnownFields(true)
|
||||||
return nil, types.NewUnmarshalError(err)
|
// The decoder returns a bare io.EOF for an empty file, errors.Is would also match a wrapped one.
|
||||||
|
if err := decoder.Decode(parsedData); err != nil && err != io.EOF { //nolint:errorlint
|
||||||
|
return nil, types.NewUnmarshalError(yamlError(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode reads one document, so anything after the first "---" would be dropped silently.
|
||||||
|
for {
|
||||||
|
var next yaml.Node
|
||||||
|
err := decoder.Decode(&next)
|
||||||
|
if err == io.EOF { //nolint:errorlint
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewUnmarshalError(yamlError(err))
|
||||||
|
}
|
||||||
|
if !isEmptyDocument(&next) {
|
||||||
|
return nil, types.NewUnmarshalError(types.ErrYAMLMultipleDocuments)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fieldParseErrors []types.FieldParseError
|
var fieldParseErrors []types.FieldParseError
|
||||||
@@ -290,6 +320,9 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
|
|
||||||
config.Values = append(config.Values, parsedData.Values...)
|
config.Values = append(config.Values, parsedData.Values...)
|
||||||
config.Timeout = parsedData.Timeout
|
config.Timeout = parsedData.Timeout
|
||||||
|
if parsedData.MaxResponseBody != nil {
|
||||||
|
config.MaxResponseBody = new(uint64(*parsedData.MaxResponseBody))
|
||||||
|
}
|
||||||
config.Insecure = parsedData.Insecure
|
config.Insecure = parsedData.Insecure
|
||||||
config.Lua = append(config.Lua, parsedData.Lua...)
|
config.Lua = append(config.Lua, parsedData.Lua...)
|
||||||
config.Js = append(config.Js, parsedData.Js...)
|
config.Js = append(config.Js, parsedData.Js...)
|
||||||
@@ -300,3 +333,186 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configYAMLTypeName is the Go type name the yaml package puts in its messages.
|
||||||
|
var configYAMLTypeName = reflect.TypeFor[configYAML]().String()
|
||||||
|
|
||||||
|
// isEmptyDocument reports whether a document holds nothing, which a trailing "---" produces.
|
||||||
|
// A null tag alone is not enough, since a user can hang it on a mapping that does hold content.
|
||||||
|
func isEmptyDocument(node *yaml.Node) bool {
|
||||||
|
if len(node.Content) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
root := node.Content[0]
|
||||||
|
return root.Kind == yaml.ScalarNode && root.Tag == "!!null"
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlError rewrites yaml decode errors so they read like config file errors.
|
||||||
|
// Errors from building the config arrive collected, everything earlier arrives on its own.
|
||||||
|
func yamlError(err error) error {
|
||||||
|
var loadErrors *yaml.LoadErrors
|
||||||
|
if errors.As(err, &loadErrors) && len(loadErrors.Errors) > 0 {
|
||||||
|
var builder strings.Builder
|
||||||
|
for i, loadError := range loadErrors.Errors {
|
||||||
|
if i > 0 {
|
||||||
|
builder.WriteString("\n")
|
||||||
|
}
|
||||||
|
builder.WriteString(yamlErrorText(loadError))
|
||||||
|
}
|
||||||
|
return errors.New(builder.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadError, ok := errors.AsType[*yaml.LoadError](err); ok {
|
||||||
|
return errors.New(yamlErrorText(loadError))
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlErrorText renders one error as "line 3: while scanning a plain scalar: found a tab character".
|
||||||
|
// The mark is where reading gave up and the context mark starts the construct that broke.
|
||||||
|
func yamlErrorText(loadError *yaml.LoadError) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
if loadError.Mark.Line > 0 {
|
||||||
|
fmt.Fprintf(&builder, "line %d: ", loadError.Mark.Line)
|
||||||
|
}
|
||||||
|
if loadError.ContextMsg != "" {
|
||||||
|
builder.WriteString(loadError.ContextMsg + ": ")
|
||||||
|
}
|
||||||
|
builder.WriteString(yamlErrorMessage(loadError.Message))
|
||||||
|
|
||||||
|
start := loadError.ContextMark
|
||||||
|
if loadError.ContextMsg != "" && start.Line > 0 && start.Line != loadError.Mark.Line {
|
||||||
|
fmt.Fprintf(&builder, " (started at line %d)", start.Line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlTypeNames maps the Go types and YAML tags the yaml package prints to words a user knows.
|
||||||
|
var yamlTypeNames = map[string]string{
|
||||||
|
"!!str": "text",
|
||||||
|
"!!int": "a number",
|
||||||
|
"!!float": "a number",
|
||||||
|
"!!bool": "true or false",
|
||||||
|
"!!seq": "a list",
|
||||||
|
"!!map": "a mapping",
|
||||||
|
"!!null": "an empty value",
|
||||||
|
"!!timestamp": "a date",
|
||||||
|
"!!binary": "binary data",
|
||||||
|
"string": "text",
|
||||||
|
"bool": "true or false",
|
||||||
|
"uint": "a whole number of 0 or more",
|
||||||
|
"uint64": "a whole number of 0 or more",
|
||||||
|
"time.Duration": "a duration such as 10s",
|
||||||
|
configYAMLTypeName: "a mapping of config keys",
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlNodeName names what a node holds, so an error can say "a list" instead of a kind number.
|
||||||
|
// A tag that contradicts the shape is kept, since the shape alone would read as a contradiction.
|
||||||
|
func yamlNodeName(node *yaml.Node) string {
|
||||||
|
if name, ok := yamlTypeNames[node.Tag]; ok && yamlTagKind(node.Tag) == node.Kind {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if node.Tag == "" {
|
||||||
|
return yamlKindName(node.Kind)
|
||||||
|
}
|
||||||
|
return yamlKindName(node.Kind) + " tagged " + node.Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlTagKind returns the node shape a tag belongs on, so a mismatch can be spotted.
|
||||||
|
func yamlTagKind(tag string) yaml.Kind {
|
||||||
|
switch tag {
|
||||||
|
case "!!seq":
|
||||||
|
return yaml.SequenceNode
|
||||||
|
case "!!map":
|
||||||
|
return yaml.MappingNode
|
||||||
|
default:
|
||||||
|
return yaml.ScalarNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlKindName names a node by its shape, for when its tag says nothing a user would know.
|
||||||
|
func yamlKindName(kind yaml.Kind) string {
|
||||||
|
switch kind {
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
return "a list"
|
||||||
|
case yaml.MappingNode:
|
||||||
|
return "a mapping"
|
||||||
|
case yaml.AliasNode:
|
||||||
|
return "an alias"
|
||||||
|
default:
|
||||||
|
return "a value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlErrorMessage turns "field x not found in type config.configYAML" into "unknown key "x"".
|
||||||
|
func yamlErrorMessage(message string) string {
|
||||||
|
if field, ok := strings.CutSuffix(message, " not found in type "+configYAMLTypeName); ok {
|
||||||
|
if name, ok := strings.CutPrefix(field, "field "); ok {
|
||||||
|
return fmt.Sprintf("unknown key %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user controls the value, so the rightmost separator is the only one that is ours.
|
||||||
|
// After "as a" comes the tag the user wrote, after "into" comes the type the key accepts.
|
||||||
|
if names, ok := strings.CutPrefix(message, "cannot construct "); ok {
|
||||||
|
at, width, tagged := -1, 0, false
|
||||||
|
for index, separator := range []string{" into ", " as a "} {
|
||||||
|
if found := strings.LastIndex(names, separator); found > at {
|
||||||
|
at, width, tagged = found, len(separator), index == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at >= 0 {
|
||||||
|
got, want := yamlGotName(names[:at]), yamlWantedName(names[at+width:])
|
||||||
|
if tagged {
|
||||||
|
return fmt.Sprintf("%s is not %s", got, want)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("expected %s, but got %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.ReplaceAll(message, configYAMLTypeName, "the config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlWantedName names what a key accepts, which the yaml package prints as a Go type.
|
||||||
|
func yamlWantedName(text string) string {
|
||||||
|
if name, ok := yamlTypeNames[text]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlGotName names what the file held, which the yaml package prints as a tag and sometimes a value.
|
||||||
|
// A tag the user would not know stays in the text, since without it the message reads as nonsense.
|
||||||
|
func yamlGotName(text string) string {
|
||||||
|
// A collection tag carries no backticks, so a scalar wearing one arrives glued to it.
|
||||||
|
for _, tag := range []string{"!!seq", "!!map"} {
|
||||||
|
if value, ok := strings.CutPrefix(text, tag); ok && value != "" {
|
||||||
|
return yamlValueText(value) + " tagged " + tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, value, _ := strings.Cut(text, " ")
|
||||||
|
value = strings.TrimSuffix(strings.TrimPrefix(value, "`"), "`")
|
||||||
|
|
||||||
|
name, known := yamlTypeNames[tag]
|
||||||
|
switch {
|
||||||
|
case known && value != "":
|
||||||
|
return yamlValueText(value)
|
||||||
|
case known:
|
||||||
|
return name
|
||||||
|
case value != "":
|
||||||
|
return yamlValueText(value) + " tagged " + tag
|
||||||
|
default:
|
||||||
|
return "a value tagged " + tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlValueText quotes a value, saying so when the yaml package shortened it to seven characters.
|
||||||
|
func yamlValueText(value string) string {
|
||||||
|
if start, ok := strings.CutSuffix(value, "..."); ok && len(start) == 7 {
|
||||||
|
return "a value starting with " + strconv.Quote(start)
|
||||||
|
}
|
||||||
|
return strconv.Quote(value)
|
||||||
|
}
|
||||||
@@ -193,7 +193,7 @@ func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.F
|
|||||||
|
|
||||||
func ValidateTemplates(config *Config) []types.FieldValidationError {
|
func ValidateTemplates(config *Config) []types.FieldValidationError {
|
||||||
// Create template function map using the same functions as sarin package
|
// Create template function map using the same functions as sarin package
|
||||||
// Use nil for fileCache during validation - templates are only parsed, not executed
|
// fileCache is nil because templates are only parsed here, not executed
|
||||||
randSource := sarin.NewDefaultRandSource()
|
randSource := sarin.NewDefaultRandSource()
|
||||||
funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
|
funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
|
||||||
|
|
||||||
|
|||||||
+123
-98
@@ -5,27 +5,19 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"math"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
utilsSlice "go.aykhans.me/utils/slice"
|
utilsSlice "go.aykhans.me/utils/slice"
|
||||||
"golang.org/x/net/proxy"
|
"golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HostClientGenerator func() *fasthttp.HostClient
|
// clientIndexGenerator returns the index of the host client (and proxy) to use next.
|
||||||
|
type clientIndexGenerator func() int
|
||||||
func safeUintToInt(u uint) int {
|
|
||||||
if u > math.MaxInt {
|
|
||||||
return math.MaxInt
|
|
||||||
}
|
|
||||||
return int(u)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHostClients creates a list of fasthttp.HostClient instances for the given proxies.
|
// NewHostClients creates a list of fasthttp.HostClient instances for the given proxies.
|
||||||
// If no proxies are provided, a single client without a proxy is returned.
|
// If no proxies are provided, a single client without a proxy is returned.
|
||||||
@@ -38,6 +30,7 @@ func NewHostClients(
|
|||||||
maxConns uint,
|
maxConns uint,
|
||||||
requestURL *url.URL,
|
requestURL *url.URL,
|
||||||
skipVerify bool,
|
skipVerify bool,
|
||||||
|
maxResponseBody uint64,
|
||||||
) ([]*fasthttp.HostClient, error) {
|
) ([]*fasthttp.HostClient, error) {
|
||||||
isTLS := requestURL.Scheme == "https"
|
isTLS := requestURL.Scheme == "https"
|
||||||
|
|
||||||
@@ -66,6 +59,9 @@ func NewHostClients(
|
|||||||
MaxConnDuration: timeout,
|
MaxConnDuration: timeout,
|
||||||
WriteTimeout: timeout,
|
WriteTimeout: timeout,
|
||||||
ReadTimeout: timeout,
|
ReadTimeout: timeout,
|
||||||
|
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||||
|
MaxIdemponentCallAttempts: 1,
|
||||||
|
ReadBufferSize: 64 << 10, // 64 KiB
|
||||||
DisableHeaderNamesNormalizing: true,
|
DisableHeaderNamesNormalizing: true,
|
||||||
DisablePathNormalizing: true,
|
DisablePathNormalizing: true,
|
||||||
NoDefaultUserAgentHeader: true,
|
NoDefaultUserAgentHeader: true,
|
||||||
@@ -77,8 +73,9 @@ func NewHostClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := &fasthttp.HostClient{
|
client := &fasthttp.HostClient{
|
||||||
MaxConns: safeUintToInt(maxConns),
|
MaxConns: safeUintToInt(maxConns),
|
||||||
IsTLS: isTLS,
|
IsTLS: isTLS,
|
||||||
|
DialDualStack: true,
|
||||||
TLSConfig: &tls.Config{
|
TLSConfig: &tls.Config{
|
||||||
InsecureSkipVerify: skipVerify, //nolint:gosec
|
InsecureSkipVerify: skipVerify, //nolint:gosec
|
||||||
},
|
},
|
||||||
@@ -87,6 +84,9 @@ func NewHostClients(
|
|||||||
MaxConnDuration: timeout,
|
MaxConnDuration: timeout,
|
||||||
WriteTimeout: timeout,
|
WriteTimeout: timeout,
|
||||||
ReadTimeout: timeout,
|
ReadTimeout: timeout,
|
||||||
|
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||||
|
MaxIdemponentCallAttempts: 1,
|
||||||
|
ReadBufferSize: 64 << 10, // 64 KiB
|
||||||
DisableHeaderNamesNormalizing: true,
|
DisableHeaderNamesNormalizing: true,
|
||||||
DisablePathNormalizing: true,
|
DisablePathNormalizing: true,
|
||||||
NoDefaultUserAgentHeader: true,
|
NoDefaultUserAgentHeader: true,
|
||||||
@@ -94,40 +94,45 @@ func NewHostClients(
|
|||||||
return []*fasthttp.HostClient{client}, nil
|
return []*fasthttp.HostClient{client}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProxyDialFunc creates a dial function for the given proxy URL.
|
// NewProxyDialFunc creates a dial function for the given proxy URL with a fixed timeout.
|
||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ProxyUnsupportedSchemeError
|
// - types.ProxyUnsupportedSchemeError
|
||||||
|
// - types.ErrProxyNoContextDialer
|
||||||
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
||||||
var (
|
dial, err := NewProxyDialFuncWithTimeout(ctx, proxyURL)
|
||||||
dialer fasthttp.DialFunc
|
if err != nil {
|
||||||
err error
|
return nil, err
|
||||||
)
|
}
|
||||||
|
|
||||||
|
return func(addr string) (net.Conn, error) {
|
||||||
|
return dial(addr, timeout)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProxyDialFuncWithTimeout creates a dial function for the given proxy URL that takes the timeout per call.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ProxyUnsupportedSchemeError
|
||||||
|
// - types.ErrProxyNoContextDialer
|
||||||
|
func NewProxyDialFuncWithTimeout(ctx context.Context, proxyURL *url.URL) (fasthttp.DialFuncWithTimeout, error) {
|
||||||
switch proxyURL.Scheme {
|
switch proxyURL.Scheme {
|
||||||
case "socks5":
|
case "socks5":
|
||||||
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, true)
|
return fasthttpSocksDialer(ctx, proxyURL, true)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
case "socks5h":
|
case "socks5h":
|
||||||
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, false)
|
return fasthttpSocksDialer(ctx, proxyURL, false)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
case "http":
|
case "http":
|
||||||
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxyURL.String(), timeout)
|
return fasthttpConnectDialer(proxyURL, false), nil
|
||||||
case "https":
|
case "https":
|
||||||
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
|
return fasthttpConnectDialer(proxyURL, true), nil
|
||||||
default:
|
default:
|
||||||
return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme)
|
return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
return dialer, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The returned dial function can return the following errors:
|
// fasthttpSocksDialer creates a SOCKS5 dial function that takes the timeout per call.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrProxyNoContextDialer
|
||||||
// - types.ProxyDialError
|
// - types.ProxyDialError
|
||||||
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
|
func fasthttpSocksDialer(ctx context.Context, proxyURL *url.URL, resolveLocally bool) (fasthttp.DialFuncWithTimeout, error) {
|
||||||
netDialer := &net.Dialer{}
|
netDialer := &net.Dialer{}
|
||||||
|
|
||||||
// Parse auth from proxy URL if present
|
// Parse auth from proxy URL if present
|
||||||
@@ -149,66 +154,92 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
|
|
||||||
proxyStr := proxyURL.String()
|
proxyStr := proxyURL.String()
|
||||||
|
|
||||||
// Assert to ContextDialer for timeout support
|
// Timeouts need DialContext, which proxy.SOCKS5 always provides with a net.Dialer.
|
||||||
contextDialer, ok := socksDialer.(proxy.ContextDialer)
|
contextDialer, ok := socksDialer.(proxy.ContextDialer)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Fallback without timeout (should not happen with net.Dialer)
|
return nil, types.ErrProxyNoContextDialer
|
||||||
return func(addr string) (net.Conn, error) {
|
|
||||||
conn, err := socksDialer.Dial("tcp", addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
|
||||||
}
|
|
||||||
return conn, nil
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return dial function that uses context with timeout
|
// Return dial function that uses context with timeout
|
||||||
return func(addr string) (net.Conn, error) {
|
return func(addr string, timeout time.Duration) (net.Conn, error) {
|
||||||
deadline := time.Now().Add(timeout)
|
deadline := time.Now().Add(timeout)
|
||||||
|
|
||||||
|
addrs := []string{addr}
|
||||||
if resolveLocally {
|
if resolveLocally {
|
||||||
host, port, err := net.SplitHostPort(addr)
|
var err error
|
||||||
|
addrs, err = resolveIPv4First(ctx, addr, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
|
// Try every resolved address, so one unreachable family does not fail the dial.
|
||||||
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
var lastErr error
|
||||||
dnsCancel()
|
for _, candidate := range addrs {
|
||||||
if err != nil {
|
remaining := time.Until(deadline)
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
if remaining <= 0 {
|
||||||
}
|
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
||||||
if len(ips) == 0 {
|
|
||||||
return nil, types.NewProxyDialError(proxyStr, types.NewProxyResolveError(host))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the first resolved IP
|
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
||||||
addr = net.JoinHostPort(ips[0].String(), port)
|
conn, err := contextDialer.DialContext(dialCtx, "tcp", candidate)
|
||||||
|
dialCancel()
|
||||||
|
if err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
}
|
}
|
||||||
|
return nil, types.NewProxyDialError(proxyStr, lastErr)
|
||||||
// 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
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveIPv4First resolves the host of addr into candidate addresses, IPv4 ones first.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ProxyResolveError
|
||||||
|
func resolveIPv4First(ctx context.Context, addr string, timeout time.Duration) ([]string, error) {
|
||||||
|
host, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
|
||||||
|
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
||||||
|
dnsCancel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return nil, types.NewProxyResolveError(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
addrs := make([]string, 0, len(ips))
|
||||||
|
for _, ip := range ips {
|
||||||
|
if ip.To4() != nil {
|
||||||
|
addrs = append(addrs, net.JoinHostPort(ip.String(), port))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
if ip.To4() == nil {
|
||||||
|
addrs = append(addrs, net.JoinHostPort(ip.String(), port))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addrs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fasthttpConnectDialer creates a dial function that tunnels through an HTTP proxy with CONNECT,
|
||||||
|
// reaching the proxy over TLS when useTLS is set.
|
||||||
|
//
|
||||||
// The returned dial function can return the following errors:
|
// The returned dial function can return the following errors:
|
||||||
// - types.ProxyDialError
|
// - types.ProxyDialError
|
||||||
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
|
func fasthttpConnectDialer(proxyURL *url.URL, useTLS bool) fasthttp.DialFuncWithTimeout {
|
||||||
|
defaultPort := "80"
|
||||||
|
if useTLS {
|
||||||
|
defaultPort = "443"
|
||||||
|
}
|
||||||
|
|
||||||
proxyAddr := proxyURL.Host
|
proxyAddr := proxyURL.Host
|
||||||
if proxyURL.Port() == "" {
|
if proxyURL.Port() == "" {
|
||||||
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), "443")
|
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), defaultPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build Proxy-Authorization header if auth is present
|
// Build Proxy-Authorization header if auth is present
|
||||||
@@ -222,7 +253,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
|
|
||||||
proxyStr := proxyURL.String()
|
proxyStr := proxyURL.String()
|
||||||
|
|
||||||
return func(addr string) (net.Conn, error) {
|
return func(addr string, timeout time.Duration) (net.Conn, error) {
|
||||||
// Establish TCP connection to proxy with timeout
|
// Establish TCP connection to proxy with timeout
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
||||||
@@ -242,13 +273,15 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade to TLS
|
if useTLS {
|
||||||
tlsConn := tls.Client(conn, &tls.Config{
|
tlsConn := tls.Client(conn, &tls.Config{
|
||||||
ServerName: proxyURL.Hostname(),
|
ServerName: proxyURL.Hostname(),
|
||||||
})
|
})
|
||||||
if err := tlsConn.Handshake(); err != nil {
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
|
}
|
||||||
|
conn = tlsConn
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and send CONNECT request
|
// Build and send CONNECT request
|
||||||
@@ -262,35 +295,35 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
connectReq.Header.Set("Proxy-Authorization", proxyAuth)
|
connectReq.Header.Set("Proxy-Authorization", proxyAuth)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := connectReq.Write(tlsConn); err != nil {
|
if err := connectReq.Write(conn); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read response using buffered reader, but return wrapped connection
|
// Read response using buffered reader, but return wrapped connection
|
||||||
// to preserve any buffered data
|
// to preserve any buffered data
|
||||||
bufReader := bufio.NewReader(tlsConn)
|
bufReader := bufio.NewReader(conn)
|
||||||
resp, err := http.ReadResponse(bufReader, connectReq)
|
resp, err := http.ReadResponse(bufReader, connectReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
resp.Body.Close() //nolint:errcheck,gosec
|
resp.Body.Close() //nolint:errcheck,gosec
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
|
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear deadline for the tunneled connection
|
// Clear deadline for the tunneled connection
|
||||||
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
|
if err := conn.SetDeadline(time.Time{}); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return wrapped connection that uses the buffered reader
|
// Return wrapped connection that uses the buffered reader
|
||||||
// to avoid losing any data that was read ahead
|
// to avoid losing any data that was read ahead
|
||||||
return &bufferedConn{Conn: tlsConn, reader: bufReader}, nil
|
return &bufferedConn{Conn: conn, reader: bufReader}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,18 +339,10 @@ func (c *bufferedConn) Read(b []byte) (int, error) {
|
|||||||
return c.reader.Read(b)
|
return c.reader.Read(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHostClientGenerator(clients ...*fasthttp.HostClient) HostClientGenerator {
|
func newClientIndexGenerator(count int) clientIndexGenerator {
|
||||||
switch len(clients) {
|
indexes := make([]int, count)
|
||||||
case 0:
|
for i := range indexes {
|
||||||
hostClient := &fasthttp.HostClient{}
|
indexes[i] = i
|
||||||
return func() *fasthttp.HostClient {
|
|
||||||
return hostClient
|
|
||||||
}
|
|
||||||
case 1:
|
|
||||||
return func() *fasthttp.HostClient {
|
|
||||||
return clients[0]
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return utilsSlice.RandomCycle(nil, clients...)
|
|
||||||
}
|
}
|
||||||
|
return utilsSlice.RandomCycle(nil, indexes...)
|
||||||
}
|
}
|
||||||
@@ -58,8 +58,7 @@ func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
|
|||||||
|
|
||||||
file := &CachedFile{Content: content, Filename: filename}
|
file := &CachedFile{Content: content, Filename: filename}
|
||||||
|
|
||||||
// LoadOrStore handles race condition - if another goroutine
|
// If another goroutine cached it first, LoadOrStore returns theirs
|
||||||
// cached it first, we get theirs (no duplicate storage)
|
|
||||||
actual, _ := fc.cache.LoadOrStore(source, file)
|
actual, _ := fc.cache.LoadOrStore(source, file)
|
||||||
return actual.(*CachedFile), nil
|
return actual.(*CachedFile), nil
|
||||||
}
|
}
|
||||||
@@ -94,10 +93,18 @@ func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
|
|||||||
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := io.ReadAll(resp.Body)
|
// A declared size above the limit is refused before anything is read.
|
||||||
|
if resp.ContentLength > types.RemoteFetchLimit {
|
||||||
|
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", types.NewHTTPFetchError(url, err)
|
return nil, "", types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
if len(content) > types.RemoteFetchLimit {
|
||||||
|
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
// Extract filename from URL path
|
// Extract filename from URL path
|
||||||
filename := path.Base(url)
|
filename := path.Base(url)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sarin
|
package sarin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -19,3 +20,17 @@ func firstOrEmpty(values []string) string {
|
|||||||
}
|
}
|
||||||
return values[0]
|
return values[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func safeUintToInt(u uint) int {
|
||||||
|
if u > math.MaxInt {
|
||||||
|
return math.MaxInt
|
||||||
|
}
|
||||||
|
return int(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeUint64ToInt(u uint64) int {
|
||||||
|
if u > math.MaxInt {
|
||||||
|
return math.MaxInt
|
||||||
|
}
|
||||||
|
return int(u)
|
||||||
|
}
|
||||||
+17
-12
@@ -191,18 +191,7 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(reqData.Cookies) > 0 {
|
if len(reqData.Cookies) > 0 {
|
||||||
var sb strings.Builder
|
req.Header.Add("Cookie", cookieHeaderValue(reqData.Cookies))
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if scheme == "https" {
|
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) {
|
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||||
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot)
|
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import (
|
|||||||
const DefaultResponseDurationAccuracy uint32 = 1
|
const DefaultResponseDurationAccuracy uint32 = 1
|
||||||
const DefaultResponseColumnMaxWidth = 50
|
const DefaultResponseColumnMaxWidth = 50
|
||||||
|
|
||||||
|
// maxResponseKeys bounds the distinct response keys, so error messages carrying
|
||||||
|
// per-request text cannot grow the map without limit. It stays above the number of
|
||||||
|
// HTTP status codes, so real ones are never merged.
|
||||||
|
const maxResponseKeys = 1000
|
||||||
|
|
||||||
|
// otherResponseKey collects everything that arrives after the key limit is reached.
|
||||||
|
const otherResponseKey = "other"
|
||||||
|
|
||||||
// Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings.
|
// Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings.
|
||||||
type Duration time.Duration
|
type Duration time.Duration
|
||||||
|
|
||||||
@@ -95,6 +103,11 @@ func (data *SarinResponseData) Add(responseKey string, responseTime time.Duratio
|
|||||||
defer data.Unlock()
|
defer data.Unlock()
|
||||||
|
|
||||||
response, ok := data.Responses[responseKey]
|
response, ok := data.Responses[responseKey]
|
||||||
|
if !ok && len(data.Responses) >= maxResponseKeys {
|
||||||
|
responseKey = otherResponseKey
|
||||||
|
response, ok = data.Responses[responseKey]
|
||||||
|
}
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
data.Responses[responseKey] = &Response{
|
data.Responses[responseKey] = &Response{
|
||||||
durations: map[time.Duration]uint64{
|
durations: map[time.Duration]uint64{
|
||||||
|
|||||||
+64
-68
@@ -199,10 +199,15 @@ type sarin struct {
|
|||||||
logError bool
|
logError bool
|
||||||
logFile string
|
logFile string
|
||||||
|
|
||||||
hostClients []*fasthttp.HostClient
|
// jobsCtx ends the run, either when the duration expires or on Ctrl+C.
|
||||||
responses *SarinResponseData
|
jobsCtx context.Context //nolint:containedctx
|
||||||
fileCache *FileCache
|
jobsCancel context.CancelFunc
|
||||||
scriptChain *script.Chain
|
|
||||||
|
hostClients []*fasthttp.HostClient
|
||||||
|
scriptHTTPClients []*scriptHTTPClient
|
||||||
|
responses *SarinResponseData
|
||||||
|
fileCache *FileCache
|
||||||
|
scriptChain *script.Chain
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSarin creates a new sarin instance for load testing.
|
// NewSarin creates a new sarin instance for load testing.
|
||||||
@@ -220,6 +225,7 @@ func NewSarin(
|
|||||||
totalDuration *time.Duration,
|
totalDuration *time.Duration,
|
||||||
showProgress bool,
|
showProgress bool,
|
||||||
skipCertVerify bool,
|
skipCertVerify bool,
|
||||||
|
maxResponseBody uint64,
|
||||||
params types.Params,
|
params types.Params,
|
||||||
headers types.Headers,
|
headers types.Headers,
|
||||||
cookies types.Cookies,
|
cookies types.Cookies,
|
||||||
@@ -248,7 +254,12 @@ func NewSarin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
|
proxyURLs := make([]url.URL, len(proxies))
|
||||||
|
for i, proxy := range proxies {
|
||||||
|
proxyURLs[i] = url.URL(proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostClients, err := NewHostClients(ctx, timeout, proxyURLs, workers, requestURL, skipCertVerify, maxResponseBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -266,28 +277,42 @@ func NewSarin(
|
|||||||
|
|
||||||
scriptChain := script.NewChain(luaSources, jsSources)
|
scriptChain := script.NewChain(luaSources, jsSources)
|
||||||
|
|
||||||
|
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
var scriptHTTPClients []*scriptHTTPClient
|
||||||
|
if !scriptChain.IsEmpty() {
|
||||||
|
scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers, maxResponseBody)
|
||||||
|
if err != nil {
|
||||||
|
jobsCancel()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
srn := &sarin{
|
srn := &sarin{
|
||||||
workers: workers,
|
jobsCtx: jobsCtx,
|
||||||
requestURL: requestURL,
|
jobsCancel: jobsCancel,
|
||||||
methods: methods,
|
workers: workers,
|
||||||
params: params,
|
requestURL: requestURL,
|
||||||
headers: headers,
|
methods: methods,
|
||||||
cookies: cookies,
|
params: params,
|
||||||
bodies: bodies,
|
headers: headers,
|
||||||
totalRequests: totalRequests,
|
cookies: cookies,
|
||||||
totalDuration: totalDuration,
|
bodies: bodies,
|
||||||
timeout: timeout,
|
totalRequests: totalRequests,
|
||||||
showProgress: showProgress,
|
totalDuration: totalDuration,
|
||||||
skipCertVerify: skipCertVerify,
|
timeout: timeout,
|
||||||
values: values,
|
showProgress: showProgress,
|
||||||
collectStats: collectStats,
|
skipCertVerify: skipCertVerify,
|
||||||
dryRun: dryRun,
|
values: values,
|
||||||
logInfo: logInfo,
|
collectStats: collectStats,
|
||||||
logError: logError,
|
dryRun: dryRun,
|
||||||
logFile: logFile,
|
logInfo: logInfo,
|
||||||
hostClients: hostClients,
|
logError: logError,
|
||||||
fileCache: NewFileCache(time.Second * 10),
|
logFile: logFile,
|
||||||
scriptChain: scriptChain,
|
hostClients: hostClients,
|
||||||
|
scriptHTTPClients: scriptHTTPClients,
|
||||||
|
fileCache: NewFileCache(time.Second * 30),
|
||||||
|
scriptChain: scriptChain,
|
||||||
}
|
}
|
||||||
|
|
||||||
if collectStats {
|
if collectStats {
|
||||||
@@ -301,9 +326,7 @@ func (s sarin) GetResponses() *SarinResponseData {
|
|||||||
return s.responses
|
return s.responses
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
func (s sarin) Start(stopCtrl *StopController) {
|
||||||
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
|
||||||
|
|
||||||
var workersWG sync.WaitGroup
|
var workersWG sync.WaitGroup
|
||||||
jobsCh := make(chan struct{}, max(s.workers, 1))
|
jobsCh := make(chan struct{}, max(s.workers, 1))
|
||||||
|
|
||||||
@@ -364,18 +387,17 @@ func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start workers
|
// Start workers
|
||||||
s.startWorkers(&workersWG, jobsCh, s.hostClients, &counter, sendLog, sendRespLog)
|
s.startWorkers(&workersWG, jobsCh, &counter, sendLog, sendRespLog)
|
||||||
|
|
||||||
if runTUI {
|
if runTUI {
|
||||||
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
|
|
||||||
go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar)
|
go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup duration-based cancellation
|
// Setup duration-based cancellation
|
||||||
s.setupDurationTimeout(ctx, jobsCancel)
|
s.setupDurationTimeout()
|
||||||
// Distribute jobs to workers.
|
// Distribute jobs to workers.
|
||||||
// This blocks until all jobs are sent or the context is canceled.
|
// This blocks until all jobs are sent or the context is canceled.
|
||||||
s.sendJobs(jobsCtx, jobsCh)
|
s.sendJobs(jobsCh)
|
||||||
|
|
||||||
// Close the jobs channel so workers stop after completing their current job
|
// Close the jobs channel so workers stop after completing their current job
|
||||||
close(jobsCh)
|
close(jobsCh)
|
||||||
@@ -394,7 +416,7 @@ func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newWriterLog builds the loggers that write formatted lines to w (a log file or
|
// newWriterLog builds the loggers that write formatted lines to w (a log file or
|
||||||
// stderr). sendLog stays general (it filters by each log's level); sendRespLog
|
// stderr). sendLog stays general (it filters by each log's level), while sendRespLog
|
||||||
// only ever emits info, so its decision is baked once into a no-op when off.
|
// only ever emits info, so its decision is baked once into a no-op when off.
|
||||||
func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) {
|
func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) {
|
||||||
// log.Logger serializes writes with its own mutex, so concurrent workers
|
// log.Logger serializes writes with its own mutex, so concurrent workers
|
||||||
@@ -432,65 +454,39 @@ func (s sarin) newChannelLog(ch chan<- runtimeLog) (runtimeLogger, respLogger) {
|
|||||||
return sendLog, sendRespLog
|
return sendLog, sendRespLog
|
||||||
}
|
}
|
||||||
|
|
||||||
// newHostClients initializes HTTP clients for the given configuration.
|
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
|
||||||
// It can return the following errors:
|
|
||||||
// - types.ProxyDialError
|
|
||||||
func newHostClients(
|
|
||||||
ctx context.Context,
|
|
||||||
timeout time.Duration,
|
|
||||||
proxies types.Proxies,
|
|
||||||
workers uint,
|
|
||||||
requestURL *url.URL,
|
|
||||||
skipCertVerify bool,
|
|
||||||
) ([]*fasthttp.HostClient, error) {
|
|
||||||
proxiesRaw := make([]url.URL, len(proxies))
|
|
||||||
for i, proxy := range proxies {
|
|
||||||
proxiesRaw[i] = url.URL(proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NewHostClients(
|
|
||||||
ctx,
|
|
||||||
timeout,
|
|
||||||
proxiesRaw,
|
|
||||||
workers,
|
|
||||||
requestURL,
|
|
||||||
skipCertVerify,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
|
|
||||||
for range max(s.workers, 1) {
|
for range max(s.workers, 1) {
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
s.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendLog, sendRespLog)
|
s.Worker(jobs, counter, sendLog, sendRespLog)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
|
func (s sarin) setupDurationTimeout() {
|
||||||
if s.totalDuration != nil {
|
if s.totalDuration != nil {
|
||||||
go func() {
|
go func() {
|
||||||
timer := time.NewTimer(*s.totalDuration)
|
timer := time.NewTimer(*s.totalDuration)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
select {
|
select {
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
cancel()
|
s.jobsCancel()
|
||||||
case <-ctx.Done():
|
case <-s.jobsCtx.Done():
|
||||||
// Context cancelled, cleanup
|
// Context cancelled, cleanup
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
|
func (s sarin) sendJobs(jobs chan<- struct{}) {
|
||||||
if s.totalRequests != nil && *s.totalRequests > 0 {
|
if s.totalRequests != nil && *s.totalRequests > 0 {
|
||||||
for range *s.totalRequests {
|
for range *s.totalRequests {
|
||||||
if ctx.Err() != nil {
|
if s.jobsCtx.Err() != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
jobs <- struct{}{}
|
jobs <- struct{}{}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for ctx.Err() == nil {
|
for s.jobsCtx.Err() == nil {
|
||||||
jobs <- struct{}{}
|
jobs <- struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
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),
|
||||||
|
MaxIdemponentCallAttempts: 1,
|
||||||
|
ReadBufferSize: 64 << 10, // 64 KiB
|
||||||
|
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.ErrResponseBodyTooLarge
|
||||||
|
}
|
||||||
|
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
|
return string(data), nil
|
||||||
},
|
},
|
||||||
// json_Object builds a JSON object from interleaved key-value pairs and returns it
|
// json_Object builds a JSON object from interleaved key-value pairs and returns it
|
||||||
// as a JSON string. Keys must be strings; values may be any JSON-encodable type.
|
// as a JSON string. Keys must be strings, values may be any JSON-encodable type.
|
||||||
// Usage: {{ json_Object "name" "Alice" "age" 30 }}
|
// Usage: {{ json_Object "name" "Alice" "age" 30 }}
|
||||||
"json_Object": func(pairs ...any) (string, error) {
|
"json_Object": func(pairs ...any) (string, error) {
|
||||||
if len(pairs)%2 != 0 {
|
if len(pairs)%2 != 0 {
|
||||||
@@ -751,7 +751,7 @@ func NewDefaultBodyTemplateFuncMap(
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(val, "@@"):
|
case strings.HasPrefix(val, "@@"):
|
||||||
// Escaped @ - send as literal string without first @
|
// Escaped @: send the string without the first @
|
||||||
if err := writer.WriteField(key, val[1:]); err != nil {
|
if err := writer.WriteField(key, val[1:]); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-24
@@ -1,12 +1,16 @@
|
|||||||
package sarin
|
package sarin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"go.aykhans.me/sarin/internal/script"
|
"go.aykhans.me/sarin/internal/script"
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const dryRunResponseKey = "dry-run"
|
const dryRunResponseKey = "dry-run"
|
||||||
@@ -29,9 +33,36 @@ func statusCodeToString(code int) string {
|
|||||||
return strconv.Itoa(code)
|
return strconv.Itoa(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// responseErrorKey drops the local address, whose port would mint a stats entry per connection.
|
||||||
|
func responseErrorKey(err error) string {
|
||||||
|
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
|
||||||
|
return types.ErrResponseBodyTooLarge.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
message := err.Error()
|
||||||
|
|
||||||
|
var opErr *net.OpError
|
||||||
|
if !errors.As(err, &opErr) || opErr.Source == nil {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutSource := *opErr
|
||||||
|
withoutSource.Source = nil
|
||||||
|
return strings.Replace(message, opErr.Error(), withoutSource.Error(), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestErrorKey keys a generation failure by its source instead of its message.
|
||||||
|
// A script picks its own URLs and error texts, so the message would mint a new
|
||||||
|
// stats entry per request. The full message still goes to the runtime log.
|
||||||
|
func requestErrorKey(err error) string {
|
||||||
|
if chainErr, ok := errors.AsType[types.ScriptChainError](err); ok {
|
||||||
|
return chainErr.EngineType + " script[" + strconv.Itoa(chainErr.Index) + "] error"
|
||||||
|
}
|
||||||
|
return "request generation error"
|
||||||
|
}
|
||||||
|
|
||||||
func (s sarin) Worker(
|
func (s sarin) Worker(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
hostClientGenerator HostClientGenerator,
|
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendLog runtimeLogger,
|
sendLog runtimeLogger,
|
||||||
sendRespLog respLogger,
|
sendRespLog respLogger,
|
||||||
@@ -53,6 +84,9 @@ func (s sarin) Worker(
|
|||||||
defer scriptTransformer.Close()
|
defer scriptTransformer.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextClientIndex := newClientIndexGenerator(len(s.hostClients))
|
||||||
|
bindScriptProxy := s.newScriptProxyBinder(scriptTransformer)
|
||||||
|
|
||||||
requestGenerator, isDynamic := NewRequestGenerator(
|
requestGenerator, isDynamic := NewRequestGenerator(
|
||||||
s.methods, s.requestURL, s.params, s.headers, s.cookies, s.bodies, s.values, s.fileCache, scriptTransformer,
|
s.methods, s.requestURL, s.params, s.headers, s.cookies, s.bodies, s.values, s.fileCache, scriptTransformer,
|
||||||
)
|
)
|
||||||
@@ -60,34 +94,45 @@ func (s sarin) Worker(
|
|||||||
if s.dryRun {
|
if s.dryRun {
|
||||||
switch {
|
switch {
|
||||||
case s.collectStats && isDynamic:
|
case s.collectStats && isDynamic:
|
||||||
s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
|
s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
|
||||||
case s.collectStats && !isDynamic:
|
case s.collectStats && !isDynamic:
|
||||||
s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
||||||
case !s.collectStats && isDynamic:
|
case !s.collectStats && isDynamic:
|
||||||
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
|
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
|
||||||
default:
|
default:
|
||||||
s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
switch {
|
switch {
|
||||||
case s.collectStats && isDynamic:
|
case s.collectStats && isDynamic:
|
||||||
s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog, sendRespLog)
|
||||||
case s.collectStats && !isDynamic:
|
case s.collectStats && !isDynamic:
|
||||||
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
|
||||||
case !s.collectStats && isDynamic:
|
case !s.collectStats && isDynamic:
|
||||||
s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog, sendRespLog)
|
||||||
default:
|
default:
|
||||||
s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newScriptProxyBinder returns a func that sends the scripts' http.* calls through proxy i.
|
||||||
|
func (s sarin) newScriptProxyBinder(scriptTransformer *script.Transformer) func(i int) {
|
||||||
|
if scriptTransformer == nil || scriptTransformer.IsEmpty() {
|
||||||
|
return func(int) {}
|
||||||
|
}
|
||||||
|
return func(i int) {
|
||||||
|
scriptTransformer.SetHTTPDoer(s.scriptHTTPClients[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s sarin) workerStatsWithDynamic(
|
func (s sarin) workerStatsWithDynamic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
resp *fasthttp.Response,
|
resp *fasthttp.Response,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
hostClientGenerator HostClientGenerator,
|
nextClientIndex clientIndexGenerator,
|
||||||
|
bindScriptProxy func(int),
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendLog runtimeLogger,
|
sendLog runtimeLogger,
|
||||||
sendRespLog respLogger,
|
sendRespLog respLogger,
|
||||||
@@ -95,19 +140,22 @@ func (s sarin) workerStatsWithDynamic(
|
|||||||
for range jobs {
|
for range jobs {
|
||||||
req.Reset()
|
req.Reset()
|
||||||
|
|
||||||
|
// Bound before generation so scripts' http.* calls use the main request's proxy.
|
||||||
|
clientIndex := nextClientIndex()
|
||||||
|
bindScriptProxy(clientIndex)
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
s.responses.Add(err.Error(), 0)
|
s.responses.Add(requestErrorKey(err), 0)
|
||||||
sendLog(runtimeLogLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
|
||||||
respDuration := time.Since(startTime)
|
respDuration := time.Since(startTime)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.responses.Add(err.Error(), respDuration)
|
s.responses.Add(responseErrorKey(err), respDuration)
|
||||||
} else {
|
} else {
|
||||||
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
||||||
sendRespLog(respDuration, resp)
|
sendRespLog(respDuration, resp)
|
||||||
@@ -121,15 +169,16 @@ func (s sarin) workerStatsWithStatic(
|
|||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
resp *fasthttp.Response,
|
resp *fasthttp.Response,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
hostClientGenerator HostClientGenerator,
|
nextClientIndex clientIndexGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendLog runtimeLogger,
|
sendLog runtimeLogger,
|
||||||
sendRespLog respLogger,
|
sendRespLog respLogger,
|
||||||
) {
|
) {
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
// Static request generation failed - record all jobs as errors
|
// Static request generation failed, so record all jobs as errors
|
||||||
|
errorKey := requestErrorKey(err)
|
||||||
for range jobs {
|
for range jobs {
|
||||||
s.responses.Add(err.Error(), 0)
|
s.responses.Add(errorKey, 0)
|
||||||
sendLog(runtimeLogLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
@@ -138,10 +187,10 @@ func (s sarin) workerStatsWithStatic(
|
|||||||
|
|
||||||
for range jobs {
|
for range jobs {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
|
||||||
respDuration := time.Since(startTime)
|
respDuration := time.Since(startTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.responses.Add(err.Error(), respDuration)
|
s.responses.Add(responseErrorKey(err), respDuration)
|
||||||
} else {
|
} else {
|
||||||
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
||||||
sendRespLog(respDuration, resp)
|
sendRespLog(respDuration, resp)
|
||||||
@@ -155,20 +204,24 @@ func (s sarin) workerNoStatsWithDynamic(
|
|||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
resp *fasthttp.Response,
|
resp *fasthttp.Response,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
hostClientGenerator HostClientGenerator,
|
nextClientIndex clientIndexGenerator,
|
||||||
|
bindScriptProxy func(int),
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendLog runtimeLogger,
|
sendLog runtimeLogger,
|
||||||
sendRespLog respLogger,
|
sendRespLog respLogger,
|
||||||
) {
|
) {
|
||||||
for range jobs {
|
for range jobs {
|
||||||
req.Reset()
|
req.Reset()
|
||||||
|
// Bound before generation so scripts' http.* calls use the main request's proxy.
|
||||||
|
clientIndex := nextClientIndex()
|
||||||
|
bindScriptProxy(clientIndex)
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
sendLog(runtimeLogLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
sendRespLog(time.Since(startTime), resp)
|
sendRespLog(time.Since(startTime), resp)
|
||||||
}
|
}
|
||||||
@@ -181,7 +234,7 @@ func (s sarin) workerNoStatsWithStatic(
|
|||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
resp *fasthttp.Response,
|
resp *fasthttp.Response,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
hostClientGenerator HostClientGenerator,
|
nextClientIndex clientIndexGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendLog runtimeLogger,
|
sendLog runtimeLogger,
|
||||||
sendRespLog respLogger,
|
sendRespLog respLogger,
|
||||||
@@ -189,7 +242,7 @@ func (s sarin) workerNoStatsWithStatic(
|
|||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
sendLog(runtimeLogLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
|
|
||||||
// Static request generation failed - just count the jobs without sending
|
// Static request generation failed, so just count the jobs without sending
|
||||||
for range jobs {
|
for range jobs {
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
@@ -198,7 +251,7 @@ func (s sarin) workerNoStatsWithStatic(
|
|||||||
|
|
||||||
for range jobs {
|
for range jobs {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
sendRespLog(time.Since(startTime), resp)
|
sendRespLog(time.Since(startTime), resp)
|
||||||
}
|
}
|
||||||
@@ -210,14 +263,18 @@ func (s sarin) workerDryRunStatsWithDynamic(
|
|||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
|
nextClientIndex clientIndexGenerator,
|
||||||
|
bindScriptProxy func(int),
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendLog runtimeLogger,
|
sendLog runtimeLogger,
|
||||||
) {
|
) {
|
||||||
for range jobs {
|
for range jobs {
|
||||||
req.Reset()
|
req.Reset()
|
||||||
|
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
|
||||||
|
bindScriptProxy(nextClientIndex())
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
s.responses.Add(err.Error(), time.Since(startTime))
|
s.responses.Add(requestErrorKey(err), time.Since(startTime))
|
||||||
sendLog(runtimeLogLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
continue
|
continue
|
||||||
@@ -235,9 +292,10 @@ func (s sarin) workerDryRunStatsWithStatic(
|
|||||||
sendLog runtimeLogger,
|
sendLog runtimeLogger,
|
||||||
) {
|
) {
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
// Static request generation failed - record all jobs as errors
|
// Static request generation failed, so record all jobs as errors
|
||||||
|
errorKey := requestErrorKey(err)
|
||||||
for range jobs {
|
for range jobs {
|
||||||
s.responses.Add(err.Error(), 0)
|
s.responses.Add(errorKey, 0)
|
||||||
sendLog(runtimeLogLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
@@ -254,11 +312,15 @@ func (s sarin) workerDryRunNoStatsWithDynamic(
|
|||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
|
nextClientIndex clientIndexGenerator,
|
||||||
|
bindScriptProxy func(int),
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendLog runtimeLogger,
|
sendLog runtimeLogger,
|
||||||
) {
|
) {
|
||||||
for range jobs {
|
for range jobs {
|
||||||
req.Reset()
|
req.Reset()
|
||||||
|
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
|
||||||
|
bindScriptProxy(nextClientIndex())
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
sendLog(runtimeLogLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ func (t *Transformer) Transform(req *RequestData) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetHTTPDoer sets the doer that the scripts' http.* calls are sent through.
|
||||||
|
func (t *Transformer) SetHTTPDoer(doer HTTPDoer) {
|
||||||
|
for _, engine := range t.luaEngines {
|
||||||
|
engine.SetHTTPDoer(doer)
|
||||||
|
}
|
||||||
|
for _, engine := range t.jsEngines {
|
||||||
|
engine.SetHTTPDoer(doer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close releases all engine resources.
|
// Close releases all engine resources.
|
||||||
func (t *Transformer) Close() {
|
func (t *Transformer) Close() {
|
||||||
for _, engine := range t.luaEngines {
|
for _, engine := range t.luaEngines {
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
@@ -11,6 +12,10 @@ import (
|
|||||||
type JsEngine struct {
|
type JsEngine struct {
|
||||||
runtime *goja.Runtime
|
runtime *goja.Runtime
|
||||||
transform goja.Callable
|
transform goja.Callable
|
||||||
|
jsonParse goja.Callable
|
||||||
|
// errorConstructor is the built-in Error, captured before the script can replace it.
|
||||||
|
errorConstructor goja.Value
|
||||||
|
http httpBridge
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewJsEngine creates a new JavaScript script engine with the given script content.
|
// NewJsEngine creates a new JavaScript script engine with the given script content.
|
||||||
@@ -29,11 +34,17 @@ type JsEngine struct {
|
|||||||
// - types.ScriptExecutionError
|
// - types.ScriptExecutionError
|
||||||
func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
|
engine := &JsEngine{runtime: vm}
|
||||||
|
|
||||||
|
// Register the globals before running the script so its functions can use them
|
||||||
|
if err := engine.registerHTTP(); err != nil {
|
||||||
|
return nil, types.NewScriptExecutionError("JavaScript", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Execute the script to define the transform function
|
// Execute the script to define the transform function
|
||||||
_, err := vm.RunString(scriptContent)
|
_, err := vm.RunString(scriptContent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewScriptExecutionError("JavaScript", err)
|
return nil, types.NewScriptExecutionError("JavaScript", engine.exceptionError(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the transform function
|
// Get the transform function
|
||||||
@@ -47,23 +58,30 @@ func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
|||||||
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
|
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &JsEngine{
|
engine.transform = transform
|
||||||
runtime: vm,
|
|
||||||
transform: transform,
|
return engine, nil
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
|
||||||
|
func (e *JsEngine) SetHTTPDoer(doer HTTPDoer) {
|
||||||
|
e.http.doer = doer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform executes the JavaScript transform function with the given request data.
|
// Transform executes the JavaScript transform function with the given request data.
|
||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ScriptExecutionError
|
// - types.ScriptExecutionError
|
||||||
func (e *JsEngine) Transform(req *RequestData) error {
|
func (e *JsEngine) Transform(req *RequestData) error {
|
||||||
|
e.http.active = true
|
||||||
|
defer func() { e.http.active = false }()
|
||||||
|
|
||||||
// Convert RequestData to JavaScript object
|
// Convert RequestData to JavaScript object
|
||||||
reqObj := e.requestDataToObject(req)
|
reqObj := e.requestDataToObject(req)
|
||||||
|
|
||||||
// Call transform(req)
|
// Call transform(req)
|
||||||
result, err := e.transform(goja.Undefined(), reqObj)
|
result, err := e.transform(goja.Undefined(), reqObj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewScriptExecutionError("JavaScript", err)
|
return types.NewScriptExecutionError("JavaScript", e.exceptionError(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update RequestData from the returned object
|
// Update RequestData from the returned object
|
||||||
@@ -79,6 +97,8 @@ func (e *JsEngine) Close() {
|
|||||||
// goja doesn't have an explicit close method, but we can help GC
|
// goja doesn't have an explicit close method, but we can help GC
|
||||||
e.runtime = nil
|
e.runtime = nil
|
||||||
e.transform = nil
|
e.transform = nil
|
||||||
|
e.jsonParse = nil
|
||||||
|
e.errorConstructor = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestDataToObject converts RequestData to a goja Value (JavaScript object).
|
// requestDataToObject converts RequestData to a goja Value (JavaScript object).
|
||||||
@@ -176,23 +196,52 @@ func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string
|
|||||||
result := make(map[string][]string)
|
result := make(map[string][]string)
|
||||||
for _, key := range obj.Keys() {
|
for _, key := range obj.Keys() {
|
||||||
v := obj.Get(key)
|
v := obj.Get(key)
|
||||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's an array
|
// Check if it's an array
|
||||||
if arr, ok := v.Export().([]any); ok {
|
if arr, ok := v.(*goja.Object); ok && arr.ClassName() == "Array" {
|
||||||
var values []string
|
length := int(arr.Get("length").ToInteger())
|
||||||
for _, item := range arr {
|
values := make([]string, 0, length)
|
||||||
if s, ok := item.(string); ok {
|
for i := range length {
|
||||||
values = append(values, s)
|
if text, ok := jsStringValue(arr.Get(strconv.Itoa(i))); ok {
|
||||||
|
values = append(values, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result[key] = values
|
result[key] = values
|
||||||
} else {
|
continue
|
||||||
// Single value - wrap in slice
|
}
|
||||||
result[key] = []string{v.String()}
|
|
||||||
|
// Single value, wrap it in a slice
|
||||||
|
if text, ok := jsStringValue(v); ok {
|
||||||
|
result[key] = []string{text}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exceptionError keeps the thrown value's message without goja's stack frame.
|
||||||
|
// Stringifying runs through Runtime.Try because a thrown value with no usable
|
||||||
|
// toString makes goja panic outside its own execution context.
|
||||||
|
func (e *JsEngine) exceptionError(err error) error {
|
||||||
|
var exception *goja.Exception
|
||||||
|
if !errors.As(err, &exception) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var message string
|
||||||
|
if thrown := e.runtime.Try(func() { message = exception.Value().String() }); thrown != nil {
|
||||||
|
message = "unprintable thrown value"
|
||||||
|
}
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsStringValue renders a JavaScript primitive the way the script would see it.
|
||||||
|
// Objects, arrays and functions have no useful text form, so they are skipped.
|
||||||
|
func jsStringValue(v goja.Value) (string, bool) {
|
||||||
|
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if _, isObject := v.(*goja.Object); isObject {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return v.String(), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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
|
||||||
|
})
|
||||||
|
// body is a JavaScript string, so bytes that are not valid UTF-8 reach the script
|
||||||
|
// only through this.
|
||||||
|
_ = obj.Set("bytes", func(goja.FunctionCall) goja.Value {
|
||||||
|
buffer := rt.NewArrayBuffer([]byte(resp.Body))
|
||||||
|
value, err := rt.New(rt.Get("Uint8Array").ToObject(rt), rt.ToValue(buffer))
|
||||||
|
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 {
|
type LuaEngine struct {
|
||||||
state *lua.LState
|
state *lua.LState
|
||||||
transform *lua.LFunction
|
transform *lua.LFunction
|
||||||
|
http httpBridge
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLuaEngine creates a new Lua script engine with the given script content.
|
// NewLuaEngine creates a new Lua script engine with the given script content.
|
||||||
@@ -29,6 +30,11 @@ type LuaEngine struct {
|
|||||||
// - types.ScriptExecutionError
|
// - types.ScriptExecutionError
|
||||||
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
||||||
L := lua.NewState()
|
L := lua.NewState()
|
||||||
|
engine := &LuaEngine{state: L}
|
||||||
|
|
||||||
|
// Register the globals before running the script so its functions can use them
|
||||||
|
engine.registerHTTP()
|
||||||
|
registerLuaJSON(L)
|
||||||
|
|
||||||
// Execute the script to define the transform function
|
// Execute the script to define the transform function
|
||||||
if err := L.DoString(scriptContent); err != nil {
|
if err := L.DoString(scriptContent); err != nil {
|
||||||
@@ -42,17 +48,23 @@ func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
|||||||
L.Close()
|
L.Close()
|
||||||
return nil, types.ErrScriptTransformMissing
|
return nil, types.ErrScriptTransformMissing
|
||||||
}
|
}
|
||||||
|
engine.transform = transform.(*lua.LFunction)
|
||||||
|
|
||||||
return &LuaEngine{
|
return engine, nil
|
||||||
state: L,
|
}
|
||||||
transform: transform.(*lua.LFunction),
|
|
||||||
}, nil
|
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
|
||||||
|
func (e *LuaEngine) SetHTTPDoer(doer HTTPDoer) {
|
||||||
|
e.http.doer = doer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform executes the Lua transform function with the given request data.
|
// Transform executes the Lua transform function with the given request data.
|
||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ScriptExecutionError
|
// - types.ScriptExecutionError
|
||||||
func (e *LuaEngine) Transform(req *RequestData) error {
|
func (e *LuaEngine) Transform(req *RequestData) error {
|
||||||
|
e.http.active = true
|
||||||
|
defer func() { e.http.active = false }()
|
||||||
|
|
||||||
// Convert RequestData to Lua table
|
// Convert RequestData to Lua table
|
||||||
reqTable := e.requestDataToTable(req)
|
reqTable := e.requestDataToTable(req)
|
||||||
|
|
||||||
@@ -93,39 +105,23 @@ func (e *LuaEngine) requestDataToTable(req *RequestData) *lua.LTable {
|
|||||||
t.RawSetString("path", lua.LString(req.Path))
|
t.RawSetString("path", lua.LString(req.Path))
|
||||||
t.RawSetString("body", lua.LString(req.Body))
|
t.RawSetString("body", lua.LString(req.Body))
|
||||||
|
|
||||||
// Headers (map[string][]string -> table of arrays)
|
t.RawSetString("headers", stringSliceMapToTable(L, req.Headers))
|
||||||
headers := L.NewTable()
|
t.RawSetString("params", stringSliceMapToTable(L, req.Params))
|
||||||
for k, values := range req.Headers {
|
t.RawSetString("cookies", stringSliceMapToTable(L, req.Cookies))
|
||||||
arr := L.NewTable()
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringSliceMapToTable converts a Go map[string][]string to a Lua table of arrays.
|
||||||
|
func stringSliceMapToTable(state *lua.LState, m map[string][]string) *lua.LTable {
|
||||||
|
t := state.NewTable()
|
||||||
|
for k, values := range m {
|
||||||
|
arr := state.NewTable()
|
||||||
for _, v := range values {
|
for _, v := range values {
|
||||||
arr.Append(lua.LString(v))
|
arr.Append(lua.LString(v))
|
||||||
}
|
}
|
||||||
headers.RawSetString(k, arr)
|
t.RawSetString(k, arr)
|
||||||
}
|
}
|
||||||
t.RawSetString("headers", headers)
|
|
||||||
|
|
||||||
// Params (map[string][]string -> table of arrays)
|
|
||||||
params := L.NewTable()
|
|
||||||
for k, values := range req.Params {
|
|
||||||
arr := L.NewTable()
|
|
||||||
for _, v := range values {
|
|
||||||
arr.Append(lua.LString(v))
|
|
||||||
}
|
|
||||||
params.RawSetString(k, arr)
|
|
||||||
}
|
|
||||||
t.RawSetString("params", params)
|
|
||||||
|
|
||||||
// Cookies (map[string][]string -> table of arrays)
|
|
||||||
cookies := L.NewTable()
|
|
||||||
for k, values := range req.Cookies {
|
|
||||||
arr := L.NewTable()
|
|
||||||
for _, v := range values {
|
|
||||||
arr.Append(lua.LString(v))
|
|
||||||
}
|
|
||||||
cookies.RawSetString(k, arr)
|
|
||||||
}
|
|
||||||
t.RawSetString("cookies", cookies)
|
|
||||||
|
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,20 +168,33 @@ func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string {
|
|||||||
}
|
}
|
||||||
key := string(k.(lua.LString))
|
key := string(k.(lua.LString))
|
||||||
|
|
||||||
switch v.Type() {
|
if v.Type() == lua.LTTable {
|
||||||
case lua.LTString:
|
// Array of values, keys outside 1..n are skipped
|
||||||
// Single string value
|
arr := v.(*lua.LTable)
|
||||||
result[key] = []string{string(v.(lua.LString))}
|
length := arr.Len()
|
||||||
case lua.LTTable:
|
values := make([]string, 0, length)
|
||||||
// Array of strings
|
for i := 1; i <= length; i++ {
|
||||||
var values []string
|
if text, ok := luaScalarString(arr.RawGetInt(i)); ok {
|
||||||
v.(*lua.LTable).ForEach(func(_, item lua.LValue) {
|
values = append(values, text)
|
||||||
if item.Type() == lua.LTString {
|
|
||||||
values = append(values, string(item.(lua.LString)))
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
result[key] = values
|
result[key] = values
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if text, ok := luaScalarString(v); ok {
|
||||||
|
result[key] = []string{text}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// luaScalarString renders strings, numbers and booleans as text.
|
||||||
|
func luaScalarString(v lua.LValue) (string, bool) {
|
||||||
|
switch v.Type() {
|
||||||
|
case lua.LTString, lua.LTNumber, lua.LTBool:
|
||||||
|
return v.String(), true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
// The script should modify the RequestData and return it.
|
||||||
Transform(req *RequestData) error
|
Transform(req *RequestData) error
|
||||||
|
|
||||||
|
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
|
||||||
|
SetHTTPDoer(doer HTTPDoer)
|
||||||
|
|
||||||
// Close releases any resources held by the engine.
|
// Close releases any resources held by the engine.
|
||||||
Close()
|
Close()
|
||||||
}
|
}
|
||||||
@@ -69,7 +72,7 @@ func LoadSource(ctx context.Context, source string, engineType EngineType) (*Sou
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(source, "@@"):
|
case strings.HasPrefix(source, "@@"):
|
||||||
// Escaped @ - it's an inline script starting with literal @
|
// Escaped @: an inline script starting with a literal @
|
||||||
content = source[1:] // Remove first @, keep the rest
|
content = source[1:] // Remove first @, keep the rest
|
||||||
case strings.HasPrefix(source, "@"):
|
case strings.HasPrefix(source, "@"):
|
||||||
// File or URL reference
|
// File or URL reference
|
||||||
@@ -125,7 +128,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to create an engine - this validates syntax and transform function
|
// Creating an engine validates the syntax and the transform function
|
||||||
var engine Engine
|
var engine Engine
|
||||||
switch engineType {
|
switch engineType {
|
||||||
case EngineTypeLua:
|
case EngineTypeLua:
|
||||||
@@ -140,7 +143,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the engine - we only needed it for validation
|
// The engine was only needed for validation
|
||||||
engine.Close()
|
engine.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -168,10 +171,18 @@ func fetchURL(ctx context.Context, url string) (string, error) {
|
|||||||
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
// A declared size above the limit is refused before anything is read.
|
||||||
|
if resp.ContentLength > types.RemoteFetchLimit {
|
||||||
|
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", types.NewHTTPFetchError(url, err)
|
return "", types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
if len(data) > types.RemoteFetchLimit {
|
||||||
|
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
return string(data), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,17 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
errNoError = errors.New("no error (internal)")
|
errNoError = errors.New("no error (internal)")
|
||||||
|
|
||||||
|
ErrByteSizeInvalid = errors.New(
|
||||||
|
"invalid value for size, expected a whole number of bytes or a whole number with a unit: " +
|
||||||
|
"B, KB, MB, GB, TB (1000 based) or KiB, MiB, GiB, TiB (1024 based), e.g. '1048576', '10MiB', '2MB'",
|
||||||
|
)
|
||||||
|
|
||||||
|
ErrRemoteFileTooLarge = errors.New("remote file exceeds " + FormatByteSize(RemoteFetchLimit))
|
||||||
|
|
||||||
|
ErrResponseBodyTooLarge = errors.New("response body exceeds the body limit")
|
||||||
|
|
||||||
|
ErrYAMLMultipleDocuments = errors.New("expected a single YAML document, but found more than one")
|
||||||
)
|
)
|
||||||
|
|
||||||
type FieldParseError struct {
|
type FieldParseError struct {
|
||||||
@@ -297,6 +308,27 @@ func (e CLIUnexpectedArgsError) Error() string {
|
|||||||
|
|
||||||
// ======================================== Config File ========================================
|
// ======================================== Config File ========================================
|
||||||
|
|
||||||
|
// ConfigFileError names the file a parse error came from, which nested configFile chains lose.
|
||||||
|
type ConfigFileError struct {
|
||||||
|
Path string
|
||||||
|
error error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigFileError(path string, err error) ConfigFileError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ConfigFileError{path, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ConfigFileError) Error() string {
|
||||||
|
return e.error.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ConfigFileError) Unwrap() error {
|
||||||
|
return e.error
|
||||||
|
}
|
||||||
|
|
||||||
type ConfigFileReadError struct {
|
type ConfigFileReadError struct {
|
||||||
error error
|
error error
|
||||||
}
|
}
|
||||||
@@ -318,6 +350,8 @@ func (e ConfigFileReadError) Unwrap() error {
|
|||||||
|
|
||||||
// ======================================== Proxy ========================================
|
// ======================================== Proxy ========================================
|
||||||
|
|
||||||
|
var ErrProxyNoContextDialer = errors.New("SOCKS5 dialer does not support timeouts")
|
||||||
|
|
||||||
type ProxyUnsupportedSchemeError struct {
|
type ProxyUnsupportedSchemeError struct {
|
||||||
Scheme string
|
Scheme string
|
||||||
}
|
}
|
||||||
@@ -401,8 +435,88 @@ var (
|
|||||||
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
|
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
|
||||||
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
|
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
|
||||||
ErrScriptURLNoHost = errors.New("script URL must have a host")
|
ErrScriptURLNoHost = errors.New("script URL must have a host")
|
||||||
|
ErrScriptHTTPOutsideTransform = errors.New("http functions can only be called while transform is running")
|
||||||
|
ErrScriptHTTPUnavailable = errors.New("http client is not available")
|
||||||
|
ErrScriptHTTPMethodEmpty = errors.New("http method cannot be empty")
|
||||||
|
ErrScriptHTTPURLInvalid = errors.New("URL must be an absolute http or https URL")
|
||||||
|
ErrScriptHTTPUnknownOption = errors.New("unknown option")
|
||||||
|
ErrScriptJSONCycle = errors.New("cannot encode a table that contains itself")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ScriptTypeError struct {
|
||||||
|
Expected string
|
||||||
|
Got string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptTypeError(expected, got string) ScriptTypeError {
|
||||||
|
return ScriptTypeError{expected, got}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptTypeError) Error() string {
|
||||||
|
return fmt.Sprintf("expected %s, got %s", e.Expected, e.Got)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptHTTPOptionError struct {
|
||||||
|
Option string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptHTTPOptionError(option string, err error) ScriptHTTPOptionError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ScriptHTTPOptionError{option, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptHTTPOptionError) Error() string {
|
||||||
|
return fmt.Sprintf("http option %q: %v", e.Option, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptHTTPOptionError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptHTTPRequestError struct {
|
||||||
|
Method string
|
||||||
|
URL string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptHTTPRequestError(method, url string, err error) ScriptHTTPRequestError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ScriptHTTPRequestError{method, url, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptHTTPRequestError) Error() string {
|
||||||
|
return fmt.Sprintf("http %s %s: %v", e.Method, e.URL, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptHTTPRequestError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptJSONError struct {
|
||||||
|
Op string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptJSONError(op string, err error) ScriptJSONError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ScriptJSONError{op, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptJSONError) Error() string {
|
||||||
|
return fmt.Sprintf("json.%s: %v", e.Op, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptJSONError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
type ScriptLoadError struct {
|
type ScriptLoadError struct {
|
||||||
Source string
|
Source string
|
||||||
Err error
|
Err error
|
||||||
|
|||||||
Reference in new issue
Block a user