API reference
The v1 wire protocol: enrollment, signed report submission, the feed, and the DNSBL zone.
Base URL: https://api.minsec.io. All request and response bodies are JSON unless
stated otherwise. Errors are:
{"error": "code", "message": "human readable, optional"}
This is the contract minsec-sync implements. You do not need any of it to use
minsec — but the feed and DNSBL endpoints are public, and the signing construction
is here so a third-party agent is possible.
Enrollment
Unattended, and gated by a proof-of-work. The agent generates an Ed25519 keypair on
first run, keeps the private key (mode 0600) forever, and stores the host_id it
receives.
POST /v1/enroll/challenge
Request body {}.
200 {
"challenge": "<opaque base64url string>",
"difficulty": 20,
"expires_in": 300,
"algo": "sha256-pow-v1"
}
429 with Retry-After when the per-IP enrollment rate limit is hit. The challenge
is opaque, single-use, expires after expires_in seconds, and is bound to the
requesting address — solve and enroll from the same address, promptly.
Proof of work (sha256-pow-v1)
Find an ASCII nonce such that sha256(challenge || nonce) — concatenating the
raw ASCII strings — has at least difficulty leading zero bits. Difficulty 20
is roughly one to five seconds of one CPU core.
POST /v1/enroll
{
"challenge": "<as issued>",
"pow_nonce": "<solution>",
"pubkey": "<base64 (std) 32-byte Ed25519 public key>",
"agent_version": "minsec-sync/0.1.0",
"install_token": null
}
201 {
"host_id": "<uuid v7>",
"report_url": "/v1/reports",
"feed_url": "/v1/feed",
"min_report_interval": 3600
}
min_report_interval is the server's advertised floor, in seconds, between
report submissions from one host. It is 3600 on the free tier — the packaged
minsec-sync.timer runs hourly to match — and a client should treat a larger
value returned here as authoritative rather than assuming the default.
Failures: 403 challenge_rejected (invalid, expired, wrong IP, or already used —
request a fresh challenge and re-solve), 403 pow_rejected, 400 bad_pubkey,
409 already_enrolled (this key already has a host_id; keep using the stored one).
Report submission
Request signing
Reports carry a detached Ed25519 signature over this canonical string — newline-separated, no trailing newline:
minsec-report-v1
<host_id>
<timestamp>
<hex(sha256(request body bytes))>
timestamp is unix seconds, decimal, exactly as sent in the header; the server
rejects anything more than ±300 s from its clock. The digest is over the exact
body bytes sent — there is no JSON canonicalisation anywhere in this protocol.
Content-Type: application/json
X-Minsec-Host: <host_id>
X-Minsec-Timestamp: <unix seconds>
X-Minsec-Signature: <base64 (std) 64-byte signature>
Every authentication failure — unknown host, bad signature, skewed timestamp,
revoked agent — returns the same 401 {"error":"unauthorized"}.
POST /v1/reports
Body ≤ 1 MiB, ≤ 1000 items per batch.
{
"seq": 42,
"agent_version": "minsec-sync/0.1.0",
"reports": [
{"ts": 1724371190, "ip": "203.0.113.7/32", "filter": "sshd", "count": 5, "ban_ttl": 3600},
{"ts": 1724371195, "ip": "2001:db8:1:2::/64", "filter": "postfix-sasl", "count": 3, "ban_ttl": 7200, "escalation": 2}
]
}
seqmust be strictly increasing per agent. A batch withseqat or below the last accepted value returns200 {"accepted":0,"rejected":0,"duplicate":true}and stores nothing — so retrying after a network failure is always safe.ipis a CIDR string: IPv4/24–/32, IPv6/48–/64. Anything narrower than/64is aggregated to/64server-side.filtermatches^[a-z0-9._-]{1,64}$. It is an opaque rule name, mapped through the signature registry to a category.escalationis optional, 0–255: the reporter's repeat-offender depth for this address.- Report only automatic bans. Never manual bans, never unbans.
- Invalid items are dropped and counted in
rejected; they never fail the batch. Items past the day's remaining quota are dropped the same way, so a batch that straddles the ceiling returns a smalleracceptedthan the number of items sent; the next batch gets429 quota_exceeded.
200 {"accepted": 17, "rejected": 1}
Other responses: 413 too_large, 400 too_many_items, 429 quota_exceeded
(daily per-agent quota — honour Retry-After).
Blocklist feed
GET /v1/feed/{tier}/{family}
tier ∈ basic | high, family ∈ v4 | v6. Public. Full details and the
delta protocol: blocklist feed.
Signature categories
Every reported filter name resolves to exactly one category:
| Category | Meaning |
|---|---|
mail-auth | credential abuse against mail submission or webmail (postfix-sasl, dovecot, roundcube) |
mail-mx | dictionary / RCPT abuse at the MX (postfix) |
web-auth | web application login brute force (wordpress, apache-auth, nginx-auth) |
web-exploit | exploitation attempt against a known vulnerability |
infra | generic infrastructure abuse (sshd, proftpd, webmin) |
unknown | no registry entry — scored for visibility, never published |
Quorum is counted per (address, category). An agent labelling all of its reports with a single signature name still contributes exactly one vote toward that category, so a category listing always requires independent reporters that agree on the category. Votes are weighted by reporter reputation and by the signature's registered confidence.
DNSBL zone
GET /v1/dnsbl/{family}
family ∈ v4 | v6. An rbldnsd zone file. Public. Bit assignments and
SpamAssassin rules: DNSBL for mail.
Health
GET /healthz— process liveness,200 ok.GET /readyz— readiness (database reachable),200 okor503.