- Go 99%
- Just 0.8%
- Dockerfile 0.2%
Reviewed-on: #11 Co-authored-by: Renovate Bot <renovate@onlyhavecans.works> Co-committed-by: Renovate Bot <renovate@onlyhavecans.works> |
||
|---|---|---|
| .forgejo/workflows | ||
| internal | ||
| testdata | ||
| .gitignore | ||
| .golangci.yaml | ||
| .goreleaser.yaml | ||
| .markdownlint-cli2.yaml | ||
| CHANGELOG.md | ||
| config-example.toml | ||
| CONTRIBUTING.md | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| Justfile | ||
| LICENSE | ||
| main.go | ||
| openapi.yaml | ||
| README.md | ||
| renovate.json | ||
spider_webhook
A minimal HTTP server for securely executing pre-configured commands remotely
Features
- Only pre-configured commands can be executed
- No additional data can be passed to commands
- Commands take an internal lock and cannot overlap execution
- Bearer token auth
- Per-IP rate limiting
- Per-command timeouts with process group cleanup
- Async commands allow fire-and-forget execution with immediate 202 response
- Graceful shutdown drains in-flight requests on SIGINT/SIGTERM
Installation
Download a binary from the Releases page, or build from source:
git clone https://onlyhavecans.works/tools/spider_webhook.git
cd spider_webhook
just build
# binary is at bin/spider_webhook
Docker
docker run -v /path/to/config.toml:/etc/spider_webhook/config.toml:ro \
skwrl/spider_webhook:latest
Quick start
-
Create a config file:
cp config-example.toml config.toml # edit config.toml — add your commands and API keys -
Start the server:
spider_webhook -config config.toml -
Call a command:
curl -X POST http://127.0.0.1:8990/v1/execute \ -H "Authorization: Bearer <your-api-key>" \ -H "Content-Type: application/json" \ -d '{"command": "deploy-app"}' # IPv6: curl -X POST http://[::1]:8990/v1/execute ...
Configuration
Minimal example
[server]
bind = "127.0.0.1:8990" # or "[::1]:8990" for IPv6, ":8990" for dual-stack
[[api_keys]]
name = "my-key"
key = "change-me-at-least-16-chars"
[[commands]]
name = "hello"
command = "/usr/bin/echo"
See config-example.toml for a full example with all options.
Server settings
| Field | Default | Description |
|---|---|---|
server.bind |
127.0.0.1:8990 |
Listen address (e.g. [::1]:8990 for IPv6, :8990 for dual-stack) |
server.trust_proxy_headers |
false |
Trust X-Forwarded-For / X-Real-IP for client IP |
server.default_timeout |
30s |
Default command timeout |
server.max_output_bytes |
1048576 (1 MiB) |
Max bytes captured per stream |
server.rate_limit_rps |
0 (disabled) |
Per-IP requests per second |
Per-command settings
| Field | Default | Description |
|---|---|---|
name |
(required) | Unique name matching ^[a-zA-Z0-9_-]+$ |
command |
(required) | Absolute path to the executable |
timeout |
default_timeout |
Command-specific timeout (e.g. "120s") |
working_dir |
server's cwd | Absolute path to set as working directory |
environment |
(none) | Map of environment variables to pass to the command |
async |
false |
If true, returns 202 immediately; result is logged server-side only |
API keys must be at least 16 characters. Both key names and command names must be unique.
Important
Commands run with an empty environment by default — they do not inherit the server's environment variables. Scripts that rely on
$PATHor other variables must have them set explicitly:environment = { PATH = "/usr/local/bin:/usr/bin:/bin" }
Hardcoded limits
These are not configurable:
| Setting | Value | Notes |
|---|---|---|
| Read timeout | 10s | Max time to read request headers + body |
| Write timeout | max sync command timeout + 10s | Calculated from non-async command timeouts at startup |
| Max header bytes | 4 KiB | Maximum size of request headers |
| Max request body | 1 KiB | Maximum size of the JSON request body |
API
GET /healthz
Health check. No authentication required.
Returns 200 while the server is running:
{"status": "ok"}
Returns 503 during graceful shutdown so load balancers stop routing traffic:
{"status": "shutting_down"}
POST /v1/execute
Execute a registered command. Requires Authorization: Bearer <key> header.
Request:
{"command": "deploy-app"}
Synchronous response (200):
{
"status": "success",
"command": "deploy-app",
"stdout": "deployed v1.2.3\n",
"stderr": "",
"exitCode": 0,
"durationMs": 1523
}
The status field is one of: success, error, timeout.
When a command fails to start (e.g. binary not found), status is error, exitCode is -1, and the OS error message is appended to stderr.
Async response (202):
Commands configured with async = true return immediately:
{
"status": "accepted",
"command": "backup-db"
}
The command runs in the background. Results are logged server-side only — there is no callback or polling mechanism.
Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error": "invalid request body"} |
Malformed JSON, unknown fields, or body exceeds 1024 bytes |
| 400 | {"error": "command field is required"} |
Missing command field |
| 400 | {"error": "invalid command name"} |
Name doesn't match allowed pattern |
| 401 | {"error": "unauthorized"} |
Missing or invalid Bearer token |
| 404 | {"error": "unknown command"} |
Command not in config |
| 409 | {"error": "command is already running"} |
Another instance of this command is still executing |
| 405 | {"error": "method not allowed"} |
Non-POST to /v1/execute |
| 429 | {"error": "rate limit exceeded"} |
Per-IP rate limit hit |
Deployment
spider_webhook does not provide TLS. Place it behind a TLS-terminating reverse proxy (nginx, Caddy, etc.)
and set trust_proxy_headers = true so rate limiting uses the real client IP.
When proxy headers are trusted, X-Forwarded-For (rightmost IP) takes precedence over X-Real-IP.
This assumes a single trusted reverse proxy directly in front of the server.
For systemd, a basic unit file:
[Service]
ExecStart=/usr/local/bin/spider_webhook -config /etc/spider_webhook/config.toml
For Docker, mount your config file and any scripts the commands reference:
docker run -d \
-v /etc/spider_webhook/config.toml:/etc/spider_webhook/config.toml:ro \
-v /opt/scripts:/opt/scripts:ro \
-p 127.0.0.1:8990:8990 \
skwrl/spider_webhook:latest
# For IPv6: -p '[::1]:8990:8990' and set bind = ":8990" in config
Warning
Never expose the plain HTTP port to untrusted networks.
Operations
Graceful shutdown
In-flight HTTP requests drain with a 30-second timeout, then in-flight async commands get a separate 30-second timeout.
Security model
- No input from the request is passed to commands.
- stdout and stderr each capped independently (default 1 MiB) to prevent memory exhaustion.
- No arbitrary execution. Only commands explicitly listed in the config can be run.
- Commands do not inherit the server's environment.
- API keys are hashed (SHA-256) at startup. Comparison is constant-time.
- Child processes are killed by process group on timeout, preventing orphaned subprocesses.
Development
just build # Build binary
just test # Run tests with race detector
just lint # Run golangci-lint + markdownlint
just check # lint + test
Contributing
See CONTRIBUTING.md.