README
¶
sendit
A Go CLI tool that simulates realistic user web traffic across HTTP, headless browser, DNS, WebSocket, and gRPC protocols. Designed to blend into normal traffic baselines while being polite to both the local machine and target servers.
Key properties:
- Stays polite by default — all pacing is delay-gated before acquiring worker slots;
mode: burstis available for internal infrastructure testing but requires an explicit time-bounded run (--duration) to start - Per-domain token-bucket rate limits with decorrelated jitter backoff on transient errors
- Pauses dispatch when local CPU or RAM exceeds configurable thresholds
- Graceful shutdown: waits for all in-flight requests to complete on SIGINT/SIGTERM
Contents
- Install
- Quick Start
- CLI Commands
- Dry-run mode
- Generate
- Probe
- Pinch
- Capture
- Docker
- Configuration Reference
- Dispatch Pipeline
- Architecture
- Running Tests
- Verification
- Security
Install
Homebrew (macOS / Linux)
brew install lewta/tap/sendit
Shell completions for bash, zsh, and fish are installed automatically.
Linux packages
Download the package for your distro from the latest release:
# Debian / Ubuntu
sudo dpkg -i sendit_*_linux_amd64.deb
# Fedora / RHEL / CentOS
sudo rpm -i sendit_*_linux_amd64.rpm
# Arch Linux / Omarchy — via AUR helper (recommended)
yay -S sendit-bin
# or: paru -S sendit-bin
# Arch Linux — direct package install (no AUR helper required)
sudo pacman -U sendit_*_linux_amd64.pkg.tar.zst
Shell completions are bundled and installed automatically.
Windows (Scoop)
scoop bucket add lewta https://github.com/lewta/scoop-bucket
scoop install lewta/sendit
Binary download
Download a pre-built binary for your platform from the releases page, extract it, and place sendit somewhere in your $PATH.
Build from source
git clone https://github.com/lewta/sendit
cd sendit
go build -o sendit ./cmd/sendit
Quick Start
Prerequisites
- Go 1.24+ (build from source only)
- Chrome/Chromium (only required for
type: browsertargets)
Test an endpoint without a config file
sendit probe works with no config — it auto-detects HTTP from https:// and DNS from a bare hostname:
# HTTP
./sendit probe https://example.com
# DNS
./sendit probe example.com
Validate your config
./sendit validate --config config/example.yaml
# config valid
Run
./sendit start --config config/example.yaml --log-level debug
Run with a targets file
Rather than listing every target in the YAML, you can point targets_file at a plain-text list:
# config/my-targets.txt
https://example.com http 5
example.com dns 2
# config/simple.yaml
targets_file: "config/my-targets.txt"
target_defaults:
http:
timeout_s: 15
dns:
resolver: "8.8.8.8:53"
./sendit validate --config config/simple.yaml # check the file parses cleanly
./sendit start --config config/simple.yaml --log-level debug
CLI Commands
sendit generate [--targets-file <path>] [--url <url>] [--from-history chrome|firefox|safari] [--from-bookmarks chrome|firefox] [--output <file>]
sendit start [-c <path>] [--foreground] [--log-level debug|info|warn|error] [--dry-run] [--capture <file>]
sendit probe <target> [--type http|dns|websocket] [--interval 1s] [--timeout 5s] [--send <msg>]
sendit pinch <host:port> [--type tcp|udp] [--interval 1s] [--timeout 5s]
sendit export --pcap <results.jsonl> [--output <results.pcap>]
sendit stop [--pid-file <path>]
sendit reload [--pid-file <path>]
sendit status [--pid-file <path>]
sendit validate [-c <path>]
sendit version
sendit completion <shell>
| Command | Description |
|---|---|
generate |
Generate a ready-to-use config.yaml from a targets file, a seed URL with in-domain crawling, or your local browser history/bookmarks. |
start |
Start the engine. Writes a PID file by default so stop/status can find the process; use --foreground to skip writing the PID file. |
probe |
Test a single HTTP, DNS, or WebSocket endpoint in a loop (like ping). No config file required. |
pinch |
Check whether a TCP or UDP port is open on a remote host, repeating on an interval. No config file required. |
export |
Convert a JSONL results file to PCAP format for analysis in Wireshark or tshark. |
stop |
Send SIGTERM to a running instance via its PID file. |
reload |
Send SIGHUP to a running instance via its PID file to reload the config atomically. Not available on Windows — use a full restart instead. |
status |
Check whether the process in the PID file is still alive. |
validate |
Parse and validate a config file without starting the engine. Exits 0 on success, non-zero with a message on failure. |
version |
Print version, commit, and build date. |
completion |
Generate shell autocompletion scripts (bash, zsh, fish, powershell). |
start flags
| Flag | Short | Default | Description |
|---|---|---|---|
--config |
-c |
config/example.yaml |
Path to YAML config file |
--foreground |
false |
Skip writing the PID file (process always runs in the foreground) | |
--log-level |
(from config) | Override log level: debug | info | warn | error |
|
--dry-run |
false |
Print config summary (targets, pacing, limits) and exit without sending traffic | |
--capture |
"" |
Write a synthetic PCAP file while running; file is finalised on clean shutdown | |
--duration |
(unlimited) | Auto-stop after this wall-clock time (e.g. 5m, 30s); required when pacing.mode: burst |
probe flags
| Flag | Default | Description |
|---|---|---|
--type |
(auto-detected) | Driver type: http | dns | websocket |
--interval |
1s |
Delay between requests |
--timeout |
5s |
Per-request timeout |
--resolver |
8.8.8.8:53 |
DNS resolver (dns targets only) |
--record-type |
A |
DNS record type (dns targets only) |
--send |
"" |
Message to send after connecting (websocket only); waits for one reply and reports round-trip latency |
pinch flags
| Flag | Default | Description |
|---|---|---|
--type |
tcp |
Protocol type: tcp | udp |
--interval |
1s |
Delay between checks |
--timeout |
5s |
Per-check timeout |
export flags
| Flag | Default | Description |
|---|---|---|
--pcap |
(required) | JSONL results file to convert to PCAP |
--output |
(input with .pcap extension) |
Output PCAP file path |
stop / reload / status flags
| Flag | Default | Description |
|---|---|---|
--pid-file |
/tmp/sendit.pid |
Path to the PID file written by start |
validate flags
| Flag | Short | Default | Description |
|---|---|---|---|
--config |
-c |
config/example.yaml |
Path to YAML config file |
Dry-run mode
Pass --dry-run to sendit start to preview the effective configuration — target weights, pacing parameters, and resource limits — without sending any traffic:
./sendit start --config config/example.yaml --dry-run
Config: config/example.yaml ✓ valid
Targets (4):
URL TYPE WEIGHT SHARE
https://httpbin.org/get http 10 47.6%
https://httpbin.org/status/200 http 5 23.8%
https://news.ycombinator.com browser 3 14.3%
example.com dns 3 14.3%
Total weight: 21
Pacing:
mode: human | delay: 800ms–8000ms (random uniform)
Limits:
workers: 4 (browser: 1) | cpu: 60% | memory: 512 MB
Generate
sendit generate produces a ready-to-use config.yaml from one or more input sources. Use it to get from zero to sending traffic in seconds without hand-editing YAML.
From a targets file
sendit generate --targets-file config/targets.txt > config/generated.yaml
sendit validate --config config/generated.yaml
sendit start --config config/generated.yaml
The targets file format is <url> <type> [weight] per line — the same format as targets_file: in the YAML config. Comments (#) and blank lines are ignored.
From a seed URL with crawling
# Crawl example.com up to depth 2 and discover up to 50 in-domain pages
sendit generate --url https://example.com --depth 2 --max-pages 50 --output config/generated.yaml
The crawler fetches the seed URL, parses <a href> links, and follows in-domain links breadth-first. robots.txt is respected by default; pass --ignore-robots to skip it.
From browser history
# Top 100 most-visited Chrome URLs (weight ∝ visit count, capped at 10)
sendit generate --from-history chrome --history-limit 100 --output config/generated.yaml
# Firefox or Safari
sendit generate --from-history firefox --output config/generated.yaml
sendit generate --from-history safari --output config/generated.yaml # macOS only
Visit count is mapped to a target weight (capped at 10) so frequently visited pages appear proportionally more often in the generated traffic without dominating it.
From browser bookmarks
sendit generate --from-bookmarks chrome --output config/generated.yaml
sendit generate --from-bookmarks firefox --output config/generated.yaml
All bookmarked HTTP/HTTPS URLs are emitted as equal-weight targets. Sources can be combined:
sendit generate --url https://example.com --from-history chrome --history-limit 50 --output config/gen.yaml
generate flags
| Flag | Default | Description |
|---|---|---|
--targets-file |
"" |
Generate from an existing targets file (url type [weight] per line) |
--url |
"" |
Seed URL for crawl-based generation (implies --crawl) |
--crawl |
false |
Enable in-domain page discovery for HTTP targets (used with --url) |
--depth |
2 |
Maximum crawl depth |
--max-pages |
50 |
Maximum number of pages to discover |
--ignore-robots |
false |
Skip robots.txt enforcement during crawl |
--from-history |
"" |
Harvest visited URLs from browser history: chrome | firefox | safari |
--from-bookmarks |
"" |
Harvest bookmarked URLs: chrome | firefox (Safari bookmarks not yet supported) |
--history-limit |
100 |
Maximum URLs to import from history (ordered by visit count descending) |
--output |
(stdout) | Write config to a file instead of stdout; prompts before overwriting |
Probe
sendit probe <target> tests a single HTTP, DNS, or WebSocket endpoint in a loop with no config file. Press Ctrl-C to stop and print a summary.
Type auto-detection:
| Target format | Detected type |
|---|---|
https://example.com |
http |
http://example.com |
http |
wss://example.com |
websocket |
ws://example.com |
websocket |
example.com |
dns |
Override with --type http, --type dns, or --type websocket.
HTTP example:
./sendit probe https://example.com
Probing https://example.com (http) — Ctrl-C to stop
200 142ms 1.2 KB
200 38ms 1.2 KB
200 503ms 1.2 KB
^C
--- https://example.com ---
3 sent, 3 ok, 0 error(s)
min/avg/max latency: 38ms / 227ms / 503ms
DNS example:
./sendit probe example.com --record-type A --resolver 1.1.1.1:53
Probing example.com (dns, A @ 1.1.1.1:53) — Ctrl-C to stop
NOERROR 12ms
NOERROR 8ms
NOERROR 11ms
^C
--- example.com ---
3 sent, 3 ok, 0 error(s)
min/avg/max latency: 8ms / 10ms / 12ms
WebSocket example (connect only):
./sendit probe wss://echo.websocket.org
Probing wss://echo.websocket.org (websocket, connect only) — Ctrl-C to stop
101 38ms
101 41ms
101 36ms
^C
--- wss://echo.websocket.org ---
3 sent, 3 ok, 0 error(s)
min/avg/max latency: 36ms / 38ms / 41ms
WebSocket example (send + receive round-trip):
./sendit probe wss://echo.websocket.org --send 'ping'
Probing wss://echo.websocket.org (websocket, send+recv) — Ctrl-C to stop
101 42ms
101 39ms
101 44ms
^C
--- wss://echo.websocket.org ---
3 sent, 3 ok, 0 error(s)
min/avg/max latency: 39ms / 41ms / 44ms
Pinch
sendit pinch <host:port> checks whether a TCP or UDP port is open on a remote host, repeating on an interval. Press Ctrl-C to stop and print a summary. No config file required.
TCP example:
./sendit pinch example.com:80
Pinching example.com:80 (tcp) — Ctrl-C to stop
open 142ms
open 38ms
closed 0ms connection refused
^C
--- example.com:80 ---
3 sent, 2 open, 1 closed/filtered
min/avg/max latency: 38ms / 90ms / 142ms
UDP example:
./sendit pinch 8.8.8.8:53 --type udp
Pinching 8.8.8.8:53 (udp) — Ctrl-C to stop
open 4ms
open|filtered 5s (no response within timeout)
^C
--- 8.8.8.8:53 ---
2 sent, 1 open, 1 closed/filtered
min/avg/max latency: 4ms / 4ms / 4ms
Status labels:
| Label | Protocol | Meaning |
|---|---|---|
open |
TCP | Connection accepted |
closed |
TCP | Connection refused |
filtered |
TCP | No response (deadline exceeded) |
open |
UDP | Response data received |
closed |
UDP | ICMP port unreachable received |
open|filtered |
UDP | Timeout — UDP is inherently ambiguous |
Capture
sendit start --capture <file> writes a synthetic PCAP alongside normal traffic. The file is finalised on clean shutdown (SIGINT/SIGTERM). No root, CAP_NET_RAW, or libpcap is required.
./sendit start --config config/example.yaml --capture session.pcap
# ... Ctrl-C to stop ...
# open session.pcap in Wireshark
sendit export --pcap <results.jsonl> converts a previously written JSONL results file to PCAP. Useful when you forgot to pass --capture, or when you want to post-process results from a long-running session.
./sendit start --config config/example.yaml
# output:
# enabled: true
# file: results.jsonl
sendit export --pcap results.jsonl
# Exported 312 packets → results.pcap
The PCAP uses LINKTYPE_USER0 (147) — there is no IP/TCP framing. Each packet payload is a text record:
ts=2024-01-01T12:00:00Z url=https://example.com type=http status=200 duration_ms=142 bytes=1256 error=
Open in Wireshark and use Analyze → Follow → TCP Stream (or the raw packet bytes view) to inspect individual request records.
Docker
The docker/ directory contains a ready-to-use Docker setup. The image is built from source so no binary download is needed.
Quick start
# 1. Copy and edit the example config
cp docker/config.yaml docker/my-config.yaml
# 2. Build and run (config is mounted as a volume)
cd docker
docker compose up --build
Prometheus metrics are exposed on port 9090. A liveness probe is available at GET /healthz on the same port.
With Prometheus + Grafana
cd docker
docker compose --profile observability up --build
This starts three containers:
| Service | Port | Description |
|---|---|---|
sendit |
9090 | Main process + /metrics + /healthz |
prometheus |
9091 | Scrapes sendit every 15 s |
grafana |
3000 | Dashboard UI (anonymous access pre-enabled) |
Config
Mount your config at /etc/sendit/config.yaml. For container deployments, set:
metrics:
enabled: true
prometheus_port: 9090
daemon:
log_format: json # friendlier for log aggregators
The --foreground flag is set in the image entrypoint — PID files are not useful inside containers.
Files
| File | Description |
|---|---|
docker/Dockerfile |
Multi-stage build (golang:1.24-alpine → alpine) |
docker/docker-compose.yml |
sendit + optional Prometheus/Grafana via --profile observability |
docker/config.yaml |
Docker-ready example config (metrics enabled, JSON logs) |
docker/prometheus.yml |
Prometheus scrape config targeting sendit:9090 |
Configuration Reference
See config/example.yaml for a full working example. Every section has defaults so you only need to specify what you want to override.
pacing
Controls how requests are spaced in time.
| Field | Default | Description |
|---|---|---|
mode |
human |
human | rate_limited | scheduled | burst |
requests_per_minute |
20 |
Target RPM — used by rate_limited and scheduled modes only |
jitter_factor |
0.4 |
Unused in human mode; reserved for future modes |
min_delay_ms |
800 |
Minimum inter-request delay in human mode |
max_delay_ms |
8000 |
Maximum inter-request delay in human mode |
schedule |
[] |
List of cron windows — required when mode: scheduled |
ramp_up_s |
0 |
Seconds to linearly ramp up to full speed — burst mode only; 0 = immediate |
Pacing modes:
human— random delay per request uniformly sampled from[min_delay_ms, max_delay_ms].requests_per_minuteandjitter_factorare ignored in this mode.rate_limited— token-bucket limiter atrequests_per_minuteplus a small random jitter after each token.scheduled— cron expressions open active windows; within each window behaves likerate_limitedat the window's own RPM. Dispatch is paused between windows.burst— fires requests as fast as worker slots allow with no inter-request delay. Intended for internal infrastructure testing. Requires--durationonsendit start— the engine refuses to run an unbounded burst session.
pacing:
mode: scheduled
schedule:
- cron: "0 9 * * 1-5" # weekdays 09:00
duration_minutes: 30
requests_per_minute: 40
# Burst example — always pair with --duration when starting
pacing:
mode: burst
ramp_up_s: 30 # optional: ramp from slow to full speed over 30 s
limits
Concurrency and resource thresholds.
| Field | Default | Description |
|---|---|---|
max_workers |
4 |
Maximum simultaneous requests across all driver types |
max_browser_workers |
1 |
Sub-limit for concurrent headless browser instances |
cpu_threshold_pct |
60.0 |
Pause dispatch when CPU usage exceeds this percentage |
memory_threshold_mb |
512 |
Pause dispatch when RAM in use exceeds this value in MB |
Note:
memory_threshold_mbdefaults to 512 MB, which is below baseline usage on most modern machines. Set this to a value above your system's idle memory footprint (e.g.8192for a 16 GB machine) to avoid blocking dispatch entirely.
rate_limits
Per-domain token buckets applied after the pacing delay and before acquiring a worker slot.
| Field | Default | Description |
|---|---|---|
default_rps |
0.5 |
Requests per second applied to all domains not listed in per_domain |
per_domain |
[] |
List of {domain, rps} overrides |
rate_limits:
default_rps: 0.5
per_domain:
- domain: "example.com"
rps: 0.2
backoff
Retry behaviour on transient errors (HTTP 429, 502, 503, 504, DNS SERVFAIL, network failures).
| Field | Default | Description |
|---|---|---|
initial_ms |
1000 |
Base delay for the first retry, in milliseconds |
max_ms |
120000 |
Maximum delay cap, in milliseconds |
multiplier |
2.0 |
Exponential growth factor per attempt |
max_attempts |
3 |
Stop retrying after this many consecutive failures for a domain |
Permanent errors (HTTP 400, 403, 404; DNS NXDOMAIN, REFUSED) are logged and skipped immediately with no retry. Context cancellation errors are dropped silently.
targets_file and target_defaults
Instead of (or in addition to) listing targets inline, you can point targets_file at a plain-text file of URL/type pairs. Targets from the file are appended to any inline targets entries, so both can be used together.
File format — one entry per line:
<url> <type> [weight]
url— full URL (https://,wss://,grpc://) or a bare hostname for DNS targetstype— one ofhttp|browser|dns|websocket|grpcweight— optional positive integer; defaults totarget_defaults.weightwhen omitted- Lines starting with
#and blank lines are ignored
# config/targets.txt
https://example.com http 5
https://api.example.com http 3
example.com dns 2
wss://ws.example.com websocket
grpc://svc.example.com:50051/helloworld.Greeter/SayHello grpc 4
target_defaults supplies the remaining fields (driver settings, default weight) for every target loaded from the file. Inline targets are unaffected and use whatever fields they specify directly.
targets_file: "config/targets.txt"
target_defaults:
weight: 1 # used when weight is omitted from the file
auth: # optional: apply shared credentials to all file-loaded targets
type: bearer
token_env: API_TOKEN # resolved from env at dispatch time
http:
method: GET
headers:
User-Agent: "Mozilla/5.0 ..."
timeout_s: 15
browser:
scroll: false
timeout_s: 30
dns:
resolver: "8.8.8.8:53"
record_type: A
websocket:
duration_s: 30
expect_messages: 0
grpc:
timeout_s: 15
target_defaults field |
Default | Description |
|---|---|---|
weight |
1 |
Selection weight for file targets with no explicit weight |
auth.type |
"" |
Auth type: bearer | basic | header | query |
http.method |
GET |
HTTP verb |
http.timeout_s |
15 |
Request timeout in seconds |
browser.timeout_s |
30 |
Page load timeout in seconds |
dns.resolver |
8.8.8.8:53 |
DNS resolver address |
dns.record_type |
A |
DNS record type |
websocket.duration_s |
30 |
How long to hold the connection open |
grpc.timeout_s |
15 |
Per-call timeout in seconds |
Note: HTTP header map keys are lowercased by the YAML parser (e.g.
User-Agentis stored asuser-agent). This applies to both inline targets andtarget_defaults.
targets
List of endpoints to request. Each target has a weight controlling selection frequency relative to the others. Selection uses the Vose alias method (O(1) per pick).
Non-standard ports are specified directly in the URL — no additional config needed:
targets:
- url: "http://internal-api.example.com:8080/health"
weight: 1
type: http
- url: "wss://stream.example.com:9443/feed"
weight: 1
type: websocket
For DNS, non-standard resolver ports use the existing host:port format in dns.resolver:
- url: "example.com"
type: dns
dns:
resolver: "192.168.1.1:5353"
targets:
- url: "https://example.com"
weight: 10
type: http
http:
method: GET
headers:
User-Agent: "Mozilla/5.0 ..."
body: "" # optional request body
timeout_s: 15
- url: "https://news.ycombinator.com"
weight: 5
type: browser
browser:
scroll: true # scroll to mid-page then bottom
wait_for_selector: "#hnmain" # wait for this CSS selector before returning
timeout_s: 30
- url: "example.com"
weight: 3
type: dns
dns:
resolver: "8.8.8.8:53"
record_type: A # A | AAAA | MX | TXT | CNAME | ...
- url: "wss://stream.example.com/feed"
weight: 2
type: websocket
websocket:
duration_s: 30 # hold connection open for this long
send_messages: ['{"type":"subscribe"}'] # messages to send on connect
expect_messages: 1 # wait to receive this many messages
- url: "grpc://api.example.com:50051/helloworld.Greeter/SayHello"
weight: 4
type: grpc
grpc:
body: '{"name": "world"}' # JSON-encoded request (optional — empty sends default-constructed message)
timeout_s: 15
# tls: false # force TLS even when scheme is grpc://
# insecure: false # skip TLS certificate verification
# Auth — token values resolved from env vars at dispatch time.
# Supported types: bearer, basic, header, query.
- url: "https://api.example.com/data"
weight: 3
type: http
auth:
type: bearer
token_env: API_TOKEN # export API_TOKEN=<value> before starting
- url: "https://api.example.com/search"
weight: 2
type: http
auth:
type: basic
username: alice
password_env: API_PASS
- url: "https://api.example.com/v2/items"
weight: 1
type: http
auth:
type: header
header_name: X-API-Key
token_env: API_KEY
- url: "https://api.example.com/v3/items"
weight: 1
type: http
auth:
type: query
param_name: api_key
token_env: API_KEY
output
Optional result export to a file for offline analysis.
| Field | Default | Description |
|---|---|---|
enabled |
false |
Enable result export |
file |
sendit-results.jsonl |
Output file path |
format |
jsonl |
jsonl (one JSON object per line) | csv |
append |
false |
Append to an existing file instead of truncating on start |
output:
enabled: true
file: "results.jsonl"
format: jsonl # jsonl | csv
append: false
Each JSONL record contains: ts, url, type, status, duration_ms, bytes, error.
CSV output writes a header row when append: false.
metrics
Optional Prometheus exposition.
metrics:
enabled: true
prometheus_port: 9090 # GET http://localhost:9090/metrics
Exposed metrics:
| Metric | Type | Labels |
|---|---|---|
sendit_requests_total |
Counter | type, domain, status_code |
sendit_errors_total |
Counter | type, domain, error_class |
sendit_request_duration_seconds |
Histogram | type, domain |
sendit_bytes_read_total |
Counter | type |
daemon
daemon:
pid_file: "/tmp/sendit.pid" # written by start unless --foreground is set
log_level: info # debug | info | warn | error
log_format: text # text (coloured console) | json
Dispatch Pipeline
Every task flows through the following gates in order before a worker goroutine is launched:
Scheduler.Wait pacing delay (human jitter / token bucket / cron window)
→ resource.Admit pause if CPU or RAM over threshold
→ backoff.Wait per-domain delay after transient errors
→ ratelimit.Wait per-domain token bucket
→ pool.Acquire global semaphore + browser sub-semaphore
→ go driver.Execute
→ pool.Release
This ordering ensures that slow or rate-limited domains do not consume worker slots while waiting.
Architecture
cmd/sendit/main.go cobra CLI
internal/config/ YAML loader, defaults, validator, targets_file parser
internal/task/ Task & Result types; Vose alias weighted selector
internal/ratelimit/ Per-domain token-bucket registry; decorrelated jitter backoff
internal/resource/ gopsutil CPU/RAM monitor with Admit() gate
internal/driver/ HTTP · headless browser (chromedp) · DNS (miekg) · WebSocket · gRPC (reflection-based)
internal/engine/ Worker pool · scheduler · dispatch loop
internal/metrics/ Prometheus counters & histograms
internal/output/ JSONL / CSV result writer (non-blocking, goroutine-backed)
internal/pcap/ Synthetic PCAP writer and JSONL→PCAP exporter (pure Go, no CGO)
config/example.yaml Full reference configuration (with target_defaults section)
config/targets.txt Example targets file (url + type per line)
config/test.yaml Lightweight HTTP+DNS config for local smoke-testing
docker/ Container deployment: Dockerfile, docker-compose, example config
Browser driver
Each browser task spawns its own chromedp.ExecAllocator — no shared browser state — which prevents memory accumulation from long-running sessions. The max_browser_workers sub-semaphore limits concurrent Chrome instances independently of the global worker pool.
DNS driver
DNS RCODEs are mapped to HTTP-like status codes so the engine's unified error classifier works across all driver types:
| DNS RCODE | HTTP equivalent | Effect |
|---|---|---|
| NOERROR (0) | 200 | success |
| NXDOMAIN (3) | 404 | permanent skip |
| REFUSED (5) | 403 | permanent skip |
| SERVFAIL (2) | 503 | transient backoff |
| other | 502 | transient backoff |
gRPC driver
Executes unary gRPC calls using server reflection — no .proto files required. gRPC status codes are mapped to HTTP-like codes on the same principle:
| gRPC code | HTTP equivalent | Effect |
|---|---|---|
| OK (0) | 200 | success |
| InvalidArgument (3), OutOfRange (11) | 400 | permanent skip |
| Unauthenticated (16) | 401 | permanent skip |
| PermissionDenied (7) | 403 | permanent skip |
| NotFound (5) | 404 | permanent skip |
| AlreadyExists (6) | 409 | permanent skip |
| ResourceExhausted (8) | 429 | transient backoff |
| Unimplemented (12) | 501 | permanent skip |
| Unavailable (14) | 503 | transient backoff |
| DeadlineExceeded (4) | 504 | transient backoff |
| other | 500 | transient backoff |
URL format: grpc://host:port/Service/Method (plaintext) or grpcs://host:port/Service/Method (TLS). The target server must have the gRPC server reflection service enabled.
Running Tests
go test ./...
go test -race ./... # with race detector
go test -tags integration -race -v ./internal/engine/... # full pipeline integration tests
Integration tests spin up local HTTP, DNS, and WebSocket servers and exercise the complete dispatch pipeline including backoff, graceful shutdown, and the resource gate.
Verification
| Scenario | How to test |
|---|---|
| Config validation | sendit validate --config config/example.yaml → prints "config valid", exits 0 |
| HTTP traffic | Use config/test.yaml (points at httpbin.org); observe status codes in logs |
| DNS traffic | DNS targets in config/test.yaml; look for type=dns status=200 log lines |
| targets_file | Set targets_file: "config/targets.txt" in a config; validate checks the file, start loads all entries |
| targets_file error | Point targets_file at a file with a bad line (e.g. example.com ftp) → validate prints the line number and error |
| gRPC traffic | Add a type: grpc target pointing at a service with reflection enabled (e.g. a local gRPC health server); observe type=grpc status=200 log lines |
| Auth (bearer) | Add auth: {type: bearer, token_env: MY_TOKEN} to a target; export MY_TOKEN=test; observe Authorization: Bearer test in server logs |
| Auth (env unset) | Set token_env to an unset variable; observe error result in output and no request reaching the server |
| target_defaults | Omit method from target_defaults.http; confirm requests default to GET in logs |
| Resource gate | Set cpu_threshold_pct: 1 → logs show "resource monitor: over threshold, dispatch paused" |
| Rate limiting | Set default_rps: 0.1, max_workers: 1 → ~1 req/10s per domain observed |
| Backoff | Point a target at a URL returning 429; observe exponential backoff= delay in WRN logs |
| Graceful shutdown | Send SIGTERM during active requests → process logs "engine stopped" after in-flight tasks finish |
| Dry-run | sendit start --config config/example.yaml --dry-run → prints target table, pacing, and limits then exits 0 |
| Result export | Set output.enabled: true, run briefly, inspect the output file for JSONL records |
| PCAP capture | sendit start --config config/example.yaml --capture session.pcap → stop after a few requests → open session.pcap in Wireshark; packets should appear with LINKTYPE_USER0 (147) |
| PCAP export | Run with output.enabled: true, then sendit export --pcap results.jsonl → results.pcap created; verify with file results.pcap or Wireshark |
| Probe (HTTP) | sendit probe https://httpbin.org/get → prints status/latency/bytes per request |
| Probe (DNS) | sendit probe example.com → prints NOERROR/latency per query |
| Pinch (TCP) | sendit pinch example.com:80 → prints open/closed/filtered + latency per check |
| Pinch (UDP) | sendit pinch 8.8.8.8:53 --type udp → prints open/closed/open|filtered per check |
| Non-standard port | Set url: "http://localhost:8080" in config → sendit start sends traffic to port 8080; or sendit probe http://localhost:8080 |
| Docker | cd docker && docker compose up --build → container starts; curl localhost:9090/healthz returns {"status":"ok"} |
Security
To report a vulnerability, use GitHub private vulnerability reporting. See SECURITY.md for the full policy.