descry

A sink-agnostic, event-sourced HTTP uptime engine. A Check runs against a
Target, produces an Observation, mapped to a CloudEvents 1.0 event, handed to
an EventSink (stdout / file in v1); tenancy and routing ride along as an opaque
Labels map the engine never interprets, so it owes no particular consumer
anything. It runs in production in my homelab, watching real endpoints.
The part that is more than table stakes is the SSRF guard: two layers, the second
of which checks the already-resolved socket address inside
net.Dialer.ControlContext, so a rebound DNS answer is blocked before connect(2)
rather than after. Threat model and residual risks are in Security.
Install
As a binary:
go install github.com/fairbearlab/descry/cmd/descry@latest
As a library:
go get github.com/fairbearlab/descry
Design
docs/ARCHITECTURE.md is the producer/consumer seam
contract: what the engine owns and never delegates, what a consumer owns, and the
litmus test for whether a proposed change leaks consumer specifics into the
engine's exported types. Read it before adding a typed field to Observation.
60-second demo (stdout)
git clone https://github.com/fairbearlab/descry
cd descry
go run ./cmd/descry --config example.yaml
# prints CloudEvents 1.0 observations to stdout on the configured interval
File-sink replay demo
Edit example.yaml to switch the sink:
sink: file
file_path: events.jsonl
Then run:
go run ./cmd/descry --config example.yaml &
sleep 15
cat events.jsonl # one valid CloudEvent per line
Version
descry --version prints version, commit, and build date. Release builds stamp
them via -ldflags -X main.version=… (see .goreleaser.yaml); a plain
go build reports the dev defaults.
HTTP check options
httpcheck.New takes a timeout plus optional functional options:
import httpcheck "github.com/fairbearlab/descry/checks/http"
// Default: net/http's stdlib User-Agent ("Go-http-client/1.1"), no impersonation.
c := httpcheck.New(10 * time.Second)
// Override the User-Agent (e.g. to satisfy a WAF that blocks the default UA):
c := httpcheck.New(10*time.Second, httpcheck.WithUserAgent("MyMonitor/1.0 (+https://example.com)"))
WithUserAgent("") (or omitting the option) leaves the stdlib default in place —
the engine never impersonates a browser unless the caller explicitly opts in.
Security
The SSRF guard is best-effort, not a security boundary. Network egress
isolation is the real control and the deployer's responsibility.
The guard runs two layers:
-
Parse-time (Layer 1): A string-only allowlist/blocklist applied before any
DNS lookup. Blocks private RFC 1918 ranges, loopback, link-local, CGNAT,
TEST-NET ranges, reserved blocks, and non-standard ports. Literal IPv6
addresses are decoded for known embedding classes (::ffff:x.x.x.x).
-
Dial-time (Layer 2): A net.Dialer.ControlContext hook runs on the
already-resolved socket address before connect(2) — no TOCTOU window.
Applies to every dial attempt including Happy Eyeballs and each redirect hop.
Residual risks the guard does not cover:
- Kernel/network routing manipulation that maps a public-looking address to an
internal host after the Layer 2 check — e.g. policy routing, NAT/NAT64, or a
VPN/overlay that redirects an allowed destination IP to an internal endpoint.
(Classic DNS rebinding is not a residual risk here: Layer 2 re-checks the
resolved socket address on every dial, so a later private DNS answer is blocked
locally before connect rather than bypassing the guard.)
- IPv6 embedding classes not explicitly decoded (unknown future embedding schemes).
- Non-HTTP protocols tunneled over allowed ports 80/443.
For production use, run descry inside a network namespace or egress firewall that
enforces the isolation policy independently of this guard.
License
Apache-2.0. See LICENSE.