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
132 lines
5.9 KiB
TypeScript
132 lines
5.9 KiB
TypeScript
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'
|