A webhook server for running commands securely
  • Go 99%
  • Just 0.8%
  • Dockerfile 0.2%
Find a file
Renovate Bot f3b9db2688
All checks were successful
CI / lint (push) Successful in 1m57s
CI / test (push) Successful in 1m47s
CI / build (push) Successful in 1m30s
chore(deps): update dependency golangci/golangci-lint to v2.11.3 (#11)
Reviewed-on: #11
Co-authored-by: Renovate Bot <renovate@onlyhavecans.works>
Co-committed-by: Renovate Bot <renovate@onlyhavecans.works>
2026-03-10 08:08:57 -07:00
.forgejo/workflows chore(deps): update dependency golangci/golangci-lint to v2.11.3 (#11) 2026-03-10 08:08:57 -07:00
internal docs: proper ipv6 2026-02-26 15:35:17 -08:00
testdata lint: fix up openapi and endpoints 2026-02-25 21:41:02 -08:00
.gitignore fix: add config.toml to ignore 2026-02-26 09:56:00 -08:00
.golangci.yaml inital: first version 2026-02-25 20:10:39 -08:00
.goreleaser.yaml ci: send container to dockerhub 2026-02-26 08:05:36 -08:00
.markdownlint-cli2.yaml lint: add more & fix 2026-02-25 21:17:15 -08:00
CHANGELOG.md release: v0.1.1 2026-02-27 16:05:39 -08:00
config-example.toml docs: proper ipv6 2026-02-26 15:35:17 -08:00
CONTRIBUTING.md docs: add contributing 2026-02-26 10:07:08 -08:00
Dockerfile ci: add a goreleaser 2026-02-25 21:41:50 -08:00
go.mod fix: use 1.25 for a bit 2026-02-26 15:35:03 -08:00
go.sum inital: first version 2026-02-25 20:10:39 -08:00
Justfile lint: add more & fix 2026-02-25 21:17:15 -08:00
LICENSE fix: LICENSE missing info 2026-02-26 09:55:03 -08:00
main.go fix: magix nubmers to const 2026-02-26 09:55:47 -08:00
openapi.yaml feat: add per command lock 2026-02-26 09:18:50 -08:00
README.md docs: proper ipv6 2026-02-26 15:35:17 -08:00
renovate.json chore: Configure Renovate (#1) 2026-02-26 07:55:52 -08:00

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

  1. Create a config file:

    cp config-example.toml config.toml
    # edit config.toml — add your commands and API keys
    
  2. Start the server:

    spider_webhook -config config.toml
    
  3. 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 $PATH or 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.