A small Go daemon that proactively scans a list of domains for SSL, DNS and HTTP hygiene issues and exposes the results as Prometheus metrics. It is a reference implementation distilled from tooling I have built and operated in production; it is intentionally compact and dependency-light so the whole codebase can be read in one sitting.
| Area | Findings |
|---|---|
| SSL/TLS | Certificate expiry (with configurable warning window), invalid chains, servers still accepting TLS 1.0/1.1 |
| DNS | Domains that no longer resolve, dangling CNAMEs (subdomain-takeover risk) |
| HTTP | 5xx from the root path, missing security headers (HSTS, CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy) |
go build -o domain-scanner .
cp configs/domains.example.yaml configs/domains.yaml # edit the domain list
# One-shot mode: prints findings, exits 1 if any issue was found.
# Useful in CI or cron.
./domain-scanner -config configs/domains.yaml -once
# Daemon mode: rescans on an interval, serves Prometheus metrics.
./domain-scanner -config configs/domains.yaml -listen :9315
curl -s localhost:9315/metrics | grep domain_scannerAll metrics live under the domain_scanner_ namespace:
| Metric | Type | Labels | Meaning |
|---|---|---|---|
cert_expiry_seconds |
gauge | domain |
Seconds until leaf cert expiry (negative = expired) |
cert_valid |
gauge | domain |
1 if the chain verifies against system roots |
tls_weak_protocol |
gauge | domain |
1 if the server accepts TLS < 1.2 |
dns_resolvable |
gauge | domain |
1 if the domain resolves |
dns_dangling_cname |
gauge | domain |
1 if a CNAME points at a non-existent target |
http_status_code |
gauge | domain |
Status of GET https://<domain>/ |
http_security_header_missing |
gauge | domain, header |
1 per missing recommended header |
check_errors_total |
counter | domain, check |
Failed check executions (timeouts, resolver errors) |
scan_duration_seconds, last_scan_timestamp_seconds |
gauge | — | Scan health |
- alert: CertificateExpiringSoon
expr: domain_scanner_cert_expiry_seconds < 14 * 86400
for: 1h
labels: {severity: warning}
- alert: DanglingCNAME
expr: domain_scanner_dns_dangling_cname == 1
labels: {severity: critical}
- alert: ScannerStale
expr: time() - domain_scanner_last_scan_timestamp_seconds > 3600
labels: {severity: warning}A hardened systemd unit is provided in deploy/domain-scanner.service:
sudo useradd --system --shell /usr/sbin/nologin domain-scanner
sudo install -m 0755 domain-scanner /usr/local/bin/
sudo install -Dm 0644 configs/domains.yaml /etc/domain-scanner/domains.yaml
sudo install -m 0644 deploy/domain-scanner.service /etc/systemd/system/
sudo systemctl enable --now domain-scanner- Checks are independent packages under
internal/checks; each returns a plain result struct and never touches metrics, so they are easy to test and reuse. - DNS runs first: an unresolvable domain short-circuits the TLS/HTTP probes to keep noise out of the metrics.
- On a chain-verification failure the scanner retries the handshake without verification so it can still report expiry data for the bad certificate.
- The weak-TLS probe performs a second handshake capped at TLS 1.1; nothing is transmitted over that connection.
- Bounded concurrency (
concurrencyin the config) keeps the scanner polite to resolvers and origins.
make test # unit tests with the race detector
make cover # coverage report, opens in a browser
make vet lint # go vet, golangci-lint
make scan # single scan against configs/domains.example.yaml
make docker # container imageNetwork checks are the awkward part of a scanner: the interesting behaviour only shows up against endpoints that are broken in specific ways, and no test should depend on some third party's certificate staying expired. So the code is split so the decisions are testable without a network:
- DNS —
checkDNStakes a small resolver interface, and the tests feed it canned NXDOMAIN and CNAME answers. The dangling-CNAME logic — the check with real security value — is covered directly, including the case where a CNAME and an NXDOMAIN arrive together. - TLS — the handshake runs against a local
httptestTLS server. Its certificate is self-signed, which exercises the insecure-retry path: expiry data must still be reported for a certificate that does not verify. - HTTP — a local server returns chosen header sets, and the tests assert which headers are reported missing and that the order stays stable, so successive scan outputs diff cleanly.
- Findings —
dnsIssues/sslIssues/httpIssuesare pure functions over result structs, so expiry thresholds and message wording are covered without any I/O at all.
Two behaviours are asserted that are easy to regress and annoying to debug in
production: a gauge must fall back to 0 when a condition clears (a stale 1
keeps an alert firing after the problem is fixed), and every exported metric
must carry a help string and the shared namespace prefix.
CI runs gofmt, go vet, go mod tidy verification, race-enabled tests with
a coverage summary, golangci-lint, govulncheck, and a smoke test of the
built binary that asserts the documented exit codes (2 for a bad config, 1
when a scan finds issues, 0 when it does not).
Coverage sits at ~86% for internal/checks and 100% for internal/metrics.
main is lower by design: the daemon loop and signal handling are left to the
smoke test rather than mocked into meaninglessness.
MIT — see LICENSE.