Files
shater/panel/src/killPolicy.ts
T
omarandClaude Opus 5 7c93019e81 fix(panel): draw the settings that decide whether traffic leaks
Five things the panel knew and did not say, each one a state where the
screen read healthier than the router was.

Rule.Kill was typed, round-tripped and drawn nowhere. `open` sends a
rule's traffic out direct — around the kill-switch, with the real
address — when its target cannot be built, and such a rule looked
exactly like one that fails closed. It now has an editor beside Target
and an amber mark on the row; the fail-closed default draws nothing, so
the two states are not priced alike. An unreadable value is its own
state: it blocks, like the daemon, and the picker re-surfaces it
verbatim rather than rewriting a value it never showed.

Alert channels were write-once for Type/Token/ChatID/URL/Events, so
fixing a typo meant deleting the channel and going back to BotFather for
a token you already owned. Add and edit are now one form. The token box
starts empty and the caption says what empty means — keep, never clear —
because the panel refuses to show the secret and a save may only clear a
field the editor could show. Same rule covers a type switch: the other
kind's settings stay stored and unused.

The add-rule form pre-filled Target=direct. An untouched form is a rule
with no matchers, i.e. the default route, so one press put the whole LAN
on the plain WAN. `block` would only have swapped the leak for an
outage; the recoverable default here is no default, so the form refuses
and asks.

The empty state said "all traffic follows the default route" without
naming it. On a fresh install that route is `block` — the LAN has no
internet — and this is the page the kill-switch alarm sends people to.
Both it and the lead now name the route in force.

The interception board was computed from the config alone and lit `lan`
green over a stopped engine. Green now needs the engine up AND the full
plane; a hold plane blocks rather than carries, and unknown is an unlit
socket.

Also: four rungs of the untunnelable copy claimed traceroute works. It
prints `* * *` and no hops on every setting — the wording is now
apply/warnings.go's own udpTracerouteFacts, said once.

Tests: killPolicy / alertEdit / defaultRoute / intercept, 38 cases, each
mutation-checked (16 mutants, all caught). Browser-verified at 390 and
1280, no horizontal overflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:10:32 +03:00

132 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Rule } from './api'
/**
* `Rule.Kill` — what a rule does when its target cannot be built.
*
* WHY THIS MODULE EXISTS. The field was typed, round-tripped and completely
* invisible: no editor, and no mark on the rule row. A rule that fails OPEN —
* i.e. sends its traffic out with the real address when its group/chain/node
* cannot be resolved, deliberately around the kill-switch — was drawn exactly
* like one that fails closed. The single most consequential per-rule setting on
* the page was the one thing the page did not draw.
*
* The behaviour mirrored here is `generate/route.go ruleKillFallback`, which is
* consulted only when the target does not resolve (a dead group, a chain that
* would not assemble, a missing egress/node). The rule is ALWAYS still emitted —
* its traffic never falls through to the default route — so the only question is
* which of two outbounds it gets:
*
* "" | "default" | "closed" → block (fail-closed)
* "open" → direct (a warned, deliberate kill-switch bypass)
* anything else → block, and the daemon warns that the policy was
* unreadable ("an unreadable policy must not open
* a bypass")
*
* It lives outside `pages/Routing.tsx` because it is the part that must be
* TESTED, and the panel's runner is `node --test src/*.test.ts`: plain modules
* only, no JSX, no DOM (same reason as ruleset.ts and egressEdit.ts).
*/
/**
* The three states the panel draws. Positive and closed on purpose: `unknown` is
* a state of its own rather than folded into `closed`, because those two look
* identical to the router and completely different to the operator — one is a
* choice, the other is a typo that happens to land on the safe side.
*/
export type KillPolicy = 'closed' | 'open' | 'unknown'
/** Classify a stored `Kill` exactly as ruleKillFallback switches on it. */
export function killPolicy(raw: string | undefined | null): KillPolicy {
const v = (raw ?? '').trim().toLowerCase()
if (v === '' || v === 'default' || v === 'closed') return 'closed'
if (v === 'open') return 'open'
return 'unknown'
}
/** Where this policy actually sends the traffic. `unknown` blocks, like the daemon. */
export function killFallbackTarget(raw: string | undefined | null): 'block' | 'direct' {
return killPolicy(raw) === 'open' ? 'direct' : 'block'
}
/** The two values the editor offers. `unknown` is surfaced separately (see killSelectValue). */
export const KILL_OPTIONS: ReadonlyArray<{ value: 'closed' | 'open'; label: string }> = [
{ value: 'closed', label: 'Block it — fail closed (default)' },
{ value: 'open', label: 'Send it direct — bypasses the kill-switch' },
]
/**
* The `<select>` value that represents this stored policy.
*
* An unrecognised value comes back VERBATIM so the editor can offer it as its own
* option (the way TargetOptions re-surfaces a target pointing at a since-removed
* node). Folding it to `closed` here would mean the picker silently rewrote a
* value it never showed — the one thing a save is not allowed to do — and would
* also erase the evidence of the typo the daemon is warning about.
*/
export function killSelectValue(raw: string | undefined | null): string {
const p = killPolicy(raw)
if (p === 'unknown') return (raw ?? '').trim()
return p
}
/**
* What a save writes back, given what was stored and what the operator picked.
*
* `""`, `"default"` and `"closed"` are the SAME policy, and the picker shows them
* as one option, so choosing that option on a rule that already had one of them
* must leave the stored spelling alone. Rewriting `default` (what model/uci.go
* hands the panel for a rule with no `option kill` at all) into `closed` would
* put an explicit option on every rule anyone ever opened the editor for, and
* make a no-op edit show up as a config change.
*
* Every other transition writes the picked value: it is a real change of policy.
*/
export function carryKill(stored: string | undefined | null, picked: string): string {
if (picked === 'closed' && killPolicy(stored) === 'closed') return (stored ?? '').trim()
return picked
}
/** One mark on a rule row: the pill, and the sentence under it. */
export interface KillMark {
/** Pill text. Uppercased by the stylesheet — keep it short. */
badge: string
/** The sentence that says what happens and why it matters. */
note: string
}
/**
* The row mark for a rule's kill policy, or `null` when there is nothing to say.
*
* `closed` draws NOTHING. It is the default, it is the safe side, and a badge on
* every row would price the two states the same — which is exactly the reading
* this mark exists to prevent. Only a rule that has been moved off the safe side,
* or one whose policy cannot be read, earns a mark.
*/
export function killMark(rule: Pick<Rule, 'Kill'>): KillMark | null {
const raw = (rule.Kill ?? '').trim()
switch (killPolicy(raw)) {
case 'closed':
return null
case 'open':
return {
badge: 'fails open · direct',
note:
'If this rule’s target can’t be built — a dead group, a missing node, a chain that won’t ' +
'assemble — its traffic leaves direct instead of being blocked: around the tunnel, with ' +
'your real IP address. That is a deliberate kill-switch bypass for this rule alone.',
}
case 'unknown':
return {
badge: 'fails closed · unreadable',
note:
`“${raw}” is not a policy this router reads, so if this rule’s target can’t be built its ` +
'traffic is blocked — the safe side, but not a setting anyone chose. Edit the rule and ' +
'pick Block or Direct.',
}
}
}
/** The flag shown in the rule form while `open` is selected. */
export const KILL_OPEN_FORM_WARN =
'fails open — if this target breaks, the traffic leaves direct with your real IP'