@osqd/bothandlerjs 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +516 -0
- package/LICENSE +102 -0
- package/README.md +308 -0
- package/bin/bothandlerjs.mjs +14 -0
- package/dist/actions/index.d.ts +22 -0
- package/dist/actions/types.d.ts +59 -0
- package/dist/adapters/fastify.d.ts +43 -0
- package/dist/adapters/fetch.d.ts +51 -0
- package/dist/adapters/index.cjs +723 -0
- package/dist/adapters/index.cjs.map +1 -0
- package/dist/adapters/index.d.ts +8 -0
- package/dist/adapters/index.js +692 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/koa.d.ts +27 -0
- package/dist/adapters/node.d.ts +29 -0
- package/dist/adapters/shared.d.ts +39 -0
- package/dist/audit.d.ts +172 -0
- package/dist/challenge/index.d.ts +176 -0
- package/dist/challenge/language.d.ts +57 -0
- package/dist/challenge/page.d.ts +42 -0
- package/dist/challenge/pow.d.ts +33 -0
- package/dist/challenge/token.d.ts +72 -0
- package/dist/cli.cjs +13388 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +13358 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/index.cjs +139 -0
- package/dist/client/index.cjs.map +1 -0
- package/dist/client/index.d.ts +63 -0
- package/dist/client/index.js +112 -0
- package/dist/client/index.js.map +1 -0
- package/dist/config.d.ts +316 -0
- package/dist/core.d.ts +443 -0
- package/dist/corpus/adversarial.d.ts +2 -0
- package/dist/corpus/advertising-email.d.ts +2 -0
- package/dist/corpus/ai-crawlers.d.ts +2 -0
- package/dist/corpus/benign-bots.d.ts +2 -0
- package/dist/corpus/cdn-gateways.d.ts +2 -0
- package/dist/corpus/cookies.d.ts +49 -0
- package/dist/corpus/crawlers-regional.d.ts +2 -0
- package/dist/corpus/crawlers-vertical.d.ts +2 -0
- package/dist/corpus/headers.d.ts +403 -0
- package/dist/corpus/humans-apps.d.ts +2 -0
- package/dist/corpus/humans-browsers.d.ts +2 -0
- package/dist/corpus/humans.d.ts +2 -0
- package/dist/corpus/index.cjs +4196 -0
- package/dist/corpus/index.cjs.map +1 -0
- package/dist/corpus/index.d.ts +37 -0
- package/dist/corpus/index.js +4132 -0
- package/dist/corpus/index.js.map +1 -0
- package/dist/corpus/infrastructure.d.ts +17 -0
- package/dist/corpus/libraries-extended.d.ts +2 -0
- package/dist/corpus/ranges.d.ts +31 -0
- package/dist/corpus/reputation.d.ts +21 -0
- package/dist/corpus/runner.d.ts +130 -0
- package/dist/corpus/schema.d.ts +185 -0
- package/dist/corpus/tooling.d.ts +2 -0
- package/dist/corpus/unwanted.d.ts +2 -0
- package/dist/crawler-ranges.d.ts +113 -0
- package/dist/dashboard/client.generated.d.ts +7 -0
- package/dist/dashboard/feed.d.ts +186 -0
- package/dist/dashboard/index.d.ts +5 -0
- package/dist/dashboard/page.d.ts +51 -0
- package/dist/dashboard/parse-request.d.ts +28 -0
- package/dist/dashboard/preview.d.ts +45 -0
- package/dist/dashboard/server.d.ts +70 -0
- package/dist/dashboard/types.d.ts +714 -0
- package/dist/detectors/accept-signature.d.ts +6 -0
- package/dist/detectors/browsing-coherence.d.ts +44 -0
- package/dist/detectors/cadence.d.ts +28 -0
- package/dist/detectors/clearance.d.ts +25 -0
- package/dist/detectors/client-hints.d.ts +19 -0
- package/dist/detectors/client-signals.d.ts +27 -0
- package/dist/detectors/crawl-breadth.d.ts +28 -0
- package/dist/detectors/crawler-verification.d.ts +39 -0
- package/dist/detectors/fetch-metadata.d.ts +20 -0
- package/dist/detectors/header-integrity.d.ts +26 -0
- package/dist/detectors/header-order.d.ts +27 -0
- package/dist/detectors/identity-rotation.d.ts +34 -0
- package/dist/detectors/index.d.ts +56 -0
- package/dist/detectors/ip-intelligence.d.ts +30 -0
- package/dist/detectors/known-bots.d.ts +94 -0
- package/dist/detectors/probe-signature.d.ts +50 -0
- package/dist/detectors/rate-anomaly.d.ts +36 -0
- package/dist/detectors/self-identified.d.ts +33 -0
- package/dist/detectors/session-integrity.d.ts +24 -0
- package/dist/detectors/tls-fingerprint.d.ts +34 -0
- package/dist/detectors/trap.d.ts +98 -0
- package/dist/detectors/types.d.ts +70 -0
- package/dist/detectors/ua-coherence.d.ts +32 -0
- package/dist/evidence.d.ts +45 -0
- package/dist/facts.d.ts +46 -0
- package/dist/index.cjs +9600 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +9444 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/async.d.ts +16 -0
- package/dist/internal/clock.d.ts +17 -0
- package/dist/internal/crypto.d.ts +26 -0
- package/dist/internal/dns.d.ts +100 -0
- package/dist/internal/emitter.d.ts +18 -0
- package/dist/internal/http.d.ts +22 -0
- package/dist/internal/ip.d.ts +73 -0
- package/dist/internal/lru.d.ts +34 -0
- package/dist/internal/matcher.d.ts +41 -0
- package/dist/internal/pattern.d.ts +14 -0
- package/dist/internal/ua.d.ts +92 -0
- package/dist/metrics.d.ts +120 -0
- package/dist/notify/hub.d.ts +70 -0
- package/dist/notify/index.d.ts +7 -0
- package/dist/notify/redact.d.ts +48 -0
- package/dist/notify/sinks.d.ts +62 -0
- package/dist/notify/types.d.ts +56 -0
- package/dist/policy/index.d.ts +7 -0
- package/dist/policy/match.d.ts +20 -0
- package/dist/policy/policy.d.ts +123 -0
- package/dist/policy/presets.d.ts +161 -0
- package/dist/policy/types.d.ts +155 -0
- package/dist/robots.d.ts +83 -0
- package/dist/state.d.ts +161 -0
- package/dist/stores/index.d.ts +5 -0
- package/dist/stores/memory.d.ts +33 -0
- package/dist/stores/redis.d.ts +59 -0
- package/dist/stores/types.d.ts +37 -0
- package/dist/types.d.ts +250 -0
- package/docs/challenge/index.md +152 -0
- package/docs/challenge/localisation.md +105 -0
- package/docs/concepts/actors.md +113 -0
- package/docs/concepts/evidence.md +133 -0
- package/docs/concepts/the-guard.md +121 -0
- package/docs/concepts/threat-model.md +120 -0
- package/docs/concepts/verdicts.md +108 -0
- package/docs/course/01-first-assessment.md +178 -0
- package/docs/course/02-proof-and-suspicion.md +164 -0
- package/docs/course/03-verdicts-and-scores.md +160 -0
- package/docs/course/04-the-guard.md +200 -0
- package/docs/course/05-detectors.md +178 -0
- package/docs/course/06-identity.md +161 -0
- package/docs/course/07-actors.md +178 -0
- package/docs/course/08-traps.md +183 -0
- package/docs/course/09-rules.md +224 -0
- package/docs/course/10-actions-and-presets.md +216 -0
- package/docs/course/11-the-challenge.md +227 -0
- package/docs/course/12-going-live.md +211 -0
- package/docs/course/13-operating-it.md +238 -0
- package/docs/course/14-scaling.md +195 -0
- package/docs/course/15-extending.md +259 -0
- package/docs/course/16-proving-it.md +266 -0
- package/docs/course/index.md +112 -0
- package/docs/design/decisions.md +216 -0
- package/docs/detection/client-signals.md +96 -0
- package/docs/detection/detectors.md +376 -0
- package/docs/detection/index.md +134 -0
- package/docs/detection/signatures.md +111 -0
- package/docs/detection/verification.md +123 -0
- package/docs/detection/writing-a-detector.md +145 -0
- package/docs/index.md +100 -0
- package/docs/integration/adapters.md +138 -0
- package/docs/integration/client-ip.md +114 -0
- package/docs/integration/index.md +55 -0
- package/docs/integration/stores.md +76 -0
- package/docs/operations/audit.md +116 -0
- package/docs/operations/dashboard.md +725 -0
- package/docs/operations/index.md +101 -0
- package/docs/operations/metrics.md +74 -0
- package/docs/operations/notifications.md +72 -0
- package/docs/operations/runtime-changes.md +118 -0
- package/docs/policy/actions.md +196 -0
- package/docs/policy/index.md +84 -0
- package/docs/policy/presets.md +191 -0
- package/docs/policy/robots.md +128 -0
- package/docs/policy/rules.md +134 -0
- package/docs/reference/api.md +126 -0
- package/docs/reference/configuration.md +175 -0
- package/docs/start/choosing-a-policy.md +127 -0
- package/docs/start/first-integration.md +127 -0
- package/docs/start/installation.md +63 -0
- package/docs/start/upgrading.md +72 -0
- package/docs/testing/cli.md +119 -0
- package/docs/testing/corpus.md +129 -0
- package/docs/testing/index.md +61 -0
- package/docs/testing/replay.md +102 -0
- package/docs/testing/try-it.md +189 -0
- package/package.json +145 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
# The dashboard
|
|
2
|
+
|
|
3
|
+
The operator dashboard: what it shows, what it refuses to do, and every option it takes.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md) · [Operations](index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
Counters tell you *how much*. The dashboard tells you **which requests, and why** —
|
|
10
|
+
every assessment as it lands, and, on any row you open, the individual pieces of
|
|
11
|
+
evidence with their certainty tier and, for proven ones, the written basis.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const dashboard = await botHandler.serveDashboard({
|
|
15
|
+
port: 9674,
|
|
16
|
+
auth: { username: "ops", password: process.env.DASHBOARD_PASSWORD! },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
console.log(dashboard.url); // http://127.0.0.1:9674/
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That is the whole integration. It listens on a port of its own, subscribes to the
|
|
23
|
+
handler you called it on, and returns a handle with the URL and a `close()`.
|
|
24
|
+
|
|
25
|
+
### Four screens
|
|
26
|
+
|
|
27
|
+
**Live feed** — one row per request: when it happened, method and path, the actor, the
|
|
28
|
+
User-Agent, the verdict, the score, the action and the rule that chose it. Filter by
|
|
29
|
+
proven, suspected, human, guard stops, denied, mitigated or served; or type into the
|
|
30
|
+
search box.
|
|
31
|
+
|
|
32
|
+
The search takes fields, and negation, because the two cases people actually reach for
|
|
33
|
+
it are an address that also appears inside a User-Agent and a path that is a prefix of
|
|
34
|
+
ten others:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
actor:203.0.113.4 -path:/health that address, except its health checks
|
|
38
|
+
rule:no-scrapers action:tag the rule that fired, and what it settled on
|
|
39
|
+
score:>70 -certain probabilistic traffic close to the line
|
|
40
|
+
"GET /api/v2/orders" a phrase, spaces and all
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Fields: `path` `actor` `ua` `verdict` `action` `rule` `detector` `identity` `method`
|
|
44
|
+
`class` `id` `bypass` `outcome` `certain` `score`. Every term must match — narrowing
|
|
45
|
+
means `AND` — and anything that is not a field term is matched against the whole
|
|
46
|
+
request, so a plain word behaves as it always did. **The filter and the search are in
|
|
47
|
+
the URL**, along with the view, so a screen is a link rather than a set of
|
|
48
|
+
instructions, and a refresh keeps your place.
|
|
49
|
+
|
|
50
|
+
The rows are updated in place rather than redrawn. That is invisible until you try to
|
|
51
|
+
read a feed that is moving: a rebuilt row takes your text selection with it, so
|
|
52
|
+
copying a User-Agent out of a live feed used to be impossible without pressing Pause
|
|
53
|
+
first.
|
|
54
|
+
|
|
55
|
+
Open a row and you get the case for the verdict: every piece of evidence with its
|
|
56
|
+
tier, its family, and — for proven ones — the written basis; the **request headers in
|
|
57
|
+
wire order**, credentials replaced, which is what half the detectors are actually
|
|
58
|
+
reading; the query parameters, values masked; and any detector that failed. Three
|
|
59
|
+
buttons turn the row into something you can keep:
|
|
60
|
+
|
|
61
|
+
| | |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| **Copy replay line** | The request as a JSONL line for `bothandlerjs replay`. |
|
|
64
|
+
| **Copy corpus case** | The same request as a traffic-corpus fixture, ready to paste. |
|
|
65
|
+
| **Draft a rule** | Starts a rule from this request, in the policy editor. |
|
|
66
|
+
| **Show this actor** | Opens the drill-down. |
|
|
67
|
+
|
|
68
|
+
That first pair is the loop this library cares about: a verdict you disagree with on
|
|
69
|
+
screen becomes a fixture you re-run offline, and then a corpus case that stops it
|
|
70
|
+
coming back. **Export** in the toolbar is the same thing for the whole window at once
|
|
71
|
+
— every request matching the current filter, as replay JSONL.
|
|
72
|
+
|
|
73
|
+
**Draft a rule** is the other loop, the one that used to have no help at all: you saw
|
|
74
|
+
an actor worth acting on, then went to a different tab and hand-wrote a rule, guessing
|
|
75
|
+
at which field would catch it. It matches on the strongest thing the request actually
|
|
76
|
+
proves — a verified identity, else the detectors whose evidence was proven, else the
|
|
77
|
+
verdict with a score floor — and it does three things deliberately:
|
|
78
|
+
|
|
79
|
+
- **The action is always `tag`.** Never `block`, whatever the request looked like. A
|
|
80
|
+
drafted rule has been reviewed by nobody, and the dashboard picking a terminal action
|
|
81
|
+
for a request that annoyed you is the reflex this library exists to interrupt.
|
|
82
|
+
- **It goes last**, which is the only position that cannot change what an existing rule
|
|
83
|
+
does. If it is shadowed, the preview says so as "never matched".
|
|
84
|
+
- **Nothing is applied.** It fills the editor and previews itself, exactly like an
|
|
85
|
+
import.
|
|
86
|
+
|
|
87
|
+
Click any **actor** and the drill-down opens above the feed: what the engine knows
|
|
88
|
+
about them — requests in the window, distinct paths, prior confirmations, whether they
|
|
89
|
+
hold clearance, first seen, mean gap between requests — with their verdict and action
|
|
90
|
+
mix. It is the same picture `cadence` and `crawl-breadth` are reasoning about.
|
|
91
|
+
|
|
92
|
+
Beside the feed, **Test a request**: paste a User-Agent, a `curl` command out of
|
|
93
|
+
devtools, or a raw header block, and see the verdict, the evidence and the rule that
|
|
94
|
+
would fire — without waiting for that client to come back. It runs a **dry run** on the
|
|
95
|
+
server ([`assess(facts, { record: false })`](../detection/index.md#asking-about-a-request-that-is-not-happening)),
|
|
96
|
+
so nothing is recorded: no counter moves, no actor state changes, no row appears in the
|
|
97
|
+
feed it is sitting next to. It says what it had to assume, every time, including the one
|
|
98
|
+
that matters most — a dry run has no history, so what it answers is *what would this look
|
|
99
|
+
like as a first request*. Which is what a support ticket is asking anyway.
|
|
100
|
+
|
|
101
|
+
**Actors** — everyone the *registry* is holding, busiest first, which is a much larger
|
|
102
|
+
population than the feed's. The ring holds a few hundred requests; on a busy origin that
|
|
103
|
+
is a few seconds. The registry holds up to `maxActors` clients, each with the rate
|
|
104
|
+
series, path breadth and confirmation count that `cadence`, `crawl-breadth` and
|
|
105
|
+
`rate-anomaly` are reading — so "who is hitting me hardest right now" is a question only
|
|
106
|
+
this screen can answer. Requests, requests per minute, distinct paths, cadence
|
|
107
|
+
regularity (near zero is a metronome, which no person is), prior confirmations, and
|
|
108
|
+
whether they hold clearance. **In feed** sends one to the live feed as an `actor:` filter,
|
|
109
|
+
which makes it a shareable URL like every other view.
|
|
110
|
+
|
|
111
|
+
**Statistics** — a traffic timeline (1m/5m/15m/1h) split by outcome, which says how
|
|
112
|
+
much history the window actually holds rather than drawing a flat line through time it
|
|
113
|
+
never had; the assessment-latency histogram with mean, p95 and max; and the **score
|
|
114
|
+
distribution with your `suspectThreshold` marked on it**, which is the chart that
|
|
115
|
+
answers "how close does ordinary traffic run to the line?" before you move it.
|
|
116
|
+
|
|
117
|
+
That chart takes a scope, and the reason is worth stating: half this screen counts the
|
|
118
|
+
few hundred requests still in the feed's ring and half counts since the process
|
|
119
|
+
started. Those are different populations — on a busy server the ring can be ninety
|
|
120
|
+
seconds of a three-week run — and they used to wear the same grey subtitle. Every
|
|
121
|
+
window-scoped panel now says how much window there is ("last 500 requests · 4 min"),
|
|
122
|
+
and the score distribution defaults to **since start**, drawn from the same counters
|
|
123
|
+
the Prometheus endpoint exposes, with **this window** one click away.
|
|
124
|
+
|
|
125
|
+
Then the attribution: **guard stops broken down by the rule that overreached**, **rule
|
|
126
|
+
hit counts including the rules that never fire** (a rule matching nothing is either
|
|
127
|
+
dead configuration or a rule sitting behind a broader one, and both are invisible in a
|
|
128
|
+
chart that only draws what happened), identities seen split into verified and merely
|
|
129
|
+
claimed, busiest and most-denied paths, what bypassed detection and why, the challenge
|
|
130
|
+
funnel with its solve rate, detector failures, and every installed detector with its
|
|
131
|
+
firing count and — when timing is on — what it costs.
|
|
132
|
+
|
|
133
|
+
Beside them, the **audit panel**: the window against its baseline, measure by measure,
|
|
134
|
+
with the ratio between them and the checks that are installed. See
|
|
135
|
+
[The audit](audit.md).
|
|
136
|
+
|
|
137
|
+
**Policy** — the rules as JSON, the guard settings (read-only unless you opted in — see
|
|
138
|
+
[Changing the guard](#changing-the-guard-behind-its-own-flag)), a `robots.txt` preview
|
|
139
|
+
generated from the rules that decline crawlers, and the notices panel: startup
|
|
140
|
+
warnings, audit anomalies, and anything else the engine has raised, which otherwise
|
|
141
|
+
scroll past in a log nobody reads.
|
|
142
|
+
|
|
143
|
+
The counters come from the same `metrics()` snapshot as the Prometheus endpoint, so
|
|
144
|
+
the numbers on the screen and the numbers in your alerting agree by construction.
|
|
145
|
+
|
|
146
|
+
### Trying a policy before you mean it
|
|
147
|
+
|
|
148
|
+
The Policy screen previews a candidate rule set against the traffic still in the
|
|
149
|
+
window, and reports what would change:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
44 of 151 requests would be treated differently.
|
|
153
|
+
43 request(s) that are served today would be denied. Read the samples before applying.
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Any of the shipped presets can be previewed by name — "what would `protect-data` have
|
|
157
|
+
done to *my* traffic?" answered from your traffic rather than from the documentation —
|
|
158
|
+
and so can anything you type into the editor. It works because `decide()` is pure: a
|
|
159
|
+
candidate policy can be run over the window as many times as you like and nothing
|
|
160
|
+
about the running system moves.
|
|
161
|
+
|
|
162
|
+
Rule *matching* is exact — every field the matcher reads travels on the feed entry —
|
|
163
|
+
and so is the safety guard. What a preview cannot tell you is what the action would
|
|
164
|
+
have *done*: a challenge might have been solved, a rate limit might not have been
|
|
165
|
+
reached. It answers "which rule, and which action", which is what an edit is about.
|
|
166
|
+
|
|
167
|
+
### Live policy editing, behind a flag
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
await botHandler.serveDashboard({
|
|
171
|
+
auth: { username: "ops", password: process.env.DASHBOARD_PASSWORD! },
|
|
172
|
+
controls: { editPolicy: true }, // off by default
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
With it on, the Policy screen's editor can **apply** a rule set to the running handler.
|
|
177
|
+
The rules are validated first and swapped atomically, so a rejected edit leaves the
|
|
178
|
+
running policy exactly as it was, and every apply is announced through the handler's
|
|
179
|
+
`warning` event and the `policy-change` hook — it lands wherever your startup warnings
|
|
180
|
+
land, which is the audit trail this deserves.
|
|
181
|
+
|
|
182
|
+
The editor is a form, not a text box. Each rule is a row showing its position, its id,
|
|
183
|
+
what it matches and the action it asks for; open one and every field of the match is a
|
|
184
|
+
control — verdicts, classes, categories and detectors as toggles, evidence as
|
|
185
|
+
any/proven/unproven, score as a range, paths and identities as lists, and the action's
|
|
186
|
+
parameters appearing to suit whichever action you picked. The dropdowns are built from
|
|
187
|
+
the engine's own vocabularies and the detector ids actually installed on *that*
|
|
188
|
+
handler, so the editor cannot offer you a verdict the library does not have. **Order
|
|
189
|
+
is the semantics** — first match wins — so the rows move with ↑ and ↓ and are numbered.
|
|
190
|
+
A JSON view is one click away for anything faster to type than to click.
|
|
191
|
+
|
|
192
|
+
### Changing the guard, behind its own flag
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
await botHandler.serveDashboard({
|
|
196
|
+
auth: { authorize: (req) => roleOf(req) === "admin" },
|
|
197
|
+
controls: { editGuard: true }, // off by default, and separate from editPolicy
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
With it on, the Guard panel becomes a form: `falsePositivePolicy`, `fallbackAction`,
|
|
202
|
+
`defaultAction`, `terminalScoreThreshold` and `suspectThreshold`, applied to the
|
|
203
|
+
running handler.
|
|
204
|
+
|
|
205
|
+
**It is a separate flag from `editPolicy`, and that is the whole design.** They are
|
|
206
|
+
different powers. A rule editor can write a rule that overreaches, and the guard stops
|
|
207
|
+
it; the worst it can do is produce a downgrade. This changes the guard itself — whether
|
|
208
|
+
an unproven verdict may deny anybody at all — and the people it turns away first are
|
|
209
|
+
the ones with the most unusual and most legitimate setups. So a dashboard that hands
|
|
210
|
+
out the first does not thereby hand out the second, and you can put a different role in
|
|
211
|
+
front of each.
|
|
212
|
+
|
|
213
|
+
Everything else is the same contract the rule editor has. A change is validated before
|
|
214
|
+
anything moves, applied whole or not at all, previewed against real traffic first
|
|
215
|
+
("what would `balanced` have done to *my* traffic?" is a question the preview answers
|
|
216
|
+
from your window), and announced twice — through the handler's `warning` event, which
|
|
217
|
+
lands wherever your startup warnings land and in the notices panel, and as a
|
|
218
|
+
`guard-change` event carrying both the before and the after.
|
|
219
|
+
|
|
220
|
+
Two things stay impossible whatever the flag says, because both would leave the guard
|
|
221
|
+
switched on and doing nothing:
|
|
222
|
+
|
|
223
|
+
- **A terminal `fallbackAction`.** The fallback is what a downgraded decision *becomes*.
|
|
224
|
+
Set it to `block` and every downgrade blocks — the exact outcome the downgrade exists
|
|
225
|
+
to prevent — and the decision would still be recorded as a guard stop, so the metric
|
|
226
|
+
that is supposed to catch this would report success.
|
|
227
|
+
- **A `terminalScoreThreshold` outside 1–100.** Zero would let balanced mode deny on
|
|
228
|
+
any score at all.
|
|
229
|
+
|
|
230
|
+
The form says what each mode means where the choice is made, rather than in
|
|
231
|
+
documentation somebody would have to go and find, and Preview sits next to Apply. If
|
|
232
|
+
you turn this on, the reason should include who is allowed to press it — and
|
|
233
|
+
`guard-change` is the event to alert on.
|
|
234
|
+
|
|
235
|
+
### Acting on one client
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
await botHandler.serveDashboard({
|
|
239
|
+
auth: { authorize: (req) => roleOf(req) === "admin" },
|
|
240
|
+
controls: { editRanges: true }, // off by default
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Three operations, on the actor drill-down and on every row of the Actors screen. They
|
|
245
|
+
share one flag because they are one job — acting on a specific client rather than on a
|
|
246
|
+
class of request — and because the first is consequential enough to carry the other two.
|
|
247
|
+
|
|
248
|
+
| | |
|
|
249
|
+
| --- | --- |
|
|
250
|
+
| **Allowlist** | Adds the address to the `allowlist` range set. Asks twice, and the second button says the address it is about to exempt. |
|
|
251
|
+
| **Forget** | Discards that actor's behavioural memory. |
|
|
252
|
+
| **Clear as human** | Grants clearance for an hour, as though a challenge had been solved. |
|
|
253
|
+
|
|
254
|
+
**An allowlisted address is not judged leniently — it is not judged at all.** Detection
|
|
255
|
+
does not run on it, no evidence is produced, no rule sees it. That is the right answer
|
|
256
|
+
for your own monitoring and the wrong answer for anything that might one day be somebody
|
|
257
|
+
else's, which is why the button states the address and waits for a second click rather
|
|
258
|
+
than putting up a dialog: a confirmation you can dismiss without reading is a click with
|
|
259
|
+
extra steps.
|
|
260
|
+
|
|
261
|
+
**Forget** is the mild one and the reason the flag exists at all. A person whose actor
|
|
262
|
+
key collected a `confirmed-bot` — a shared office address, a phone that reused an IP —
|
|
263
|
+
carries `priorConfirmations` for the rest of the window, and every rule reading
|
|
264
|
+
`minPriorConfirmations` keeps matching them. Until this existed the only cure was Reset,
|
|
265
|
+
which throws away every actor's history to fix one person's. It is not an allowlist: the
|
|
266
|
+
next request from that actor is assessed exactly as any first request would be.
|
|
267
|
+
|
|
268
|
+
The **Range sets** panel on the Policy screen shows what is actually in each set rather
|
|
269
|
+
than how many entries it has, and adds or removes one at a time. It is read-only without
|
|
270
|
+
the flag.
|
|
271
|
+
|
|
272
|
+
Unavailable when `redact.maskIp` is on, and that is not a UI decision: a masked key names
|
|
273
|
+
a `/24` while the registry is keyed by the address, so the buttons would act on the wrong
|
|
274
|
+
key. The whole control goes rather than half of it.
|
|
275
|
+
|
|
276
|
+
### Who did it
|
|
277
|
+
|
|
278
|
+
The dashboard has no user model — `authorize` answers one question, and roles are yours.
|
|
279
|
+
But an audit trail that can say the guard was changed and cannot say by whom is half an
|
|
280
|
+
audit trail, so the check may name the viewer:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
auth: { authorize: (req) => sessionFrom(req)?.email ?? false }
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Anything truthy admits them; a **string** additionally says who they are, and that name
|
|
287
|
+
travels into the `warning` the handler raises, into `policy-change`, `guard-change`,
|
|
288
|
+
`range-change` and `actor-change`, and into the marker this change leaves on the traffic
|
|
289
|
+
timeline. Basic auth supplies it without being asked, since a basic credential names
|
|
290
|
+
itself. A bearer token names nobody, and inventing a name would be worse than admitting
|
|
291
|
+
the gap. An empty string is a refusal rather than an anonymous admission, so a lookup
|
|
292
|
+
returning `""` for "no such user" fails closed.
|
|
293
|
+
|
|
294
|
+
### Sections: what a listener shows
|
|
295
|
+
|
|
296
|
+
`controls` says what a viewer may **do**. `sections` says what a viewer may **see**.
|
|
297
|
+
Everything defaults to on; turning something off removes it from the page *and* from
|
|
298
|
+
the server — the tab is gone, the panel is gone, the endpoint behind it answers `403`,
|
|
299
|
+
and the fields it would have shown are dropped before they leave the process. A viewer
|
|
300
|
+
with devtools open sees exactly what the page sees.
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
await botHandler.serveDashboard({
|
|
304
|
+
port: 9684,
|
|
305
|
+
auth: { authorize: (req) => roleOf(req) === "analyst" },
|
|
306
|
+
sections: { evidence: false, policy: false },
|
|
307
|
+
redact: { maskIp: true },
|
|
308
|
+
});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
| Section | Off means |
|
|
312
|
+
| ------- | --------- |
|
|
313
|
+
| `feed` | No Live tab; `/api/feed` and `/api/stream` answer 403. |
|
|
314
|
+
| `evidence` | No evidence list, headers, query parameters, detector failures, replay or corpus export — and none of it on the wire either. |
|
|
315
|
+
| `actors` | No actor drill-down, no busiest-actors panel, and `actorStats` blanked on every entry. Takes `registry` with it. |
|
|
316
|
+
| `registry` | No Actors screen; `/api/actors` answers 403. |
|
|
317
|
+
| `tester` | No request tester; `/api/test` answers 403. |
|
|
318
|
+
| `statistics` | No Statistics tab and no counter tiles; the snapshot carries no `metrics`. |
|
|
319
|
+
| `audit` | No audit panel. |
|
|
320
|
+
| `notices` | No notices panel. |
|
|
321
|
+
| `changes` | No changes panel — the runtime audit list. The timeline markers go with it. |
|
|
322
|
+
| `policy` | No Policy tab; `/api/policy`, `/api/settings`, the preview and the editor all answer 403. Takes `guard` and `robots` with it. |
|
|
323
|
+
| `guard` | No guard panel, and `/api/guard` answers 403. |
|
|
324
|
+
| `ranges` | No range sets panel, and `/api/ranges` answers 403 — which takes the allowlist button with it. |
|
|
325
|
+
| `robots` | No `robots.txt` preview. |
|
|
326
|
+
|
|
327
|
+
`evidence` is the one to think about before sharing a dashboard widely. It is the half
|
|
328
|
+
of the page that says *which detector fired and why*, which is exactly what somebody
|
|
329
|
+
building a scraper against you needs in order to know what to fix next. Switching it
|
|
330
|
+
off leaves the feed — what happened, to whom, and what was done about it — which is
|
|
331
|
+
usually what a wider audience actually wants.
|
|
332
|
+
|
|
333
|
+
### Export and import
|
|
334
|
+
|
|
335
|
+
**Export** downloads the whole settings document: the rules, plus a record of the
|
|
336
|
+
guard, the thresholds, the installed detectors, the range sets, the audit checks and
|
|
337
|
+
the dashboard's own controls. It is the file you want when comparing two deployments
|
|
338
|
+
six months from now.
|
|
339
|
+
|
|
340
|
+
**Import** takes that file back — or a bare array of rules — by button or by dropping
|
|
341
|
+
it on the editor. It loads into the editor and applies *nothing*: an import that took
|
|
342
|
+
effect on drop would be a policy change made by a mis-drag. Review it, preview it
|
|
343
|
+
against real traffic, then apply.
|
|
344
|
+
|
|
345
|
+
Only the rules half is importable, and the file says so. The guard, the detectors and
|
|
346
|
+
the ranges come from the code that constructed the handler; a settings file that
|
|
347
|
+
appeared to carry them would be promising something it cannot deliver.
|
|
348
|
+
|
|
349
|
+
What it deliberately cannot do:
|
|
350
|
+
|
|
351
|
+
- **Relax the guard.** `falsePositivePolicy`, `fallbackAction`, `defaultAction` and
|
|
352
|
+
`terminalScoreThreshold` are not read from the submitted document, and `editPolicy`
|
|
353
|
+
confers no power over them. Write `action: "block"` on a probabilistic rule through
|
|
354
|
+
the editor and the guard downgrades it exactly as it would have downgraded it in your
|
|
355
|
+
config file. Changing the guard is a *different* permission with a different flag and
|
|
356
|
+
a different endpoint — see below — and it is off unless somebody turned it on.
|
|
357
|
+
- **Delete a rule it cannot see.** A rule whose `match` is a predicate function cannot
|
|
358
|
+
be serialised; those are shown read-only and spliced back into their original
|
|
359
|
+
positions on save. Order is the whole semantics of a first-match policy.
|
|
360
|
+
- **Exist unauthenticated on a public address.** `editPolicy` with `auth: false` on a
|
|
361
|
+
non-loopback bind is refused at startup: that combination is not a feature, it is a
|
|
362
|
+
stranger's bot policy.
|
|
363
|
+
|
|
364
|
+
Leave it off in production unless you have a reason, and the reason should include who
|
|
365
|
+
is allowed to press it.
|
|
366
|
+
|
|
367
|
+
### Reconnecting costs a handful of frames
|
|
368
|
+
|
|
369
|
+
Every frame a viewer can miss carries an id, and `EventSource` hands the last one it
|
|
370
|
+
saw back as `Last-Event-ID` when it reconnects, unasked. So a laptop lid, a proxy
|
|
371
|
+
timing out an idle stream or a five-second blip costs the frames that were actually
|
|
372
|
+
missed — not the whole ring, which is five hundred entries with their headers, evidence
|
|
373
|
+
and actor history attached, per viewer, per blip. A cursor the server cannot honour
|
|
374
|
+
(a restarted process, a cleared feed, a gap longer than the ring) gets the backlog and
|
|
375
|
+
an instruction to replace what the page is holding, because leaving a viewer with rows
|
|
376
|
+
nothing will ever correct is worse than resending.
|
|
377
|
+
|
|
378
|
+
Pressing **Reset** tells every viewer, not just the browser that pressed it.
|
|
379
|
+
|
|
380
|
+
A busy origin is a firehose — every assessment to every open browser — so the stream is
|
|
381
|
+
capped at `maxEventsPerSecond` (100 by default, `0` to remove it). What is capped is the
|
|
382
|
+
*stream*: the ring keeps everything, so the preview, the export and anyone reconnecting
|
|
383
|
+
still see every request, and the feed says how many were not streamed. A thinned feed
|
|
384
|
+
must never look like a quiet one.
|
|
385
|
+
|
|
386
|
+
**A viewer that stops reading is not allowed to cost you memory.** A socket that has
|
|
387
|
+
stopped draining — a laptop that slept with the tab open, a phone in a tunnel, a proxy
|
|
388
|
+
that stopped reading — used to accumulate frames in this process, one queue per viewer,
|
|
389
|
+
without limit; the rate cap does not help, because that bounds a rate and this is a
|
|
390
|
+
backlog. A stream that reports itself full now stops being sent feed entries until it
|
|
391
|
+
drains, the skipped frames are counted, and the viewer is told the count when it catches
|
|
392
|
+
up. One that never drains is ended after twenty seconds, and the browser reconnects and
|
|
393
|
+
resumes from its cursor — which is what makes dropping it safe.
|
|
394
|
+
|
|
395
|
+
### What somebody did, on the same axis as what happened
|
|
396
|
+
|
|
397
|
+
Every runtime change — a policy applied, the guard moved, a range set edited, an actor
|
|
398
|
+
forgotten — leaves a marker on the traffic timeline, with the time, what changed and who
|
|
399
|
+
asked for it. That is what turns a preview from a prediction into something you can
|
|
400
|
+
check: *"44 of 151 requests would be treated differently"* is a claim, and a line on the
|
|
401
|
+
chart at the moment it was applied, with the traffic either side of it, is the answer.
|
|
402
|
+
|
|
403
|
+
### Reachable without a mouse, and without a screen
|
|
404
|
+
|
|
405
|
+
The tab strip honours the keyboard contract its `role="tablist"` promises: arrow keys,
|
|
406
|
+
Home/End, one stop in the tab order. Every feed row's method-and-path is a real
|
|
407
|
+
disclosure button — the row used to *be* the button, with the actor link inside it,
|
|
408
|
+
which is a control nested in a control and leaves a screen reader with two things to
|
|
409
|
+
announce and no way to say which one `Enter` belongs to. Clicking anywhere in the row
|
|
410
|
+
still opens it, because a click is a convenience rather than a contract.
|
|
411
|
+
|
|
412
|
+
Each chart carries its data in words. `role="img"` with a name says a picture is here
|
|
413
|
+
and what it is called; it says nothing about what is *in* it, so every number on the
|
|
414
|
+
Statistics screen used to be unreachable to a reader who cannot see the bars. The
|
|
415
|
+
traffic chart, the score distribution and the latency histogram now each have a
|
|
416
|
+
description naming their totals, their bands and — for traffic — the runtime changes
|
|
417
|
+
marked on them.
|
|
418
|
+
|
|
419
|
+
`npm run test:browser` runs axe against all four screens on every change and fails on
|
|
420
|
+
anything it rates serious or critical. It is what found the two defects above.
|
|
421
|
+
|
|
422
|
+
### One dashboard, one process — and the others
|
|
423
|
+
|
|
424
|
+
`peers` puts the sibling instances in the header:
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
peers: [
|
|
428
|
+
{ label: "web-1", href: "https://web-1.internal:9674/" },
|
|
429
|
+
{ label: "web-2", href: "https://web-2.internal:9674/" },
|
|
430
|
+
],
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
That is the honest amount of help this page can give with a fleet: a way to reach the
|
|
434
|
+
other ones. It aggregates nothing, deliberately. A feed and an actor registry summed
|
|
435
|
+
across pods would be a different tool with a shared store behind it, and the counters —
|
|
436
|
+
the part of this that genuinely wants aggregating — already go to Prometheus, which is
|
|
437
|
+
a thing your monitoring does better than a page could.
|
|
438
|
+
|
|
439
|
+
### It reports on one process
|
|
440
|
+
|
|
441
|
+
**This is the boundary most likely to mislead you.** A dashboard subscribes to the
|
|
442
|
+
handler in *its own process*. Behind a load balancer with eight pods there are eight
|
|
443
|
+
rings, eight sets of counters and eight actor registries, and the one you have open is
|
|
444
|
+
showing you an eighth of your traffic — including its Actors screen, its rate limits and
|
|
445
|
+
its allowlist edits, which land on that instance and nowhere else.
|
|
446
|
+
|
|
447
|
+
The header names the instance (`instance`, defaulting to the hostname) so a partial
|
|
448
|
+
picture does not look like a whole one. Aggregating across a fleet would mean a shared
|
|
449
|
+
store and a different tool; what this is for is looking at one process closely.
|
|
450
|
+
|
|
451
|
+
The feed is memory-only, too: a bounded ring of the last `feedLimit` requests, gone when
|
|
452
|
+
the process restarts. "What happened last night" is not a question it can answer — send
|
|
453
|
+
`onAssessment` or a [notification sink](notifications.md) somewhere durable
|
|
454
|
+
for that.
|
|
455
|
+
|
|
456
|
+
### Mounting it on a server you already have
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
import { createDashboardHandler } from "@osqd/bothandlerjs";
|
|
460
|
+
|
|
461
|
+
const dashboard = createDashboardHandler(botHandler, {
|
|
462
|
+
basePath: "/_bots",
|
|
463
|
+
auth: { authorize: (req) => sessionFrom(req)?.email ?? false },
|
|
464
|
+
allowedClients: ["10.0.0.0/8"],
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
https.createServer(tls, (req, res) => {
|
|
468
|
+
if (req.url?.startsWith("/_bots")) return dashboard(req, res);
|
|
469
|
+
return app(req, res);
|
|
470
|
+
}).listen(443);
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
`startDashboard` opens a plain HTTP listener of its own. That is right on a laptop and
|
|
474
|
+
wrong in a lot of production networks: the certificate lives at an ingress, everything
|
|
475
|
+
has to be reachable under one hostname, or the platform exposes exactly one port.
|
|
476
|
+
`createDashboardHandler` is the same dashboard without the socket — a `(request,
|
|
477
|
+
response)` function you can mount wherever you already terminate TLS.
|
|
478
|
+
|
|
479
|
+
Two things differ, and both follow from not owning the socket:
|
|
480
|
+
|
|
481
|
+
- **`auth` is required**, including the explicit `auth: false`. The listening form may
|
|
482
|
+
skip it on `127.0.0.1` because the operating system is then the access control; here
|
|
483
|
+
there is no bind address to inspect, so nothing can be assumed and what is assumed is
|
|
484
|
+
"public". Same for the editors: `editPolicy`, `editGuard` and `editRanges` with
|
|
485
|
+
`auth: false` are refused, exactly as they are on a public bind.
|
|
486
|
+
- **`close()` does not close a server it does not own.** It unsubscribes from the
|
|
487
|
+
engine, ends every event stream and stops the timers. Your server is yours.
|
|
488
|
+
|
|
489
|
+
`basePath` is the path the page is served under, because that is what the page needs in
|
|
490
|
+
order to build its own URLs. Routing accepts the path with or without that prefix, so it
|
|
491
|
+
works whether or not your framework strips the mount point first — `app.use("/_bots",
|
|
492
|
+
dashboard)` in Express and a bare `if (url.startsWith("/_bots"))` both do the right
|
|
493
|
+
thing. The `Host` check is enforced only if you pass `allowedHosts`, since the server
|
|
494
|
+
that owns the socket is the thing that knows which names reach it.
|
|
495
|
+
|
|
496
|
+
What this does *not* change is the reason the dashboard is separate from the application
|
|
497
|
+
it reports on: **mount it on a server that does not run your bot handler.** Serving it
|
|
498
|
+
from inside the application means reading the dashboard shows up in the dashboard, and a
|
|
499
|
+
challenge served to your site can lock you out of the tool you are using to read about
|
|
500
|
+
it. `examples/dashboard-mounted.ts` is the whole arrangement in one file.
|
|
501
|
+
|
|
502
|
+
### Its own listener, and why
|
|
503
|
+
|
|
504
|
+
The dashboard never mounts inside the application it reports on. That arrangement has
|
|
505
|
+
three separate failure modes: reading the dashboard shows up in the dashboard, a
|
|
506
|
+
challenge served to your site can lock you out of the tool you are using to read about
|
|
507
|
+
it, and the page inherits whatever authentication your public site happens to have. A
|
|
508
|
+
second port costs nothing and avoids all three.
|
|
509
|
+
|
|
510
|
+
### It refuses to start in an unsafe configuration
|
|
511
|
+
|
|
512
|
+
The page lists client addresses and names the exact signal that fired on each request.
|
|
513
|
+
That is a *tuning guide for whoever is scraping you*: it tells them which check to fix
|
|
514
|
+
next. So the defaults are cautious and the unsafe combinations do not start at all.
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
await botHandler.serveDashboard({ host: "0.0.0.0" });
|
|
518
|
+
// ConfigError: … publishes it beyond this machine, and no `auth` was configured.
|
|
519
|
+
// Configure auth: { username, password }, auth: { token } or
|
|
520
|
+
// auth: { authorize }; keep the default host: "127.0.0.1";
|
|
521
|
+
// or write auth: false to state that something in front of it
|
|
522
|
+
// already authenticates.
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
| | |
|
|
526
|
+
| --- | --- |
|
|
527
|
+
| **Binds loopback** | `host` defaults to `127.0.0.1`. Anything else needs an explicit `auth`, including the explicit `auth: false`. |
|
|
528
|
+
| **Credentials compared in constant time** | Both halves of a basic credential, and the whole of a token. A wrong username is indistinguishable from a wrong password. |
|
|
529
|
+
| **Authentication before routing** | An unauthenticated probe gets `401` for every path, so it cannot even map the endpoints. |
|
|
530
|
+
| **Writes must come from this page** | `Sec-Fetch-Site` must say `same-origin` or `none`, `Origin` (when there is no fetch metadata) must name this server, and a body must be `application/json` — which an HTML form cannot send. Credentials cannot decide this: a browser attaches them to somebody else's forged form as willingly as to a real request. Clients with no browser provenance at all — curl, a deploy script — still work. |
|
|
531
|
+
| **Answers only to names you wrote down** | On loopback the `Host` header must be `localhost`, `127.0.0.1`, `[::1]` or something in `allowedHosts`; anything else gets `421`. This is the lock on DNS rebinding, where a name the attacker owns resolves to `127.0.0.1` and every check above agrees it is same-origin. |
|
|
532
|
+
| **Reset is off** | Clearing the actor registry discards real state — rate series, cadence, clearances. Opt in with `controls: { reset: true }`. |
|
|
533
|
+
| **The editor is off** | And when on, it can change which rules exist and nothing about how far one may go. `controls: { editPolicy: true }`. |
|
|
534
|
+
| **A wrong password is slowed down** | Five failures from an address and the next attempt is refused for a delay that doubles each time, up to five minutes. A success clears it. Under a silent `refusal` the lockout is silent too, because a `429` would confirm there is a credential here worth guessing. |
|
|
535
|
+
| **Acting on a client is off** | `controls: { editRanges: true }`, refused unauthenticated on a public bind and unavailable under `redact.maskIp`. |
|
|
536
|
+
| **The guard editor is off, separately** | `controls: { editGuard: true }` is a different flag for a different power, refused unauthenticated on a public bind like the other one. A terminal `fallbackAction` is refused whatever it is set to. |
|
|
537
|
+
| **Strict CSP, fresh nonce per response** | `default-src 'none'`, no remote script, style, font or image, `frame-ancestors 'none'`, `no-store`. |
|
|
538
|
+
| **No `innerHTML`, anywhere** | Every value on the page — User-Agents, paths, evidence summaries — is client-written text, and reaches the document through `textContent`. A test asserts the string never appears. |
|
|
539
|
+
|
|
540
|
+
### Options
|
|
541
|
+
|
|
542
|
+
| Option | Default | |
|
|
543
|
+
| ------ | ------- | --- |
|
|
544
|
+
| `port` | `9674` | `0` binds an ephemeral port; read the real one back from `dashboard.port`. |
|
|
545
|
+
| `host` | `"127.0.0.1"` | See above. |
|
|
546
|
+
| `auth` | none on loopback | `{ username, password }`, `{ token }`, `{ authorize(req) }`, or `false`. |
|
|
547
|
+
| `basePath` | `"/"` | Mount under a prefix, e.g. `"/_bots"`. |
|
|
548
|
+
| `title` / `links` | `"bothandlerjs"` | Header name, and links back to your site or runbook. |
|
|
549
|
+
| `feedLimit` | `500` | Requests kept in the ring, capped at 5000. |
|
|
550
|
+
| `maxClients` | `16` | Concurrent viewers; beyond it the stream answers `503`. |
|
|
551
|
+
| `maxEventsPerSecond` | `100` | Entries per second pushed to each viewer; `0` removes the cap. The surplus stays in the window and the page says how much it was. |
|
|
552
|
+
| `feedTtlMs` | `3600000` | How long a request may stay in the feed. A retention promise, where `feedLimit` is a capacity bound; `0` keeps them until the ring evicts them. |
|
|
553
|
+
| `allowedClients` | none | Addresses or CIDRs that may reach this dashboard at all, checked before authentication. A layer on top of `auth`, not a replacement. |
|
|
554
|
+
| `authThrottle` | on | Backoff after repeated failed credentials, per address: `{ maxAttempts: 5, lockoutMs: 1000, maxLockoutMs: 300000 }`, or `false`. |
|
|
555
|
+
| `peers` | none | Sibling instances, as `{ label, href }`, offered in the header. Aggregates nothing; see below. |
|
|
556
|
+
| `instance` | hostname | Which process this is, shown in the header. See below. |
|
|
557
|
+
| `controls.reset` | `false` | Enables the Reset button. |
|
|
558
|
+
| `controls.editPolicy` | `false` | Enables applying rule changes to the running handler. See above. |
|
|
559
|
+
| `controls.editGuard` | `false` | Enables changing the guard on the running handler. Separate from `editPolicy` on purpose. See above. |
|
|
560
|
+
| `controls.editRanges` | `false` | Enables allowlisting an address, forgetting an actor, and granting clearance. Unavailable under `redact.maskIp`. See above. |
|
|
561
|
+
| `sections` | all on | Which parts of the page this listener has, enforced on the server as well as the page. See above. |
|
|
562
|
+
| `basePath` | `"/"` | Also what a mounted dashboard tells the page it is served under. |
|
|
563
|
+
| `refusal` | `"unauthorized"` | What a caller this dashboard will not serve is told: `"unauthorized"` (401), `"not-found"` (404, identical to an unknown path), `"close"` (drop the connection), or `{ redirect, status? }`. See below. |
|
|
564
|
+
| `allowedHosts` | loopback names | Extra `Host` values a loopback dashboard answers to, for reaching it through a name of your own — an SSH tunnel aliased in `/etc/hosts`, say. `"*"` turns the check off. Ignored on a public bind. |
|
|
565
|
+
| `redact.maskIp` | `false` | Show `203.0.113.0/24` instead of the address. Turn it on when the dashboard is shared more widely than your logs. |
|
|
566
|
+
| `redact.truncateUserAgent` | `false` | Keep only the first 48 characters. |
|
|
567
|
+
| `redact.maskQuery` | `true` | Show query parameter names, not values. Reset tokens live in query strings. |
|
|
568
|
+
| `redact.headers` | `true` | Show the request headers in the row detail. Credentials are always stripped. |
|
|
569
|
+
| `exposePrometheus` | `false` | Serves `<basePath>/metrics` behind the same auth. |
|
|
570
|
+
|
|
571
|
+
`{ token }` is also accepted as `?token=…` so a link can be opened directly — which
|
|
572
|
+
puts the secret in browser history and in every proxy log on the way. Fine for a
|
|
573
|
+
laptop; use the `Authorization` header anywhere else.
|
|
574
|
+
|
|
575
|
+
### Behind your own gateway
|
|
576
|
+
|
|
577
|
+
`auth: { authorize }` receives the raw `IncomingMessage`, so a header your gateway
|
|
578
|
+
sets, a session cookie, or an mTLS subject all work. A check that throws is a check
|
|
579
|
+
that failed — never an open door.
|
|
580
|
+
|
|
581
|
+
```ts
|
|
582
|
+
await botHandler.serveDashboard({
|
|
583
|
+
host: "0.0.0.0", // behind an authenticating proxy
|
|
584
|
+
auth: { authorize: (req) => req.headers["x-forwarded-user"] !== undefined },
|
|
585
|
+
redact: { maskIp: true },
|
|
586
|
+
});
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### Getting around it
|
|
590
|
+
|
|
591
|
+
| | |
|
|
592
|
+
| --- | --- |
|
|
593
|
+
| `1` `2` `3` `4` | Live feed, Actors, Statistics, Policy — indexing the tabs this listener actually has |
|
|
594
|
+
| `Tab` into a row | Each row's method-and-path is its disclosure button; `Enter` or `Space` opens the evidence |
|
|
595
|
+
| `←` `→` `Home` `End` | Move along the tab strip |
|
|
596
|
+
| `/` | Jump to the filter |
|
|
597
|
+
| `Enter` / `Space` | Open the focused request's evidence |
|
|
598
|
+
| `Escape` | Clear the filter, then close what is open |
|
|
599
|
+
| `Tab` | The first stop is a skip link past the header |
|
|
600
|
+
|
|
601
|
+
The view is in the URL, so `…:9674/#policy` opens on the policy screen, the back
|
|
602
|
+
button moves between views rather than leaving the page, and a refresh — the reflex
|
|
603
|
+
when a live feed looks stuck — keeps your place.
|
|
604
|
+
|
|
605
|
+
### Saying less to a stranger
|
|
606
|
+
|
|
607
|
+
`401` is honest, and honest is usually right: someone who mistyped a password needs to
|
|
608
|
+
read that. It also confirms, to anyone sweeping a port range, that this address runs an
|
|
609
|
+
administrative page worth returning to. `refusal` decides which of those matters more
|
|
610
|
+
to you.
|
|
611
|
+
|
|
612
|
+
```ts
|
|
613
|
+
await botHandler.serveDashboard({
|
|
614
|
+
auth: { token: process.env.DASHBOARD_TOKEN! },
|
|
615
|
+
refusal: "not-found", // a probe sees a server that has never heard of this path
|
|
616
|
+
});
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
| | What a refused caller gets |
|
|
620
|
+
| --- | --- |
|
|
621
|
+
| `"unauthorized"` | `401`, with `WWW-Authenticate` under basic auth. The default. |
|
|
622
|
+
| `"not-found"` | `404`, byte-identical to the answer for a path that does not exist here. |
|
|
623
|
+
| `"close"` | Nothing. The connection is destroyed, the way a dropping firewall behaves. |
|
|
624
|
+
| `{ redirect, status? }` | `302` (or `303`/`307`/`308`) to wherever you send people to sign in. |
|
|
625
|
+
|
|
626
|
+
Anything other than the default also **collapses the three pre-routing refusals into
|
|
627
|
+
one answer** — a failed `auth`, a `Host` outside `allowedHosts`, a cross-site write.
|
|
628
|
+
That is the property that makes concealment worth anything: a probe that can tell
|
|
629
|
+
"wrong host" from "wrong password" has just learned a password exists. Under the
|
|
630
|
+
default they keep their distinct statuses (`401`, `421`, `403`) on purpose, because the
|
|
631
|
+
likelier reader is an operator debugging their own deployment and three statuses are
|
|
632
|
+
three diagnoses.
|
|
633
|
+
|
|
634
|
+
Two things to be clear about.
|
|
635
|
+
|
|
636
|
+
**This is concealment, not access control.** A dashboard answering `404` to the wrong
|
|
637
|
+
credentials is exactly as reachable by someone holding the right ones, and exactly as
|
|
638
|
+
exposed if those leak. It raises the cost of *finding*, which is worth something
|
|
639
|
+
against indiscriminate scanning and close to nothing against somebody who already knows
|
|
640
|
+
where to look. It is a layer on top of `auth`, never a replacement for it.
|
|
641
|
+
|
|
642
|
+
**A silent refusal and basic auth cannot both work.** A browser prompts for a password
|
|
643
|
+
because a `401` asked it to; answer `404` and no prompt ever appears, so the credential
|
|
644
|
+
the server is waiting for can never be typed. That combination is refused at startup
|
|
645
|
+
rather than at the moment somebody needs the page — use `{ token }` or `{ authorize }`
|
|
646
|
+
with a link people already hold. A redirect is fine alongside basic auth, since a
|
|
647
|
+
prompt is still reachable at the other end.
|
|
648
|
+
|
|
649
|
+
### Roles
|
|
650
|
+
|
|
651
|
+
`authorize` is deliberately a *binary* question — may this request touch the dashboard
|
|
652
|
+
at all? There is no role model in here, no user table, no session store, because every
|
|
653
|
+
one of those already exists in your application and a second copy that disagrees with
|
|
654
|
+
the first is worse than none. What you supply is a predicate; what it reads is up to
|
|
655
|
+
you.
|
|
656
|
+
|
|
657
|
+
That gets you "only admins may open it" in one line:
|
|
658
|
+
|
|
659
|
+
```ts
|
|
660
|
+
auth: { authorize: (req) => sessionFrom(req)?.role === "admin" }
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
Two roles with *different powers* — or different **views** — is a second listener
|
|
664
|
+
rather than a cleverer predicate, because `controls` and `sections` are fixed when the
|
|
665
|
+
listener starts and not evaluated per request. That is the trade deliberately: no
|
|
666
|
+
per-request role evaluation, no session store in here, no second copy of your user
|
|
667
|
+
table to disagree with the first. You bring the roles; each listener is the surface one
|
|
668
|
+
role gets.
|
|
669
|
+
|
|
670
|
+
```ts
|
|
671
|
+
// Analysts look, and not at everything. `controls` is absent, so the server answers
|
|
672
|
+
// 403 to the editor and the reset endpoint — not a hidden button, a closed door — and
|
|
673
|
+
// `sections` takes the evidence and the policy off this listener entirely.
|
|
674
|
+
const viewer = await botHandler.serveDashboard({
|
|
675
|
+
port: 9684,
|
|
676
|
+
auth: { authorize: (req) => ["analyst", "admin"].includes(sessionFrom(req)?.role) },
|
|
677
|
+
sections: { evidence: false, policy: false },
|
|
678
|
+
redact: { maskIp: true },
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// Operators tune the rules. The guard is still fixed for them.
|
|
682
|
+
const operator = await botHandler.serveDashboard({
|
|
683
|
+
port: 9685,
|
|
684
|
+
auth: { authorize: (req) => ["operator", "admin"].includes(sessionFrom(req)?.role) },
|
|
685
|
+
controls: { editPolicy: true, reset: true },
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// Admins can also change what a rule is allowed to do.
|
|
689
|
+
const admin = await botHandler.serveDashboard({
|
|
690
|
+
port: 9686,
|
|
691
|
+
auth: { authorize: (req) => sessionFrom(req)?.role === "admin" },
|
|
692
|
+
controls: { editPolicy: true, editGuard: true, reset: true },
|
|
693
|
+
});
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
`serveDashboard` keeps no singleton state, so all three stay live against the same
|
|
697
|
+
engine, each reporting on it and each with its own surface.
|
|
698
|
+
|
|
699
|
+
`npm run demo:roles` is this, working, with a signed session cookie and an operator
|
|
700
|
+
console to pick a role at — see [Try it](../testing/try-it.md#role-gated-dashboards).
|
|
701
|
+
|
|
702
|
+
Three things that demo exists to show you before you build it yourself:
|
|
703
|
+
|
|
704
|
+
- **A custom `authorize` sends no `WWW-Authenticate`,** so the browser puts up no login
|
|
705
|
+
prompt. The 401 is bare, and it is bare on every path, since authentication runs
|
|
706
|
+
before routing. Send people to your own sign-in page; do not expect the browser to.
|
|
707
|
+
- **Cookies are scoped by host, not by origin.** One sign-in on `localhost:9683`
|
|
708
|
+
covers `:9684` and `:9685` because the port is not part of a cookie's identity.
|
|
709
|
+
Convenient on a laptop, and the reason a stray service on the same host can read a
|
|
710
|
+
session cookie your app set.
|
|
711
|
+
- **A session cookie is not what stops a forged write.** The browser attaches it to a
|
|
712
|
+
cross-site request as willingly as to a real one, which is why the dashboard checks
|
|
713
|
+
`Sec-Fetch-Site` and insists on a JSON body independently of who you are.
|
|
714
|
+
|
|
715
|
+
The demo runs exactly this dashboard — `npm run demo` calls `serveDashboard()` — so
|
|
716
|
+
what you see at `localhost:9674` is what you get in your own application.
|
|
717
|
+
|
|
718
|
+
---
|
|
719
|
+
|
|
720
|
+
## Related
|
|
721
|
+
|
|
722
|
+
- [Operations](index.md) — events, metrics, the audit
|
|
723
|
+
- [Runtime changes](runtime-changes.md) — what the editor and the guard panel actually call
|
|
724
|
+
- [Try it](../testing/try-it.md) — the demo, including the role-gated dashboards
|
|
725
|
+
|