Blocklists
An operator configures a source: a display name, what its entries identify (telegram_id, email, or username), an HTTPS URL, an optional authorization
header, and a refresh interval.
What is stored
Only a digest. Each entry is kept as the SHA-256 of its normalised value, salted by the kind it identifies. Three things follow. Omniflow holds no readable copy of a third party’s list, so it cannot leak or republish one. A fingerprint from one installation means nothing in another. And a lookup is still an exact index probe, which is the only kind of lookup a blocklist needs. Normalisation happens before hashing and is per kind. Telegram identifiers are reduced to their canonical decimal form, usernames are lowercased with a leading@ or a t.me/ prefix stripped, and an address is lowercased in the domain
only — lowercasing the local part or stripping dots is a provider-specific
convention that would make two genuinely different addresses collide.
Refresh
A refresh replaces a source’s entries wholesale inside one transaction, so an entry the publisher removed stops matching in the same instant the new set starts, and a refresh that fails part way leaves the previous list intact rather than an arbitrary prefix of the new one. Both a JSON array and a plain-text file of one value per line are accepted, and the shape is decided from the first non-space byte rather than from a declared content type — a list served with a carelesstext/plain still imports. A line
that does not normalise is skipped and counted rather than failing the refresh:
one malformed row in a third-party list must not leave an operator with a stale
blocklist and no explanation.
A source publishing more than 500,000 entries is refused. A list that suddenly
grows that large is far more likely to be misconfigured or redirected than
genuinely that big, and importing it would replace a working list with garbage.
Deciding a match
A match records the customer, the source, and the kind of identifier that hit. An operator decidesallowed or blocked with a mandatory reason, and their
identity is recorded, so an adverse decision can never be anonymous.
Re-detecting a match that has already been decided does not reopen it. The
allowlist goes further: an allowlisted customer is skipped entirely by the
check, which is what makes an operator’s “this one is fine” survive the next
refresh of the source.
A blocked match can be appealed, which returns it to the review queue.
Anomaly detection
Four metrics can be watched, each with its own rule:
A rule carries a window, a warning threshold, an alert threshold, and a minimum
sample. Thresholds are in the metric’s own unit — bytes for traffic, minor units
for purchase and refund, a plain count for referral — because converting them
would mean showing an operator a number they did not type.
minimum_sample is what stops a brand-new installation raising an alert on its
first three orders. A window with fewer observations than that raises nothing.
The traffic rule is a level check, not a rate. Remnawave owns traffic and
Omniflow holds only the counter it last observed, so the rule flags a
subscription whose observed usage has passed a threshold, not one whose usage
grew quickly. The rule’s window controls how often such a subscription is
re-raised, not what is measured.
Deduplication
A condition that persists across several evaluation runs is one signal, not one per run. The dedupe key is the metric, the subject, and the window bucket the observation falls in, so a customer who stays above a threshold for six hours produces one alert per window rather than one per sweep. A signal an operator has already reviewed stays reviewed when the same condition is observed again.Evidence
Each signal carries the numbers that produced it — counts, sums, the currency, the window — and never a message body, a link, a token, an address, or any other customer content. An operator reviewing a signal needs to see why it fired, which is arithmetic, not correspondence.Notifications
A raised signal is queued to theanomaly operator topic in the same
transaction that persists it, so an alert cannot be announced for a signal that
was rolled back. The notice names the metric, the severity, and the numbers. It
does not name the customer: an operator group is a chat, and the panel is where
somebody with the permission looks the subject up.
When any of this runs
Both surfaces are driven byinternal/sweeper in the worker process, not by a
request. Its passes run as River periodic jobs — lifecycle_sweep and
anomaly_evaluation — so each runs once per interval however many worker
replicas are up, and each is idempotent, so a pass that runs twice reaches the
same state as one that ran once. See
Jobs and queues.
Anomaly evaluation runs on its own cadence because it aggregates over the orders
table, and a persisting signal is deduplicated by window — running it more often
produces no extra alerts, only extra work.
A failing pass never stops the others. A publisher being unreachable must not
prevent gifts expiring, and a rule that cannot be evaluated must not stop a list
refreshing. Each blocklist source is independent for the same reason: one being
down leaves the others refreshed and leaves the failing source’s previous
entries intact, rather than emptying a list nobody could replace.
Editing thresholds and sources
Both are edited in the panel under Risk, withrisk.write.
The threshold form applies the same coherence rule the evaluator does — an alert
threshold at least the warning threshold, and a window of at least five minutes
— so a rule that would be silently skipped at evaluation time is refused at save
time instead. Each field states the unit it is in.
The source form is HTTPS-only, and an empty authorization header means “keep
what is stored”, so re-saving a source never echoes its credential back into the
browser. That holds only while the URL is unchanged: saving a new URL without
re-entering the header is refused with 422 secret_required, because a header
entered for one publisher must never be sent to another.
The worker downloads a source only from a public address. Loopback, private,
link-local (which includes cloud metadata), and the other non-public ranges are
refused at connect time, after the hostname has been resolved, and again for
every redirect — which must also stay on HTTPS. A source refused this way is
marked failing with the error code destination_refused, distinct from
unreachable, so the panel shows that the address itself is the problem.
Permissions
risk.read reads sources, matches, rules, and signals. risk.write decides a
match, appeals one, manages sources and rules, and edits the allowlist.
Every role that holds risk.write also holds customers.read, which is
asserted by a unit test: a role that could decide a match without being able to
read the customer would be deciding blind.