Blackhole is a ban system for a single Minecraft network, built to be GDPR-compliant by design. One backend deployment serves one network's staff, players, punishment history and integrations, while keeping personally identifiable data (IPs, raw player UUIDs) hashed or tokenized wherever it's stored or published.
Punishment decisions are driven by a dual-ELO system: every player carries a separate
chatElo and gameplayElo score. Reports and a pluggable chat-toxicity scorer feed deltas into
these scores, and crossing a configurable soft/hard threshold automatically applies a temporary
or permanent punishment — without requiring a moderator to be online. A structured appeal
workflow lets players contest both automatic and manual punishments.
| Module | What it is |
|---|---|
backend |
The Micronaut HTTP API — punishments, reports, ELO, appeals, dashboard. |
velocity |
A Velocity proxy plugin that enforces punishments and feeds chat/session data to the backend. |
client |
A Java API client generated from client/specs/blackhole-api-*.yml via OpenAPI Generator. |
phoca |
Shared utility library used across modules. |
web |
The admin web UI (Vue 3, Material Design 3): dashboard, players, templates, sections, reports, appeals. |
The backend is event-driven: NATS JetStream backs a domain event bus (subjects blackhole.events.<type>,
stream BLACKHOLE_EVENTS) that other services can consume, see docs/nats-event-bus.md. Hot reads (punishment profiles, ELO profiles, templates and player
resolutions) are served from one shared Redis-protocol cache (Dragonfly in production), so every
replica reads and evicts the same entries and the API can be scaled horizontally.
Every route except GET /health needs a Microsoft Entra ID access token; see
Authentication.
web/ is a browser UI for admins and staff. Run it with cd web && npm ci && npm run dev (proxies
/api/ to a backend on localhost:8080), or as a container with docker compose -f docker/docker-compose.yml up -d web (http://localhost:8081). Staff sign in with Entra ID. See
web/README.md for the container variables and development.
The Helm chart in deploy/charts/blackhole runs the backend, the admin
panel, the appeal portal, the Discord bot and a ClamAV scanner; the images and the chart are published to Harbor
by the release workflow. The operator runbook is docs/kubernetes.md.
- Backend: Java 21+, Micronaut, Hibernate/JPA, Flyway (PostgreSQL), NATS JetStream, Dragonfly (Redis protocol) as the shared cache, Micrometer + OpenTelemetry.
- Proxy plugin: Velocity.
- Build: Gradle (Kotlin DSL), multi-module.
- Releases: release-please, driven by Conventional Commits — see Contributing.
Blackhole signs staff in with Microsoft Entra ID (the Free tier is enough). Every route except
GET /health needs an access token as Authorization: Bearer; the backend verifies it against the
tenant's published keys and keeps no session. There is no setting that turns authentication off.
- Staff sign in to the admin UI (Authorization Code flow with PKCE). Their role comes from the
groupsclaim: the object ids of the Entra security groups are mapped to roles withBLACKHOLE_AUTH_ROLE_ADMIN,_MODERATOR,_VIEWERand_METRICS. - The Velocity plugin and a Prometheus scraper use app-only tokens (client credentials) whose
rolesclaim holds the app roleBlackhole.ProxyorBlackhole.Metrics. - A signed-in person in no mapped group has no role: every protected route answers 403 and the UI shows
a "no access" page.
GET /auth/mereturns the caller's display name and roles.
| Role | May |
|---|---|
VIEWER |
read everything |
MODERATOR |
and: apply, revoke and adjust punishments, resolve reports, decide appeals, add evidence |
ADMIN |
and: configure sections and templates, delete evidence and profiles, import, anonymize staff data |
PROXY |
the routes the proxy needs (profiles, punishments, reports, evidence) with onBehalfOf |
METRICS |
GET /prometheus |
The full route matrix is in openspec/changes/archive/2026-10-23-add-team-sso/design.md and enforced by a test.
Verified actor. The actor of every write is the oid of the token and is stored as
recorded_by_oid; the former client-supplied source, resolvedBy and reviewerId fields are gone
(an old client that still sends them is ignored). Rows written before this release have no
recorded_by_oid and are shown as unverified. The proxy may send onBehalfOf for actions that
staff took in game; see Linking a Minecraft account for how an in-game
action is traced to a person. Staff cannot review their own punishment.
Staff data and retention. (Pending legal confirmation.) Stored per staff member: oid,
display_name, the linked Minecraft account (see below) and timestamps only. Retention: as long as
records referencing the person exist (punishment history is itself retained); a link lasts as long as
the person is staff and ends when it is removed or the person is anonymized. On a leaving or erasure
request an admin calls POST /admin/staff-actor/{oid}/anonymize, which nulls display_name and
removes the person's Minecraft link (the oid stays; once the Entra account is deleted it no longer
resolves to a person). The purpose of the link is accountability of moderation decisions.
A punishment issued in game by /ban and the like reaches the backend from the proxy, which only
knows a Minecraft UUID. Staff link that UUID to their sign-in once, so that the action is attributed to
the person and not to a bare UUID.
- Sign in to the admin UI, open Konto (en: Account) in the top bar and choose Minecraft-Konto
verknüpfen (Link Minecraft account). The panel shows a code of 8 characters (for example
ABCD-EFGH) with a countdown. - In game, with the account to link, run
/blackhole link <code>. A code is single use and valid forBLACKHOLE_STAFF_LINK_TTL(defaultPT5M); issuing a new one invalidates the previous one. - Check with
/blackhole whoami, which names the staff account the Minecraft account is linked to.
Both commands are player-only and need the Velocity permission blackhole.link.
Operator rule: grant blackhole.link to the LuckPerms groups of staff only, never to a default or
player group. The code proves that the person typing it holds the staff member's session, but the
protection against someone else guessing a live code rests on the permission: there is deliberately no
failed-attempt limit in the application (guessing is bounded by the 40 bits of the code and its lifetime;
rate limiting belongs to the infrastructure). Anyone who may run the command may try codes.
- One person has one Minecraft account and one account belongs to one person. Linking another account replaces the old link, and the replacement is published as an event.
- Attribution. From the link on, in-game punishments, reports and evidence created by that account
carry the person (
actor_oid), and the punishment timeline in the UI shows the name. - No backfill. Linking does not rewrite earlier entries: the account was only proven to belong to the person now, and history stays as it was recorded. Older entries keep showing the Minecraft UUID.
- Self-review. A person cannot decide the appeal of a punishment they issued: the check compares the
reviewer with the recorded actor, with
actor_oidand with the current link of the punishment's issuer, so it also covers in-game punishments from before the link existed. Another moderator can decide it. - Unlinking. Staff remove their own link in the same panel (
DELETE /staff-link/me); an admin lists all links under Linked Minecraft accounts (GET /admin/staff-link) and removes one (DELETE /admin/staff-link/{oid}). Earlier entries keep their attribution. Anonymizing a person removes the link as well. - The code and its hash never appear in logs or events;
staff-link.createdandstaff-link.removedcarry theoidonly.
Setup. docs/entra-setup.md explains the app registrations and groups and
ships docs/entra-setup.sh, an idempotent Azure CLI script.
This is a breaking release: callers without a token get 401. Move everything in one window, in this order (between steps 2 and 4 the plugin and UI cannot reach the backend):
- Entra. Register the apps and groups (docs/entra-setup.md).
- Backend. Set
BLACKHOLE_AUTH_TENANT_ID,BLACKHOLE_AUTH_AUDIENCE(API client id),BLACKHOLE_AUTH_ISSUER(https://login.microsoftonline.com/<tenant-id>/v2.0),BLACKHOLE_AUTH_JWKS_URL(https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys) andBLACKHOLE_AUTH_ROLE_ADMIN,_MODERATOR,_VIEWER,_METRICS(group object ids) andBLACKHOLE_AUTH_MACHINE_CLIENTS_PROXY,_METRICS,_DISCORD(client ids, theazpof the app-only tokens; a machine role is refused for any other client). The defaults point at a local mock server and are refused outside dev. Optional:BLACKHOLE_AUTH_ROLES_CLAIM_HUMAN(groups),BLACKHOLE_AUTH_ROLES_CLAIM_MACHINE(roles),BLACKHOLE_AUTH_MACHINE_ROLE_PROXY,BLACKHOLE_AUTH_MACHINE_ROLE_METRICS,BLACKHOLE_DOCS_ENABLED(Swagger UI, defaultfalse),BLACKHOLE_STAFF_LINK_TTL(link code lifetime, defaultPT5M). The database migrations (V9,V10) run on start and keeps old actor values. - Velocity plugin. Add the
authobject toconfig.json:{"auth": {"tenantId": "", "clientId": "", "clientSecret": "", "scope": "api://<api-client-id>/.default"}}. Each key can be overridden byBLACKHOLE_TENANT_ID,BLACKHOLE_CLIENT_ID,BLACKHOLE_CLIENT_SECRETandBLACKHOLE_AUTH_SCOPE, which win over the file; prefer the environment for the secret.auth.tokenUrl(BLACKHOLE_TOKEN_URL) overrides the token endpoint for a mock server. It must behttps;httpto a loopback host is accepted only whenMICRONAUT_ENVIRONMENTSisdevortest. With incomplete credentials the plugin logs an error and keeps enforcing for players it already has cached, but backend-bound commands are refused. Grant the permissionblackhole.linkto the staff LuckPerms groups so that staff can link their Minecraft account; grant it to nobody else. - Admin UI. Set
BLACKHOLE_UI_OIDC_AUTHORITY,BLACKHOLE_UI_OIDC_CLIENT_IDandBLACKHOLE_UI_API_SCOPEon the web container (required; it refuses to start without them) and dropBLACKHOLE_UI_SOURCEandBLACKHOLE_UI_REVIEWER. - Other callers. Any script that calls the API directly now needs a token. Regenerate clients from
the current
client/specs/blackhole-api-*.yml. Prometheus scrapes/prometheuswith aBlackhole.Metricstoken.
The proxy learns about punishments applied elsewhere from the backend's events, over NATS with a
subscribe-only credential (blackhole-velocity, WebSocket-only; the commands to create it are in
docs/nats-event-bus.md). Add to config.json:
{"nats": {"url": "wss://nats.onelitefeather.dev", "credsFile": "/path/to/blackhole-velocity.creds", "cacheMaxAge": "PT60S"}}NATS_URL and NATS_CREDS_FILE override url and credsFile; keep the .creds file out of git.
cacheMaxAge (ISO-8601, default 60 seconds) bounds how long a cached player state is trusted while
connected. The connection is made in the background and never delays startup or a login. While the
proxy is not connected, or when credsFile is missing or unreadable, the cache is discarded and every
login and chat check asks the backend over HTTP; nothing is ever allowed merely because NATS is down,
and if the backend cannot answer either, the existing backend-unreachable rule applies (login allowed,
chat delivered). Staff notices for new reports arrive as report.created events: each proxy reads the
report from the backend (the proxy's token needs the PROXY role, which may read a single report) and
takes the player names from its online players.
Upgrading from the Redis-based plugin (breaking). The plugin no longer reads redisUri and has no Redis or
Dragonfly client: it enforces over HTTP plus NATS only. Set nats.url and nats.credsFile (or NATS_URL and
NATS_CREDS_FILE) before updating. A leftover redisUri is ignored with one warning at startup; remove it
from config.json and drop the proxy's access to Dragonfly. The tab completion of sections and templates now
reads the backend over HTTP with a short local cache (30 seconds for templates, and the section list is
refreshed on every section.changed event and every 5 minutes).
Staff never decide that someone is banned forever. They apply a temporary punishment from a template; each application costs ELO in the template's section, and when a section score falls below its permanent threshold the system itself replaces the active punishment with a permanent one.
A section owns one ELO score per player, its thresholds, its permanent-ban reason and its pardon score, and says how it is enforced:
| Enforcement | Effect on the proxy |
|---|---|
BAN |
The player cannot log in. |
MUTE |
The player's chat messages are not delivered. |
SERVER |
The player cannot connect to backend servers the section targets: glob patterns, or CloudNet tasks, groups or services. |
Two sections are seeded: chat (MUTE) and network (BAN). Operators add more through
POST /section, for example bedwars as SERVER with the pattern bedwars-* (* matches any run
of characters, ? one character, case-insensitive). A player stays on the network when a SERVER
section punishes them; if they stand on a matching server when it arrives they are moved to the
fallbackServer of the proxy config (default lobby), or disconnected if there is none.
There is one active punishment per player and section: a mute never lifts a ban.
A SERVER section normally lists glob patterns for the Velocity server names. On a network that runs
CloudNet it can instead name CloudNet tasks, groups or single
services, so a ban on BedWars covers every BedWars-<n> instance without a pattern.
Requirements. CloudNet 4.0.0-RC17 or newer (4.x has no stable release yet, all versions are
release candidates), and Velocity running as a CloudNet service with the Bridge module, which
registers every started CloudNet service in Velocity under its service name. The plugin only reads
CloudNet's driver API; it does not bundle it and the backend never talks to CloudNet. Without
CloudNet the plugin behaves as before and PATTERN sections keep working.
Target kinds. A section has one targetKind (set it through POST /section or the "Target"
selector of the admin UI); servers then holds the patterns or the exact names:
targetKind |
UI label | servers holds |
Covers a server when |
|---|---|---|---|
PATTERN (default) |
Namensmuster | glob patterns (bedwars-*) |
the pattern matches the server name (needs no CloudNet) |
CLOUDNET_TASK |
CloudNet-Task | exact task names (BedWars) |
the service belongs to one of the tasks |
CLOUDNET_GROUP |
CloudNet-Gruppe | exact group names (Games) |
the service is in one of the groups |
CLOUDNET_SERVICE |
CloudNet-Service | exact service names (BedWars-3) |
the service name equals one of the names |
CloudNet names are letters, digits, _, . and - without wildcards, and are compared ignoring
case. The backend does not check them against CloudNet: a section may name a task that does not
exist yet, and then simply covers nothing. The admin UI lists the names that the proxies report
and marks a name that is not among them as "Nicht im Katalog" / "Not in catalogue"; it also accepts
typed names, and it is the only way to enter names when no catalogue has been reported.
Service names are not stable. A CLOUDNET_SERVICE target is the service name. After a restart
the name can belong to a different instance (dynamic services get the lowest free number), so the
ban follows the name, not the instance, and survives restarts. Use a task or a group for lasting
bans; the UI shows this warning when you pick the service kind.
Unknown servers are not covered (fail open). If CloudNet does not know the server a player is
connecting to, for example a server registered by hand in velocity.toml, or the plugin runs without
CloudNet, a CloudNet section does not match and the player may connect. The proxy logs a warning once
per server name (and at startup when CloudNet sections exist but CloudNet is unavailable). This is
deliberate: the same rule as for a state outage, where an outage never locks players out, and the
Bridge only registers CloudNet services, which CloudNet knows. Cover hand-registered servers with a
PATTERN section. BAN and MUTE sections are network-wide and unaffected. Editing a section does
not move players who are already online; a player who stands on a server that a new punishment
covers is moved to the fallbackServer, or disconnected if the fallback is covered too.
Configuration (the cloudnet object in the plugin's config.json):
{
"cloudnet": {
"integration": "auto",
"proxyId": "",
"reportDebounce": "PT5S",
"heartbeat": "PT5M"
}
}| Key | Default | Meaning |
|---|---|---|
integration |
auto |
auto uses CloudNet when the plugin runs as a CloudNet service; off never probes for it. Any other value counts as auto. The environment variable BLACKHOLE_CLOUDNET_INTEGRATION wins over the file. |
proxyId |
empty | The id this proxy reports its catalogue under. Empty: the proxy's own CloudNet service name (Proxy-1). Set it when the proxy has none; without either, nothing is reported (one warning). |
reportDebounce |
PT5S |
How long changes are coalesced before one report goes out (ISO-8601, at least one second). |
heartbeat |
PT5M |
Keep-alive period of the report (ISO-8601, at least one second). |
A missing or invalid value falls back to its default.
Catalogue. For the picker in the admin UI each proxy reports its CloudNet tasks, groups and
services to the backend (PUT /cloudnet/catalogue, always a full snapshot): at startup, shortly
after any change (debounced by reportDebounce) and as a heartbeat every heartbeat (5 minutes).
The backend keeps one snapshot per proxy in the shared cache for 15 minutes
(blackhole.cloudnet.catalogue.ttl, default PT15M), so the entries of a stopped proxy disappear on
their own, and GET /cloudnet/catalogue merges the live ones. A failed report is only logged and
never affects enforcement; if the cache is down the UI falls back to free text.
| Route | VIEWER |
MODERATOR |
ADMIN |
PROXY |
|---|---|---|---|---|
PUT /cloudnet/catalogue |
no | no | no | yes |
GET /cloudnet/catalogue |
yes | yes | yes | no |
Only the proxy may report, so a human token cannot forge what the editor trusts; any signed-in staff
role may read the catalogue (it holds service names, no player data). Creating and editing sections
stays ADMIN-only.
Rollout. Deploy the backend first (the new target_kind column defaults to PATTERN), then
upgrade the proxies, and only then create CloudNet sections. A proxy from before this release reads
the names of a CloudNet section as globs, which do not match BedWars-1, so it does not enforce them.
Delete or disable CloudNet sections before rolling back.
A template has a key (what staff type), a section and ordered steps, each with a duration
(ISO-8601, greater than zero) and an eloCost (zero or more). The step is chosen by how many earlier
punishments the player has in the template's escalation group (default: the template key) within the
escalation window (default 365 days, blackhole.punishment.escalation.default-window) that were not
revoked or lifted by an appeal; beyond the last step the last step repeats. Applying a template
deducts the step's cost; if the section score is then below its permanent threshold and no permanent
punishment is active, the player is punished permanently. A staff revoke of a temporary punishment
does not refund its ELO cost; a granted appeal that fully lifts it does. Lifting a permanent
punishment (revoke or appeal) sets the section score to the section's pardon score.
| Command | Permission | What it does |
|---|---|---|
/ban <player> <template> |
blackhole.ban |
Applies a template of a BAN or SERVER section. |
/mute <player> <template> |
blackhole.mute |
Applies a template of a MUTE section. |
/punish <player> <template> |
blackhole.punish |
Applies a template of any section. |
/unban <player> [section] |
blackhole.unban |
Lifts the active ban (section defaults to network; BAN/SERVER). |
/unmute <player> [section] |
blackhole.unmute |
Lifts the active mute (section defaults to chat). |
/elo <player> |
blackhole.elo.view |
Shows the score, thresholds and permanent state of every section. |
/blackhole <player> info |
blackhole.user.info |
Shows the active punishments. |
/blackhole <player> history <sort> |
blackhole.ban.history |
Shows the punishment history. |
| (lifting a permanent punishment) | blackhole.pardon.permanent |
Additionally required by /unban and /unmute for permanent ones. |
<template> is the template key, tab-completed from the templates eligible for the command. The
player may be offline (a name the backend can resolve, or a UUID). No command accepts a duration or a
permanent flag. The former --server flag is gone: use a SERVER section. After applying, the sender
sees the section, how long it lasts, the step (n/m), the ELO change and, if the cost triggered it,
the permanent escalation. Upgrading from earlier releases: templates are selected by key instead of
by reason; deploy backend and proxies together.
Staff can decide which clients, client versions and commands reach the network. A client rule matches one of:
| Kind | Matches | Who observes it |
|---|---|---|
BRAND |
the minecraft:brand text (glob, case-insensitive), optionally negated |
the client announces it |
MOD |
a mod id of the legacy Forge mod list (Forge up to 1.12 only) | the client announces it |
CHANNEL |
a plugin channel the client registered (glob) | the client announces it |
PROTOCOL |
a range of protocol versions, optionally negated (an allowlist) | the proxy itself |
COMMAND |
the first word of a command line, also for commands forwarded to a backend server | the proxy itself |
Be honest about what this is. Brand, mods and channels are chosen by the client and can be faked
(a mod can send vanilla, or no brand at all), and modern Forge, NeoForge and Fabric mod lists are not
visible to a proxy. A hit is a signal, not proof, and a miss proves nothing. Rules on those values are a
deterrent against careless users and a hint for the team; only protocol and command rules act on facts
the proxy observed. That is why the default action is notify.
Actions. Each rule has one:
| Action | What happens |
|---|---|
NOTIFY |
(default) The hit is recorded and online staff are told. Nothing changes for the player. Also the dry run: the hit counts show what a rule would do. |
DENY |
Additionally refuses the login (global protocol rules), the connect, a disconnect (a trait that arrives late) or the command, with an English message written on the rule (default per kind, at most 200 characters). |
TEMPLATE |
Additionally applies a punishment template through the ELO automatic path: no human actor, a Ban ID, the step and ELO cost, open to appeal. Applied once while the player has an active punishment from the same rule; never for staff with the bypass permission. On a brand, mod or channel the rule must carry an explicit acknowledgement that the value can be faked. |
A DENY on a brand, mod or channel refuses as soon as the trait is known: at the first connect if it is
known by then, otherwise the player is disconnected when it arrives. Those rules never refuse a login,
because the traits are not reliably known then. Player-facing messages are English only.
Scope. A rule is global or limited to servers, with the same targeting as sections: the keys of
SERVER sections (read live) and/or glob patterns or CloudNet tasks, groups and services of its own.
A section deleted later covers nothing (fail open). Command rules use the server the player is on.
Command rules and SignedVelocity. The first word is matched after removing leading spaces and a
slash and lower-casing; namespace:label is matched as written and as label, so minecraft:op cannot
slip past a rule for op. Arguments are never matched. A denied command is neither run nor forwarded.
Velocity kicks a player whose command with signed arguments a plugin cancels, so such a command is
denied only when SignedVelocity is loaded on the proxy;
otherwise it is delivered, the player is told it is not allowed and the hit is recorded as not enforced.
The startup warning names both chat and commands. A rule can also hide its commands from tab completion
(cosmetic only; the denial is the enforcement).
Roles and permissions.
| Who | May |
|---|---|
ADMIN |
create, change and delete rules |
MODERATOR, VIEWER |
read rules, hits and the catalogue (GET /client-rule...) |
PROXY |
read rules, report hits, the catalogue and fingerprints |
blackhole.clientrules.notify (in game) |
is told about hits, once per hit, on every proxy |
blackhole.clientrules.bypass (in game) |
is never refused or punished by a rule; hits are recorded as bypassed |
Hits and privacy. A hit stores only the rule key, the hashed owner, the time, the action, whether it
was enforced or bypassed and the server name. It never stores the brand, channel, mod, command text or
the value a glob matched, and the client-rule.hit event carries none either. Hits are deleted after
blackhole.clientrules.hit-retention-days (default 30) while the rule keeps a total count, and with the
player's data on erasure. The in-game notice carries the player's name, travels over Redis pub/sub only
and is never stored.
Catalogue. The rule editor suggests brands and channels that at least three distinct players showed
(clientRules.catalogue.minPlayers), the command words of the proxy's command tree (never what players
typed) and the protocol versions the proxy supports. Names are free text anyway; without a catalogue
(cache outage) the editor works the same and enforcement never depends on it.
Client fingerprint. The proxy submits brand, non-vanilla channels and the legacy mod list once they
settle (clientRules.settle, default PT5S after the first connect) and again only when they change. The
backend turns them into a keyed hash (HMAC-SHA256 under a key derived with HKDF from
BLACKHOLE_FINGERPRINT_KEY) and stores only the hash with the owner hash. It strengthens ban-evasion
recognition next to the hashed IP: the sightings of a player show the signal per linked account (IP,
CLIENT or BOTH). A link by the client setup alone is a weak hint and never punishes. Plain vanilla,
fabric, forge and neoforge clients are not stored; a fingerprint shared by more than five accounts inside
the window (a popular modpack) links nobody. Defaults: window 14 days, retention 30 days
(blackhole.evasion.fingerprint.*).
Proxy configuration (the clientRules object in the plugin's config.json):
{
"clientRules": {
"enabled": true,
"settle": "PT5S",
"catalogue": { "minPlayers": 3, "heartbeat": "PT15M" }
}
}BLACKHOLE_CLIENT_RULES=false switches rules, collection and fingerprint submission off on a proxy and
wins over the file. Unreadable or too small values fall back to the defaults.
- Deploy the backend (it adds three tables and routes; nothing changes without callers), set
BLACKHOLE_FINGERPRINT_KEY(32 random bytes, base64:openssl rand -base64 32; unset means no fingerprinting,503on the submit route), then upgrade the proxies, then the admin panel. - Create rules with the action
NOTIFY, read the hit counts for about a week, then switch chosen rules toDENYorTEMPLATE. Check false positives first: a channel a legitimate mod registers hits innocents. - Grant
blackhole.clientrules.notifyto the team andblackhole.clientrules.bypassto staff who must never be locked out by a rule. - Treat the fingerprint key as unrotatable in place: after a new key old and new fingerprints cannot be correlated. A rotation is a new scheme and key with a documented overlap, not an edit. Losing the key only costs the history of the signal. Back it up like any other secret.
- Roll back by disabling rules in the panel, or by removing the key; routes and tables are inert without callers.
Staff and tools can attach evidence to a punishment: anticheat records, chat excerpts, replay links
and uploaded screenshots or videos. Evidence can be sent before the punishment exists (with an owner
hash and a correlationId) and is claimed when the punishment is applied with the same
evidenceCorrelationId. Punishments and appeals only expose an evidenceCount; the content is read
through /punishment/{reference}/evidence and /evidence/{id} (see the Swagger UI).
Uploaded files go through a BlobStore. S3-compatible object storage is the default
(BLACKHOLE_EVIDENCE_FILES_STORAGE=s3); a directory on disk is available for tiny single-replica
setups (local). The docker compose stack runs SeaweedFS for development.
| Server | Licence | Notes |
|---|---|---|
| SeaweedFS | Apache-2.0 | Used in docker/docker-compose.yml; the documented self-hosted choice. |
| RustFS | Apache-2.0 | Upstream does not recommend it for production yet. |
| Ceph RGW | LGPL-2.1 | Production grade but heavy; fine if you already run Ceph. |
| Garage | AGPL-3.0 | Works through the same adapter; not recommended here because of the licence. |
| MinIO | AGPL-3.0 | Community edition is archived upstream (2026); not recommended. |
| AWS S3, Cloudflare R2, Backblaze B2, ... | n/a | Operator's choice; set BLACKHOLE_EVIDENCE_S3_ENDPOINT (empty for AWS S3). |
Every upload is scanned by ClamAV (clamd) before it is stored. The scan fails closed: while it is
enabled, an infected file is rejected (422) and an unreachable scanner rejects the upload (503).
ClamAV needs about 1 GB of RAM and downloads its signature database on first start, which takes a few
minutes. Only set BLACKHOLE_EVIDENCE_SCAN_ENABLED=false if uploads are scanned elsewhere.
| Variable | Purpose |
|---|---|
BLACKHOLE_EVIDENCE_FILES_STORAGE |
s3 (default) or local. |
BLACKHOLE_EVIDENCE_S3_ENDPOINT |
S3 endpoint; empty for AWS S3. |
BLACKHOLE_EVIDENCE_S3_BUCKET |
Bucket name (default blackhole-evidence). |
BLACKHOLE_EVIDENCE_S3_REGION |
S3 region. |
BLACKHOLE_EVIDENCE_S3_ACCESS_KEY |
S3 access key. |
BLACKHOLE_EVIDENCE_S3_SECRET_KEY |
S3 secret key. |
BLACKHOLE_EVIDENCE_S3_PATH_STYLE |
Path-style addressing (needed by most self-hosted servers). |
BLACKHOLE_EVIDENCE_FILES_LOCAL_PATH |
Directory for the local backend. |
BLACKHOLE_EVIDENCE_FILES_MAX_IMAGE_BYTES |
Largest image upload in bytes. |
BLACKHOLE_EVIDENCE_FILES_MAX_VIDEO_BYTES |
Largest video upload in bytes; raise BLACKHOLE_MAX_REQUEST_SIZE with it. |
BLACKHOLE_EVIDENCE_FILES_MAX_IMAGE_PIXELS |
Images with more pixels are rejected before decoding. |
BLACKHOLE_EVIDENCE_FILES_ALLOWED_TYPES |
Allowed types (PNG, JPEG, MP4, WebM); remove video/* to disable video. |
BLACKHOLE_EVIDENCE_SCAN_ENABLED |
Virus scan of uploads (default on). |
BLACKHOLE_EVIDENCE_SCAN_CLAMD_HOST |
Host of the ClamAV clamd. |
BLACKHOLE_EVIDENCE_SCAN_CLAMD_PORT |
Port of clamd (default 3310). |
BLACKHOLE_EVIDENCE_SCAN_CLAMD_TIMEOUT |
How long one scan may take (default PT30S). |
BLACKHOLE_EVIDENCE_MAX_PAYLOAD_BYTES |
Largest structured payload in bytes. |
BLACKHOLE_EVIDENCE_MAX_CHAT_LINES |
Most lines of one chat log item. |
BLACKHOLE_EVIDENCE_PENDING_TTL |
How long unclaimed evidence is kept (default P1D). |
BLACKHOLE_EVIDENCE_HOUSEKEEPING_INTERVAL |
How often the housekeeping sweep runs (default PT1H). |
BLACKHOLE_EVIDENCE_AUDIT_RETENTION |
Purge audit rows older than this; empty keeps them (default). |
- Evidence lives as long as its punishment record. There is no time-based expiry. It is deleted
when the punishment record is deleted, or by an admin through
DELETE /evidence/{id}orDELETE /evidence/owner/{owner}, which require areason, are audited and publishevidence.deleted. The housekeeping sweep only removes evidence nobody claimed withinBLACKHOLE_EVIDENCE_PENDING_TTLand stored files without a database row. - When staff revoke a punishment, its evidence is marked
REVOKED; when an appeal fully lifts it, the evidence is markedLIFTED. Nothing is deleted. Such evidence is not counted and is hidden unless a request passesincludeInactive=true; revealing it is audited. - Access follows the role. Every reader (
VIEWERand up) can read all evidence, including lifted and revoked evidence throughincludeInactive=true; the default hiding is not access control, but revealing it is audited with the verified actor. - Video metadata is not stripped. Images are re-encoded, which drops EXIF/GPS data; MP4 and WebM files are stored as uploaded. Ask staff to prefer screen recordings of the game, or disable video.
Because evidence is never expired, storage grows with the punishment history. Size the bucket or
volume for the expected history (for example the 100 MiB video limit times the videos you expect per
month times how long you keep punishment history), watch its usage, and lower
BLACKHOLE_EVIDENCE_FILES_MAX_VIDEO_BYTES or disable video if needed. Do not use S3 lifecycle
expiry rules on the bucket: deleting objects behind the application's back leaves rows that point at
missing files and breaks the audit trail. Delete through the admin path instead.
The software ties evidence to the punishment record; you decide how long the record is kept. You should:
- document the retention period of your punishment history and its justification, and the lawful basis for storing evidence (typically legitimate interest, Art. 6(1)(f), with a balancing test);
- name the evidence categories (chat excerpts, recordings, anticheat data, links) in your privacy notice and your records of processing (Art. 30);
- answer access requests (Art. 15) with
GET /evidence?owner=<hash>&includeInactive=true, and erasure or objection requests (Art. 17, 21) through the admin delete path, deciding each case individually (for example when the evidence is needed for legal claims).
Evidence refers to players only by hash or per-evidence alias; raw UUIDs and IP addresses in payloads are rejected, and IP addresses in chat text are redacted.
Since Minecraft 1.19.1 chat is signed, and Velocity disconnects a player whose signed message a plugin cancels ("A plugin tried to cancel a signed chat message"). To block a muted player's chat without that, the proxy plugin works together with SignedVelocity by 4drian3d. It has no API to call: Blackhole denies the chat event as usual, and SignedVelocity, which runs after all other handlers, turns that into an "allowed" message plus a cancel order that its backend plugin carries out.
- Install
SignedVelocity-Proxyin the Velocitypluginsfolder. - Install
SignedVelocity-Paperin thepluginsfolder of every backend server (Sponge, Fabric and Minestom builds exist as well). A server without it would still show the message. - Restart the proxy and the servers.
Required versions (SignedVelocity 1.5.0): Java 21+, Velocity 3.4.0+, Paper 26.1.2+ on the backend. Releases: https://github.com/4drian3d/SignedVelocity/releases.
SignedVelocity is required on OLF proxies. Without it the proxy logs an error at startup and reports the missing relay to the backend, and the admin panel shows a banner while any proxy lacks it. A muted player's messages are never delivered, but the proxy cannot cancel a signed message on its own, so a muted player on a 1.19.1+ client who sends signed chat is disconnected with the mute screen as the reason (a reconnect is allowed, and the disconnect is logged without the message text). Messages from clients older than 1.19.1 are unsigned and are denied directly with the mute notice, without a disconnect.
Muted players may not run the commands in mute.commands of the proxy's config.json (default
msg, tell, w, whisper, me, r, reply); matching ignores a namespace, so /minecraft:msg
is covered too. Other commands, such as /spawn, keep working. A listed command with signed arguments
follows the same rule as chat. Client command rules are separate: a signed command that a client
rule denies is still delivered without SignedVelocity, as described under
client command rules.
Every backend server a player can reach through the proxy needs the matching SignedVelocity backend
plugin before players are sent there: SignedVelocity-Paper (Paper or Fabric 26.1.2+), the Sponge 8
or 10 build, or the Minestom library 1.3.0+. Pin the proxy plugin to 1.5.0 or later (earlier proxy
builds before 1.3.0 have a known exploit). For each server, tick:
- Server name, backend build and SignedVelocity backend plugin installed (version)
- Server restarted after the plugin was installed
Adding a backend server means adding a line here and ticking the plugin before the server is listed in any section or fallback.
The Velocity plugin can attach the punished player's recent chat as CHAT_LOG evidence, so staff do
not have to screenshot. It is off by default. Enable it in the plugin's config.json:
{
"evidence": {
"chatCapture": {
"enabled": true,
"lines": 20,
"contextLines": 5,
"maxAge": "PT10M",
"sourceSystem": "velocity",
"timeout": "PT2S"
}
}
}| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Buffer public chat at all. Off: nothing is stored, the listener is not even registered. |
lines |
20 |
Public chat lines kept per player, 1 to 50. |
contextLines |
5 |
Lines of other players that preceded the punished player's lines, 0 to 50; 0 switches the third-party context off. |
maxAge |
PT10M |
How long a line stays buffered (ISO-8601 duration). |
sourceSystem |
velocity |
The sourceSystem of the evidence. |
timeout |
PT2S |
How long the proxy waits for the backend (uploading evidence, reading the evidence count). |
A missing or invalid value falls back to its default. How it works:
- The buffer lives in the proxy's memory only: it is never written to disk or logged, is lost on restart and is dropped when the player disconnects. Commands and messages that were not delivered (a muted player's) are never buffered.
- On
/ban,/muteor/punishthe proxy takes the target's lines and up tocontextLinespreceding lines of other players on the same server. Every speaker, the target included, is replaced by an alias (Spieler A,Spieler B, ...) assigned in order of first appearance within that one evidence item, so aliases cannot be matched across items; names, UUIDs and hashes never leave the proxy in the lines. URLs, IP addresses and UUIDs in the text are replaced by[url],[ip]and[uuid], and a line is cut at 256 characters. Names that players type into their text cannot be recognised and are kept. - The evidence is uploaded first, under a fresh
correlationId, and the punishment then claims it withevidenceCorrelationId. If the upload fails or exceedstimeout, the punishment is applied anyway and the staff member is warned; evidence of a punishment that then failed expires on the backend (pending-ttl). - A punished player sees how many pieces of evidence exist on the login and chat denial screens
("Beweise: 3" / "Evidence: 3"), never their content. The count is read from
GET /punishment/{reference}/evidence/count, cached for 30 seconds (also when it failed) and left out if the call fails or times out. This works for evidence from any source, whether or not chat capture is enabled.
GDPR. Enabling capture means the proxy temporarily processes everyone's public chat, including
third parties' lines used as context. Before enabling it you should: rely on a lawful basis (typically
legitimate interest, Art. 6(1)(f), with a documented balancing test; the plugin does not make that
decision for you), mention chat capture for moderation and its short RAM-only buffering in your
privacy notice and records of processing (Art. 30), and decide whether the third-party context is
needed (contextLines: 0 switches it off). Evidence created from it follows the retention rules above.
A docker-compose.yml under docker/ provides PostgreSQL, NATS with JetStream and Dragonfly (Redis protocol,
used for proxy sync and the shared cache), plus SeaweedFS and ClamAV for evidence uploads (ClamAV needs
about 1 GB of RAM and a few minutes on its first start):
cd docker
docker compose up -dThen run the backend against the real Entra dev registration (create it once with
docs/entra-setup.sh dev, which writes ~/.config/blackhole/entra-dev.env):
set -a; . ~/.config/blackhole/entra-dev.env; set +a
./gradlew :backend:runFor the admin UI see web/README.md. Tests never reach
Entra: they use self-signed JWTs. The compose file also starts a mock OIDC server (port 18090) whose
values are the defaults of application.yml, for working offline.
Key environment variables (all have local-dev defaults, see
backend/src/main/resources/application.yml for the full list):
| Variable | Purpose |
|---|---|
JDBC_URL / JDBC_USER / JDBC_PASSWORD |
PostgreSQL connection (defaults match docker/docker-compose.yml). |
NATS_URL / NATS_CREDS_FILE |
NATS server and the .creds file of the backend user (empty locally). |
NATS_CONNECTION_NAME / NATS_CONNECT_TIMEOUT |
Name shown on the server (blackhole-backend) and the connect timeout (5s). |
NATS_STREAM_REPLICAS |
Replicas of the stream BLACKHOLE_EVENTS: 1 locally, 3 in feather-core. |
NATS_EVENTS_MAX_AGE / NATS_EVENTS_MAX_BYTES |
How long (P7D) and how much (2 GiB) of the event history the stream keeps. |
REDIS_URI |
Redis-protocol server (Dragonfly) for the backend's shared cache. The Velocity plugin does not use it. |
BLACKHOLE_EVASION_IP_SALT |
Required for ban-evasion detection; that feature returns 503 until it's set. |
BLACKHOLE_FINGERPRINT_KEY |
Key of the client fingerprint (32 bytes, base64); unset means no fingerprinting outside dev and test, see Client rules. |
BLACKHOLE_AUTH_* |
Entra ID sign-in, see Authentication. |
Swagger UI is served at /swagger/views/swagger-ui when BLACKHOLE_DOCS_ENABLED=true (off by default).
./gradlew clean buildThe event bus moved from RabbitMQ to NATS JetStream in one step (no dual-run). Provide NATS, set NATS_URL
(and NATS_CREDS_FILE where the server needs authentication), and drop RABBITMQ_*: the variables are
ignored. Queued RabbitMQ messages are discarded, which is safe because every consumer treats events as
hints. Locally, stop the old container (docker compose rm -sf rabbitmq). Details: docs/nats-event-bus.md.
- Run Dragonfly in place of Redis: the compose service now uses the Dragonfly image.
REDIS_URIis unchanged, so proxies keep working. Deploy the backend and the proxies together. - The
blackhole.cache.invalidatefanout exchange is no longer declared or used. - Cache keys are versioned (
blackhole:cache:v1:), so entries written by an older release are never read and expire on their own. Rolling back to the previous release is safe.
Commit messages and pull request titles must follow
Conventional Commits (feat:, fix:, chore:, ...) —
this is enforced in CI (.github/workflows/pr-lint.yaml) and is what release-please uses to
determine the next version and generate CHANGELOG.md. Since this repository only allows squash
merges, the PR title becomes the commit on main, so it's linted the same as individual commits.
Allowed types: build, chore, ci, docs, feat, fix, perf, refactor, revert,
style, test.
Blackhole is licensed under the GNU Affero General Public License v3.0.