Files
shater/panel/src/pages/Networks.tsx
T
omarandClaude Opus 5 4996bc0984 fix(untunnelable): make block actually block
The block policy collapsed into direct whenever routing's final target was
direct — the common "tunnel only what is blocked, everything else direct"
shape. So ICMP, ESP, AH, GRE, IGMP and SCTP left with the client's real
address under the setting whose own field doc promises "nothing ever leaves
with the client's real IP", including a standing VPN on the real address,
which is exactly what the middle rung exists to separate out.

Both ends of the ladder now short-circuit before the plan is consulted and
neither may consult it: direct accepts everything, block emits no line at all
and lets the fail-closed drops the caller writes next do the work.

A rule scoped by source could also widen the other family: emit() skipped a
family whose destination list was empty but not one whose source list was, so
a rule carrying only IPv6 source prefixes rendered an IPv4 line with no
ip saddr clause — an accept for every IPv4 host on the LAN. The two halves now
read "scoped" the same way the catch-all collapse already did.

No destination plan is built for block at all now. It is the shipped default,
and a geoip-backed plan is ~159 000 prefixes pushed into kernel memory and the
ruleset text for a policy that cannot use them.

The operator-facing texts said IPTV works. It does not, on any of the three
rungs: inbound multicast is never matched by these rules and a client's
outbound multicast UDP dies at the fail-closed guard regardless. Saying
otherwise invited trading the ESP/GRE block away for nothing. What actually
stops working under block is stated instead, and precisely: raw ESP/AH and
GRE, but not IPsec through NAT or any UDP VPN, which are ordinary tunnelled
traffic.

TestOnlyPinnedAddressIsTunnelled is how this hid: it asserted, on the default
policy, that an exception line was emitted, and read that as the feature
working. It was block rendering direct. Its render assertions move to icmp,
where they mean something.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:26:07 +03:00

1292 lines
45 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 './Networks.css'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Led, Select, Toggle, useConfirm } from '../components'
import { apply as apiApply, getConfig, putConfig, ApiError } from '../api'
import type { Inbound, Interface, Model, Status } from '../api'
import { isLanNetwork, isWanNetwork, useInterfaces } from '../srcOptions'
import { sectionNotes } from '../findings'
// The Networks page is the INGRESS editor — a thin editor over Model.Inbounds,
// following the same save-then-Apply contract as Nodes/DNS/Routing: every edit
// rewrites the slice in place, PUTs the whole Model, and only Apply pushes it
// onto the live data plane.
//
// It exists because "which of my LAN networks actually go through the tunnel?"
// was previously answerable only by reading /etc/config/shater over SSH. The
// answer is narrower than most people assume, and the page leads with it: only
// an ENABLED inbound of type `tproxy` diverts a network into the engine
// (model.IsTproxyInbound). socks/http/dokodemo are plain local listeners — they
// protect nothing on their own, and saying so is the page's main job.
// ---- inbound kinds ---------------------------------------------------------
type InboundType = 'tproxy' | 'socks' | 'http' | 'dokodemo'
const TYPES: ReadonlyArray<{ id: InboundType; label: string; blurb: string }> = [
{
id: 'tproxy',
label: 'Transparent (tproxy)',
blurb: 'Diverts a whole LAN network into the engine. This is the one that intercepts traffic.',
},
{
id: 'socks',
label: 'SOCKS listener',
blurb: 'A local SOCKS5 port for the router itself or an app that opts in. Intercepts nothing.',
},
{
// The engine has no HTTP-only listener: `http` builds `mixed`, which answers
// HTTP CONNECT *and* SOCKS5 on the same port. The label says so, because the
// old "HTTP proxy listener" opened more than it promised.
id: 'http',
label: 'HTTP + SOCKS5 (mixed)',
blurb:
'One local port that answers both HTTP CONNECT and SOCKS5, for apps that opt in. Intercepts nothing.',
},
{
id: 'dokodemo',
label: 'Fixed target (dokodemo)',
blurb: 'Forwards everything it receives to one fixed address — used to wrap a tunnel.',
},
]
/** model.Inbound.EffectiveType — an absent/empty Type means tproxy. */
function effectiveType(in_: Inbound): InboundType {
const t = (in_.Type ?? '').trim().toLowerCase()
if (t === 'socks' || t === 'http' || t === 'dokodemo') return t
if (t === 'mixed') return 'http' // the generator maps mixed → the http listener
return 'tproxy'
}
/** model.IsTproxyInbound — the ONLY shape that intercepts a network. */
function intercepts(in_: Inbound): boolean {
return in_.Enabled && effectiveType(in_) === 'tproxy'
}
const DEFAULT_TPROXY_PORT = 12345
// ---- untunnelable-traffic policy -------------------------------------------
/**
* Globals.Untunnelable — what happens to LAN traffic the tunnel physically
* cannot carry (anything that isn't TCP or UDP).
*
* The copy is written in CONSEQUENCES, not mechanism: nobody configuring a
* router wants to read "l4proto != tcp/udp is dropped", they want to know that
* ping won't work. Each option therefore names what starts working and what it
* costs, and the cost is stated plainly rather than buried — turning `icmp` or
* `direct` on does leak the real WAN IP for that traffic, and that is a
* legitimate choice to make knowingly.
*
* The three-way split matters and the wording leans on it: the leaks differ in
* KIND, not degree. ICMP echo is momentary, human-initiated, carries no payload,
* and only reveals the address to a host the user deliberately pinged. ESP/AH/GRE
* is a standing second tunnel running beside ours on the real IP — a forgotten
* corporate VPN client quietly carrying arbitrary traffic past the proxy forever.
* Collapsing those into one switch would make "I want ping to work" silently mean
* "I permit a parallel VPN bypass", so the middle option exists to remove that
* false choice — and the labels push anyone who wants diagnostics to `icmp`.
*
* WHY THIS COPY WAS REWRITTEN. `block` used to say "Nothing leaves except through
* the tunnel", and it was not true. The daemon let untunnelable traffic out toward
* every destination the ROUTING RULES send direct, on the argument that such a host
* already has your address from ordinary TCP. Under the commonest setup here —
* "tunnel what's blocked, send the rest direct" — the routing default IS direct, so
* that covered everything: `block` behaved exactly like `direct`, including ESP/GRE,
* i.e. the parallel-VPN case the middle rung exists to exclude. The daemon now drops
* unconditionally under `block`, and this copy states the price instead of hiding it
* (the owner's call: this router does not do ping and does not do IPTV).
*
* `icmp` still carries that destination-dependence for its NON-ping half, so its
* cost line says so rather than claiming "nothing else gets out".
*/
type Untunnelable = 'block' | 'icmp' | 'direct'
const UNTUNNELABLE_DEFAULT: Untunnelable = 'block'
/** Normalise like the daemon does: unknown/empty falls to the safe side. */
function normUntunnelable(raw: string | undefined): Untunnelable {
const v = (raw ?? '').trim().toLowerCase()
return v === 'icmp' || v === 'direct' ? v : UNTUNNELABLE_DEFAULT
}
const UNTUNNELABLE_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
{ value: 'block', label: 'Block everything — most private' },
// Not "Allow ping only": the rung also lets the other untunnelable protocols
// out toward directly-routed addresses, and the cost line below says so. A
// label that promised "only" would be contradicted two lines under itself.
{ value: 'icmp', label: 'Allow ping — for diagnostics' },
{ value: 'direct', label: 'Allow everything — most compatible' },
]
interface PolicyCopy {
works: string
cost: string | null
tone: 'good' | 'warn'
}
const UNTUNNELABLE_COPY: Record<Untunnelable, PolicyCopy> = {
block: {
// Scoped to "this traffic" on purpose. The old line — "Nothing leaves except
// through the tunnel" — was doubly loose: it was false (see the note above),
// and even read charitably it collides with directly-routed TCP, which does
// leave outside the tunnel by design.
works:
'None of this traffic leaves the router — it’s dropped, whatever your routing rules say. It’s the only setting whose promise doesn’t depend on how the rules are written.',
cost: 'Ping and traceroute stop working from your devices. So do IPsec and PPTP VPN connections made from a device on your network, multicast IPTV, and SCTP. VPNs that run over UDP — WireGuard, OpenVPN-UDP, and IPsec through NAT (IKEv2/NAT-T) — are unaffected: they go through the tunnel like everything else.',
tone: 'good',
},
icmp: {
works: 'Ping and traceroute work everywhere, so you can check whether something is reachable.',
cost: 'Whatever you ping sees your real IP address instead of the tunnel’s. IPsec, PPTP and IPTV also get out — but only toward addresses your routing rules already send direct, so a VPN app on a device can still open its own connection beside this one if its server is one of those.',
tone: 'warn',
},
direct: {
works: 'Ping, multicast IPTV, and connecting to a VPN from a device on your network all work.',
cost: 'All of it goes out with your real IP, around the tunnel. A VPN app left running on a device keeps its own connection open beside this one — traffic through it isn’t proxied or filtered.',
tone: 'warn',
},
}
/** The addr:port an inbound binds — the generator's clash key (listenKey). */
function listenKey(in_: Inbound): string {
if (effectiveType(in_) === 'tproxy') {
return `0.0.0.0:${in_.TproxyPort || DEFAULT_TPROXY_PORT}`
}
const addr = (in_.Listen ?? '').trim() || '127.0.0.1'
return `${addr}:${in_.Port ?? 0}`
}
// ---- helpers ---------------------------------------------------------------
const asArray = <T,>(a: T[] | null | undefined): T[] => (a ? a : [])
const errText = (e: unknown): string =>
e instanceof ApiError ? e.message : e instanceof Error ? e.message : 'request failed'
function uniqueName(base: string, taken: Set<string>): string {
const seed = base.trim() || 'inbound'
if (!taken.has(seed)) return seed
let i = 2
while (taken.has(`${seed}-${i}`)) i++
return `${seed}-${i}`
}
function portValid(p: number): boolean {
return Number.isInteger(p) && p >= 1 && p <= 65535
}
// ---- warnings --------------------------------------------------------------
type WarnLevel = 'warn' | 'crit'
interface Warning {
key: string
level: WarnLevel
text: string
}
/**
* The failures worth interrupting someone over — each one verified against the
* backend rather than guessed:
*
* - Two ENABLED inbounds on one addr:port. generate.buildInbounds keeps the
* first and SKIPS the second with only a daemon-log warning, so the panel is
* the only place this can surface before the listener silently goes missing.
* - A tproxy inbound on a `wan`-zone interface: netplane diverts that device's
* ingress into the engine, which swallows inbound port forwards.
* - A tproxy inbound naming an interface the router doesn't have — nftIifExpr
* can't resolve a device, so the inbound is skipped and that network is
* simply never diverted.
* - No enabled tproxy inbound at all: nothing on the LAN is intercepted.
*/
function computeWarnings(inbounds: Inbound[], ifaces: Interface[]): Warning[] {
const out: Warning[] = []
const enabled = inbounds.filter((i) => i.Enabled)
// duplicate listen address among enabled inbounds
const owner = new Map<string, string>()
for (const in_ of enabled) {
const key = listenKey(in_)
const first = owner.get(key)
if (first) {
out.push({
key: `dup-${key}-${in_.Name}`,
level: 'crit',
text: `“${in_.Name}” and “${first}” both listen on ${key}. Only “${first}” will start — the other is dropped without an error. Give one of them a different port.`,
})
} else {
owner.set(key, in_.Name)
}
}
const byName = new Map(ifaces.map((i) => [i.name, i]))
for (const in_ of enabled) {
if (effectiveType(in_) !== 'tproxy') continue
const net = (in_.Network ?? '').trim()
if (!net) {
out.push({
key: `nonet-${in_.Name}`,
level: 'crit',
text: `“${in_.Name}” has no network selected, so it intercepts nothing. Pick the network it should cover.`,
})
continue
}
const iface = byName.get(net)
if (!iface) {
if (ifaces.length > 0) {
out.push({
key: `unknown-${in_.Name}`,
level: 'crit',
text: `“${in_.Name}” covers “${net}”, which this router doesn’t have. Nothing is intercepted until it points at a real network.`,
})
}
continue
}
if (isWanNetwork(iface.zone)) {
out.push({
key: `wan-${in_.Name}`,
level: 'crit',
text: `“${in_.Name}” intercepts ${net}, the internet uplink. That breaks inbound port forwards — transparent inbounds belong on LAN networks.`,
})
}
}
if (!enabled.some(intercepts)) {
out.push({
key: 'no-tproxy',
level: 'warn',
text: 'No transparent inbound is on, so no LAN traffic reaches the engine. Rules and blocklists have nothing to act on.',
})
}
return out
}
// ---- page ------------------------------------------------------------------
export default function Networks({ status }: { status?: Status | null }) {
const confirm = useConfirm()
const [config, setConfig] = useState<Model | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const ifaces = useInterfaces()
const loadConfig = useCallback(async () => {
try {
setLoadError(null)
setConfig(await getConfig())
} catch (e) {
setLoadError(errText(e))
}
}, [])
useEffect(() => {
void loadConfig()
}, [loadConfig])
// ---- toast + apply banner (same contract as the other editor pages) ------
const [toast, setToast] = useState<string | null>(null)
const toastTimer = useRef<number | undefined>(undefined)
useEffect(() => () => window.clearTimeout(toastTimer.current), [])
const flash = useCallback((msg: string) => {
setToast(msg)
window.clearTimeout(toastTimer.current)
toastTimer.current = window.setTimeout(() => setToast(null), 2600)
}, [])
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [applying, setApplying] = useState(false)
const save = useCallback(
async (next: Model, okMsg: string): Promise<boolean> => {
const prev = config
setConfig(next)
setSaving(true)
try {
await putConfig(next)
setDirty(true)
flash(okMsg)
return true
} catch (e) {
setConfig(prev)
flash(`Save failed — ${errText(e)}`)
return false
} finally {
setSaving(false)
}
},
[config, flash],
)
const applyNow = useCallback(async () => {
setApplying(true)
try {
const r = await apiApply()
if (r.error) {
flash(`Apply failed — ${r.error}`)
} else {
setDirty(false)
flash(r.changed ? 'Applied — data plane reconciled' : 'Applied — already up to date')
void loadConfig()
}
} catch (e) {
flash(`Apply failed — ${errText(e)}`)
} finally {
setApplying(false)
}
}, [flash, loadConfig])
// ---- derived -------------------------------------------------------------
const inbounds = useMemo<Inbound[]>(() => asArray(config?.Inbounds), [config])
const names = useMemo(() => new Set(inbounds.map((i) => i.Name)), [inbounds])
const lanIfaces = useMemo(
() => ifaces.filter((i) => isLanNetwork(i.subnet, i.zone)),
[ifaces],
)
const warnings = useMemo(() => computeWarnings(inbounds, ifaces), [inbounds, ifaces])
const busy = saving || applying
const loading = config === null && loadError === null
// Untunnelable-traffic policy. Normalised the same way the daemon does, so an
// absent/unknown UCI value reads as `block` here too rather than as blank.
const untunnelable = normUntunnelable(config?.Globals?.Untunnelable)
const untunnelableCopy = UNTUNNELABLE_COPY[untunnelable]
// Prefer the LIVE kill-switch off /api/status; fall back to the saved config
// when the shell hasn't got a status yet.
const killSwitchOpen =
(status?.kill_switch ?? config?.Globals?.KillSwitch ?? 'closed').toLowerCase() === 'open'
// The daemon's info notes about this policy — shown beside the control they
// describe. The fail-open case has its own dedicated line below, so drop that
// one here to avoid saying the same thing twice.
const policyNotes = useMemo(
() =>
sectionNotes(status?.warnings, 'untunnelable').filter(
(n) => !(killSwitchOpen && /fail-open/i.test(n.message)),
),
[status?.warnings, killSwitchOpen],
)
// Which inbound (if any) covers each LAN network — drives the coverage board.
const coverage = useMemo(
() =>
lanIfaces.map((iface) => ({
iface,
by: inbounds.find((in_) => intercepts(in_) && (in_.Network ?? '').trim() === iface.name),
})),
[lanIfaces, inbounds],
)
const coveredCount = coverage.filter((c) => c.by).length
// ---- mutations -----------------------------------------------------------
const addInbound = useCallback(
(draft: Inbound): Promise<boolean> => {
if (!config) return Promise.resolve(false)
const in_: Inbound = { ...draft, Name: uniqueName(draft.Name, names) }
return save({ ...config, Inbounds: [...inbounds, in_] }, `Added ${in_.Name}`)
},
[config, inbounds, names, save],
)
const toggleInbound = useCallback(
(idx: number, on: boolean) => {
if (!config) return
const next = inbounds.map((in_, i) => (i === idx ? { ...in_, Enabled: on } : in_))
void save({ ...config, Inbounds: next }, `${next[idx].Name} ${on ? 'enabled' : 'disabled'}`)
},
[config, inbounds, save],
)
const editInbound = useCallback(
(idx: number, patch: Inbound): Promise<boolean> => {
if (!config) return Promise.resolve(false)
const next = inbounds.map((in_, i) => (i === idx ? patch : in_))
return save({ ...config, Inbounds: next }, `Updated ${patch.Name}`)
},
[config, inbounds, save],
)
const setUntunnelable = useCallback(
(v: string) => {
if (!config) return
const label =
v === 'icmp' ? 'ping allowed out' : v === 'direct' ? 'all other traffic allowed out' : 'other traffic blocked'
void save({ ...config, Globals: { ...config.Globals, Untunnelable: v } }, `Saved — ${label}`)
},
[config, save],
)
const removeInbound = useCallback(
async (idx: number) => {
if (!config) return
const target = inbounds[idx]
const ok = await confirm({
label: 'Delete inbound',
title: `Delete inbound “${target.Name}”?`,
body: intercepts(target)
? `${target.Network || 'Its network'} stops going through the tunnel.`
: undefined,
})
if (!ok) return
const next = inbounds.filter((_, i) => i !== idx)
void save({ ...config, Inbounds: next }, `Deleted ${target.Name}`)
},
[config, inbounds, save, confirm],
)
return (
<section className="page nw-page" aria-label="Networks and inbounds">
{loadError && (
<p className="page-error" role="alert">
Couldn’t read config — {loadError}.{' '}
<button className="linkish" onClick={() => void loadConfig()}>
Retry
</button>
</p>
)}
{dirty && (
<div className="nw-apply-banner" role="status">
<Led variant="amber" />
<span className="nw-apply-msg">
Changes saved to config — press Apply to activate them on the data plane.
</span>
<Button variant="primary" onClick={() => void applyNow()} disabled={applying}>
{applying ? 'Applying…' : 'Apply'}
</Button>
</div>
)}
{/* ---- 1. COVERAGE — the question this page exists to answer ---- */}
<div className="nw-section" aria-label="Interception coverage">
<header className="nw-sec-hd">
<h2 className="nw-sec-title">Interception</h2>
<span className="nw-sec-count mono">
{coveredCount} / {lanIfaces.length} networks
</span>
</header>
<p className="nw-sec-note">
Only a transparent (tproxy) inbound that is switched on sends a network’s traffic through
the engine. SOCKS and HTTP listeners are ports the router offers to whoever asks for them —
they don’t capture anything by themselves.
</p>
{lanIfaces.length === 0 ? (
<div className="nw-plate">
<p className="nw-plate-title">No LAN networks found</p>
<p className="nw-plate-body">
The router reported no internal networks. Check that the firewall zones are readable,
then reload.
</p>
</div>
) : (
<ul className="nw-cov" aria-label="LAN networks and their interception state">
{coverage.map(({ iface, by }) => (
<li key={iface.name} className={by ? 'nw-cov-item on' : 'nw-cov-item off'}>
<div className="nw-cov-hd">
<Led variant={by ? 'on' : 'off'} />
<span className="nw-cov-name mono">{iface.name}</span>
</div>
<span className="nw-cov-cidr mono">{iface.subnet || '—'}</span>
<span className="nw-cov-state">
{by ? (
<>
Through the tunnel via <strong className="mono">{by.Name}</strong>
</>
) : (
'Goes straight out — not intercepted'
)}
</span>
</li>
))}
</ul>
)}
{warnings.length > 0 && (
<ul className="nw-warnings" aria-label="Configuration problems">
{warnings.map((w) => (
<li key={w.key} className={`nw-warn nw-warn-${w.level}`}>
<Led variant={w.level === 'crit' ? 'crit' : 'amber'} />
<span>{w.text}</span>
</li>
))}
</ul>
)}
</div>
{/* ---- 1b. UNTUNNELABLE TRAFFIC ---- */}
<div className="nw-section" aria-label="Other traffic">
<header className="nw-sec-hd">
<h2 className="nw-sec-title">Other traffic</h2>
<span className="nw-sec-count mono">{untunnelable}</span>
</header>
<p className="nw-sec-note">
The tunnel carries the traffic almost everything uses — web, video, games, email. A few
things can’t go through it no matter what: ping, and the protocols that carry IPTV or a VPN
connection. Choose what happens to those.
</p>
<div className="nw-policy">
<div className="nw-policy-ctl">
<span className="nw-flabel" id="untun-label">
Traffic the tunnel can’t carry
</span>
<Select
value={untunnelable}
options={UNTUNNELABLE_OPTIONS}
ariaLabel="What to do with traffic the tunnel can't carry"
busy={busy}
disabled={!config}
onChange={setUntunnelable}
/>
</div>
<div className="nw-policy-copy">
<p className="nw-policy-works">{untunnelableCopy.works}</p>
{untunnelableCopy.cost && (
<p className={`nw-policy-cost nw-policy-cost--${untunnelableCopy.tone}`}>
{untunnelableCopy.tone === 'warn' && <Led variant="amber" />}
<span>{untunnelableCopy.cost}</span>
</p>
)}
</div>
</div>
{/* Fail-open makes the whole policy moot — say so instead of letting the
page imply something is being blocked when nothing is. */}
{killSwitchOpen && (
<p className="nw-sec-note nw-policy-moot" role="status">
<Led variant="amber" /> This setting isn’t doing anything right now: the kill-switch is
set to fail-open, so traffic keeps flowing directly whenever the tunnel is down. Set it
to fail-closed in Settings for this choice to take effect.
</p>
)}
{/* The daemon's own note about what the running plane does with this
traffic. It lives here, next to the setting it describes, rather than
in the Overview findings list: it never clears and asks for nothing,
so on the front page it would just be standing noise (see findings.ts). */}
{policyNotes.map((n, i) => (
<p className="nw-sec-note nw-policy-note" key={`${n.section}-${i}`} role="status">
<Led variant="off" />
<span>{n.message}</span>
</p>
))}
{untunnelable === 'block' && (
<p className="nw-sec-note nw-policy-hint">
If you just want to check whether a site is reachable, choose <strong>Allow ping</strong>{' '}
rather than allowing everything — it’s the narrower of the two.
</p>
)}
</div>
{/* ---- 2. INBOUNDS ---- */}
<div className="nw-section" aria-label="Inbounds">
<header className="nw-sec-hd">
<h2 className="nw-sec-title">Inbounds</h2>
<span className="nw-sec-count mono">
{inbounds.filter((i) => i.Enabled).length} / {inbounds.length} on
</span>
</header>
<AddInboundForm
busy={busy}
disabled={!config}
taken={names}
ifaces={ifaces}
onAdd={addInbound}
/>
{loading ? (
<ul className="nw-rows" aria-hidden="true">
<li className="nw-skel" />
<li className="nw-skel" />
</ul>
) : inbounds.length === 0 ? (
<div className="nw-plate">
<p className="nw-plate-title">No inbounds</p>
<p className="nw-plate-body">
Nothing enters the engine yet. Add a transparent inbound above and pick the network it
should cover.
</p>
</div>
) : (
<ul className="nw-rows">
{inbounds.map((in_, i) => (
<InboundRow
key={`${in_.Name}-${i}`}
inbound={in_}
ifaces={ifaces}
busy={busy}
onToggle={(on) => toggleInbound(i, on)}
onEdit={(patch) => editInbound(i, patch)}
onDelete={() => removeInbound(i)}
/>
))}
</ul>
)}
</div>
{toast && (
<div className="toast" role="status">
{toast}
</div>
)}
</section>
)
}
// ---- network picker --------------------------------------------------------
/**
* Which network a transparent inbound covers. Never free text: the options are
* the router's real LAN interfaces, classified by FIREWALL ZONE (isLanNetwork),
* which is the same rule the routing rules' Source picker uses. A stored value
* that isn't a current LAN interface is still offered — as an explicitly marked
* stale/uplink option — so opening the editor can't silently retarget it.
*/
function NetworkSelect({
value,
ifaces,
busy,
onChange,
}: {
value: string
ifaces: Interface[]
busy: boolean
onChange: (v: string) => void
}) {
const lans = ifaces.filter((i) => isLanNetwork(i.subnet, i.zone))
const known = lans.some((i) => i.name === value)
const stored = ifaces.find((i) => i.name === value)
return (
<select
className="nw-input mono"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={busy}
aria-label="Network to intercept"
>
<option value="">Select a network…</option>
{lans.map((i) => (
<option key={i.name} value={i.name}>
{i.name} · {i.subnet}
</option>
))}
{!known && value && (
<option value={value}>
{value}
{stored
? isWanNetwork(stored.zone)
? ' · internet uplink'
: ` · ${stored.subnet || 'no address'}`
: ' · not on this router'}
</option>
)}
</select>
)
}
// ---- add form --------------------------------------------------------------
/** The editable shape both the add form and the row editor bind to. */
interface Draft {
Name: string
Type: InboundType
Network: string
TproxyPort: string
Listen: string
Port: string
Auth: string
User: string
Pass: string
TargetAddr: string
TargetPort: string
TargetNetwork: string
TCP: boolean
UDP: boolean
}
function emptyDraft(): Draft {
return {
Name: '',
Type: 'tproxy',
Network: '',
TproxyPort: String(DEFAULT_TPROXY_PORT),
Listen: '127.0.0.1',
Port: '',
Auth: 'noauth',
User: '',
Pass: '',
TargetAddr: '',
TargetPort: '',
TargetNetwork: 'udp',
TCP: true,
UDP: true,
}
}
function toDraft(in_: Inbound): Draft {
const t = effectiveType(in_)
return {
Name: in_.Name,
Type: t,
Network: in_.Network ?? '',
TproxyPort: String(in_.TproxyPort || DEFAULT_TPROXY_PORT),
Listen: in_.Listen ?? '127.0.0.1',
Port: in_.Port ? String(in_.Port) : '',
Auth: in_.Auth === 'password' ? 'password' : 'noauth',
User: in_.User ?? '',
Pass: in_.Pass ?? '',
TargetAddr: in_.TargetAddr ?? '',
TargetPort: in_.TargetPort ? String(in_.TargetPort) : '',
TargetNetwork: in_.TargetNetwork && in_.TargetNetwork.trim() ? in_.TargetNetwork : 'udp',
TCP: in_.TCP ?? true,
UDP: in_.UDP ?? true,
}
}
/**
* Build the Inbound to persist. Fields are emitted PER TYPE, not merged over the
* previous value, so switching a socks listener to tproxy doesn't leave a stray
* Listen/Port behind — and so a key the daemon no longer knows can never sneak
* back in (PUT /api/config decodes with DisallowUnknownFields: one stray key
* fails the WHOLE write with a 400).
*
* TCP/UDP are only editable on a tproxy inbound, which is the only type that
* reads them. The local listeners still get both set to true, because that is
* what they actually do — socks/mixed always take TCP (and SOCKS5 UDP ASSOCIATE),
* and a dokodemo listener binds whatever `TargetNetwork` says. Writing anything
* else would make the daemon warn about a flag no one can see.
*/
function fromDraft(d: Draft, base: Partial<Inbound>, enabled: boolean): Inbound {
void base
const common = { Name: d.Name.trim(), Enabled: enabled }
const port = Number.parseInt(d.Port, 10)
switch (d.Type) {
case 'tproxy':
return {
...common,
Type: 'tproxy',
Network: d.Network.trim(),
TproxyPort: Number.parseInt(d.TproxyPort, 10) || DEFAULT_TPROXY_PORT,
TCP: d.TCP,
UDP: d.UDP,
}
case 'socks':
case 'http':
return {
...common,
Type: d.Type,
Listen: d.Listen.trim() || '127.0.0.1',
Port: port || 0,
Auth: d.Auth === 'password' ? 'password' : 'noauth',
User: d.Auth === 'password' ? d.User.trim() : '',
Pass: d.Auth === 'password' ? d.Pass : '',
TCP: true,
UDP: true,
}
case 'dokodemo':
return {
...common,
Type: 'dokodemo',
Listen: d.Listen.trim() || '127.0.0.1',
Port: port || 0,
TargetAddr: d.TargetAddr.trim(),
TargetPort: Number.parseInt(d.TargetPort, 10) || 0,
TargetNetwork: d.TargetNetwork,
TCP: true,
UDP: true,
}
}
}
/** Validate a draft; returns an error message or null. */
function validate(d: Draft): string | null {
if (!d.Name.trim()) return 'Give the inbound a name.'
if (d.Type === 'tproxy') {
if (!d.Network.trim()) return 'Pick the network this inbound should cover.'
if (!portValid(Number.parseInt(d.TproxyPort, 10))) return 'Port must be between 1 and 65535.'
if (!d.TCP && !d.UDP) return 'Carry TCP, UDP, or both — one of them must be on.'
return null
}
if (!portValid(Number.parseInt(d.Port, 10))) return 'Port must be between 1 and 65535.'
if ((d.Type === 'socks' || d.Type === 'http') && d.Auth === 'password') {
if (!d.User.trim()) return 'Password auth needs a username.'
if (!d.Pass) return 'Password auth needs a password.'
}
if (d.Type === 'dokodemo') {
// Both are required, not optional: without them the listener forwards to its
// own address and talks to itself, so nothing ever reaches the tunnel.
if (!d.TargetAddr.trim()) return 'Give the address this listener should forward to.'
if (!portValid(Number.parseInt(d.TargetPort, 10)))
return 'Give the port to forward to — between 1 and 65535.'
}
return null
}
function AddInboundForm({
busy,
disabled,
taken,
ifaces,
onAdd,
}: {
busy: boolean
disabled: boolean
taken: Set<string>
ifaces: Interface[]
onAdd: (d: Inbound) => Promise<boolean>
}) {
const [open, setOpen] = useState(false)
const [draft, setDraft] = useState<Draft>(emptyDraft)
const [err, setErr] = useState<string | null>(null)
const submit = async (e: React.FormEvent) => {
e.preventDefault()
const problem = validate(draft)
if (problem) {
setErr(problem)
return
}
if (taken.has(draft.Name.trim())) {
setErr('An inbound with that name already exists.')
return
}
const ok = await onAdd(fromDraft(draft, {}, true))
if (ok) {
setDraft(emptyDraft())
setErr(null)
setOpen(false)
}
}
if (!open) {
return (
<div className="nw-add-shut">
<Button variant="primary" onClick={() => setOpen(true)} disabled={busy || disabled}>
Add inbound
</Button>
</div>
)
}
return (
<form className="nw-add" onSubmit={(e) => void submit(e)} aria-label="Add an inbound">
<div className="nw-add-hd">
<span className="nw-add-title">New inbound</span>
<span className="nw-add-sub">Save, then Apply to put it on the data plane.</span>
</div>
<InboundFields
draft={draft}
ifaces={ifaces}
busy={busy}
onChange={(d) => {
setDraft(d)
if (err) setErr(null)
}}
/>
{err && (
<p className="nw-err" role="alert">
{err}
</p>
)}
<div className="nw-add-foot">
<Button type="submit" variant="primary" disabled={busy}>
{busy ? 'Saving…' : 'Add inbound'}
</Button>
<Button
type="button"
onClick={() => {
setOpen(false)
setDraft(emptyDraft())
setErr(null)
}}
disabled={busy}
>
Cancel
</Button>
</div>
</form>
)
}
// ---- shared field block ----------------------------------------------------
function InboundFields({
draft,
ifaces,
busy,
nameLocked,
onChange,
}: {
draft: Draft
ifaces: Interface[]
busy: boolean
nameLocked?: boolean
onChange: (d: Draft) => void
}) {
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => onChange({ ...draft, [k]: v })
const typeInfo = TYPES.find((t) => t.id === draft.Type)
const isListener = draft.Type === 'socks' || draft.Type === 'http'
return (
<div className="nw-fields">
<label className="nw-field">
<span className="nw-flabel">Name</span>
<input
className="nw-input mono"
type="text"
value={draft.Name}
onChange={(e) => set('Name', e.target.value)}
disabled={busy || nameLocked}
spellCheck={false}
autoComplete="off"
placeholder="lan"
/>
</label>
<label className="nw-field">
<span className="nw-flabel">Kind</span>
<select
className="nw-input mono"
value={draft.Type}
onChange={(e) => set('Type', e.target.value as InboundType)}
disabled={busy}
>
{TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
</label>
{typeInfo && <p className="nw-kind-blurb">{typeInfo.blurb}</p>}
{draft.Type === 'tproxy' && (
<>
<label className="nw-field">
<span className="nw-flabel">Network</span>
<NetworkSelect
value={draft.Network}
ifaces={ifaces}
busy={busy}
onChange={(v) => set('Network', v)}
/>
</label>
<label className="nw-field nw-field-sm">
<span className="nw-flabel">Port</span>
<input
className="nw-input mono"
type="text"
inputMode="numeric"
value={draft.TproxyPort}
onChange={(e) => set('TproxyPort', e.target.value)}
disabled={busy}
spellCheck={false}
/>
</label>
</>
)}
{(isListener || draft.Type === 'dokodemo') && (
<>
<label className="nw-field">
<span className="nw-flabel">Listen on</span>
<input
className="nw-input mono"
type="text"
value={draft.Listen}
onChange={(e) => set('Listen', e.target.value)}
disabled={busy}
spellCheck={false}
autoComplete="off"
placeholder="127.0.0.1"
/>
</label>
<label className="nw-field nw-field-sm">
<span className="nw-flabel">Port</span>
<input
className="nw-input mono"
type="text"
inputMode="numeric"
value={draft.Port}
onChange={(e) => set('Port', e.target.value)}
disabled={busy}
spellCheck={false}
placeholder="1080"
/>
</label>
</>
)}
{isListener && (
<>
<label className="nw-field">
<span className="nw-flabel">Sign-in</span>
<select
className="nw-input mono"
value={draft.Auth}
onChange={(e) => set('Auth', e.target.value)}
disabled={busy}
>
<option value="noauth">Open — no username</option>
<option value="password">Username and password</option>
</select>
</label>
{draft.Auth === 'password' && (
<>
<label className="nw-field">
<span className="nw-flabel">Username</span>
<input
className="nw-input mono"
type="text"
value={draft.User}
onChange={(e) => set('User', e.target.value)}
disabled={busy}
spellCheck={false}
autoComplete="off"
/>
</label>
<label className="nw-field">
<span className="nw-flabel">Password</span>
<input
className="nw-input mono"
type="password"
value={draft.Pass}
onChange={(e) => set('Pass', e.target.value)}
disabled={busy}
autoComplete="new-password"
/>
</label>
</>
)}
</>
)}
{draft.Type === 'dokodemo' && (
<>
<label className="nw-field">
<span className="nw-flabel">Send to</span>
<input
className="nw-input mono"
type="text"
value={draft.TargetAddr}
onChange={(e) => set('TargetAddr', e.target.value)}
disabled={busy}
spellCheck={false}
autoComplete="off"
placeholder="10.8.0.1"
/>
</label>
<label className="nw-field nw-field-sm">
<span className="nw-flabel">Target port</span>
<input
className="nw-input mono"
type="text"
inputMode="numeric"
value={draft.TargetPort}
onChange={(e) => set('TargetPort', e.target.value)}
disabled={busy}
spellCheck={false}
placeholder="51820"
/>
</label>
<label className="nw-field">
<span className="nw-flabel">Target carries</span>
<select
className="nw-input mono"
value={draft.TargetNetwork}
onChange={(e) => set('TargetNetwork', e.target.value)}
disabled={busy}
>
<option value="udp">UDP</option>
<option value="tcp">TCP</option>
<option value="tcp,udp">TCP and UDP</option>
</select>
</label>
<p className="nw-kind-blurb">
The address, port and networks above are what this listener forwards to. Leave the
address or port out and it forwards to itself — traffic goes in and never comes out.
</p>
</>
)}
{/* Only a tproxy inbound chooses what it carries — the nft plane diverts TCP
and UDP separately. A socks/mixed listener always takes TCP (and SOCKS5
UDP ASSOCIATE) with no switch for it in the engine, and a dokodemo
listener binds whatever "Target carries" says, so neither gets a control
here that would only look like it did something. */}
{draft.Type === 'tproxy' && (
<fieldset className="nw-checks">
<legend className="nw-flabel">Carries</legend>
<label className="nw-check">
<input
type="checkbox"
checked={draft.TCP}
onChange={(e) => set('TCP', e.target.checked)}
disabled={busy}
/>
<span>TCP</span>
</label>
<label className="nw-check">
<input
type="checkbox"
checked={draft.UDP}
onChange={(e) => set('UDP', e.target.checked)}
disabled={busy}
/>
<span>UDP</span>
</label>
</fieldset>
)}
</div>
)
}
// ---- row -------------------------------------------------------------------
function InboundRow({
inbound,
ifaces,
busy,
onToggle,
onEdit,
onDelete,
}: {
inbound: Inbound
ifaces: Interface[]
busy: boolean
onToggle: (on: boolean) => void
onEdit: (patch: Inbound) => Promise<boolean>
onDelete: () => void
}) {
const [open, setOpen] = useState(false)
const [draft, setDraft] = useState<Draft>(() => toDraft(inbound))
const [err, setErr] = useState<string | null>(null)
const panelId = `nw-edit-${inbound.Name}`
const type = effectiveType(inbound)
const capturing = intercepts(inbound)
// Re-seed the editor whenever the underlying inbound changes identity/shape,
// so a save elsewhere (or a reload) can't leave a stale draft in an open row.
useEffect(() => {
setDraft(toDraft(inbound))
}, [inbound])
const submit = async (e: React.FormEvent) => {
e.preventDefault()
const problem = validate(draft)
if (problem) {
setErr(problem)
return
}
const ok = await onEdit(fromDraft(draft, inbound, inbound.Enabled))
if (ok) {
setErr(null)
setOpen(false)
}
}
return (
<li className={`nw-row${open ? ' nw-row--open' : ''}`}>
<div className="nw-row-head">
<Toggle
pressed={inbound.Enabled}
onChange={onToggle}
label={`${inbound.Enabled ? 'Disable' : 'Enable'} inbound ${inbound.Name}`}
disabled={busy}
/>
<div className="nw-row-main">
<div className="nw-row-l1">
<span className="nw-row-name">{inbound.Name}</span>
<span className={`nw-badge nw-badge-${type}`}>{type}</span>
{capturing ? (
<span className="nw-badge nw-badge-live">intercepting</span>
) : (
type === 'tproxy' && <span className="nw-badge nw-badge-idle">off</span>
)}
</div>
<div className="nw-row-l2 mono">
{type === 'tproxy' ? (
<>
<span>{inbound.Network || 'no network'}</span>
<span className="nw-row-sep">·</span>
<span>port {inbound.TproxyPort || DEFAULT_TPROXY_PORT}</span>
</>
) : (
<>
<span>{listenKey(inbound)}</span>
{type === 'dokodemo' && inbound.TargetAddr && (
<>
<span className="nw-row-sep">→</span>
<span>
{inbound.TargetAddr}:{inbound.TargetPort ?? 0}
</span>
</>
)}
{(type === 'socks' || type === 'http') && inbound.Auth === 'password' && (
<>
<span className="nw-row-sep">·</span>
<span>sign-in required</span>
</>
)}
</>
)}
</div>
{type !== 'tproxy' && (
<p className="nw-row-hint">
{type === 'http'
? 'Local listener, answering HTTP and SOCKS5 on this port — it doesn’t intercept any network.'
: 'Local listener — it doesn’t intercept any network.'}
</p>
)}
</div>
<div className="nw-row-actions">
<Button aria-expanded={open} aria-controls={panelId} onClick={() => setOpen((v) => !v)}>
{open ? 'Hide' : 'Edit'}
</Button>
<Button onClick={onDelete} disabled={busy} aria-label={`Delete inbound ${inbound.Name}`}>
Delete
</Button>
</div>
</div>
{open && (
<form id={panelId} className="nw-edit" onSubmit={(e) => void submit(e)}>
<InboundFields
draft={draft}
ifaces={ifaces}
busy={busy}
onChange={(d) => {
setDraft(d)
if (err) setErr(null)
}}
/>
{err && (
<p className="nw-err" role="alert">
{err}
</p>
)}
<div className="nw-add-foot">
<Button type="submit" variant="primary" disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</Button>
<Button
type="button"
onClick={() => {
setDraft(toDraft(inbound))
setErr(null)
setOpen(false)
}}
disabled={busy}
>
Cancel
</Button>
</div>
</form>
)}
</li>
)
}