feat(panel): gate byedpi egress on package presence

byedpi (ciadpi) ships as a separate optional package; the panel offered the
`byedpi` egress type regardless, so selecting it without the package installed
created a dead, fail-closed egress. Now GET /api/status reports
`byedpi_installed` (exec.LookPath("ciadpi"), os.Stat fallback), and the egress
type picker disables the ByeDPI option with a hint when it's absent. Existing
byedpi egresses are never hidden or rewritten (config is sacred) — shown with an
amber warning and still round-trip on save; only NEW selection is blocked.
Unknown status (older daemon / fetch fail) => no gating.

Bump shaterd PKG_RELEASE 1 -> 2 (the SPA is embedded in the daemon binary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 20:26:23 +03:00
co-authored by Claude Opus 4.8
parent 4a1542b30a
commit 6d2a729eaa
6 changed files with 122 additions and 11 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=shaterd
PKG_VERSION:=0.2.0
PKG_RELEASE:=1
PKG_RELEASE:=2
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
PKG_LICENSE:=GPL-3.0-or-later
+6
View File
@@ -103,6 +103,12 @@ export interface Status {
// Absent on older daemons ⇒ show nothing rather than guessing.
started_unix?: number
uptime_seconds?: number
// Is the `ciadpi` binary (optional `byedpi` package) present on the router?
// A byedpi egress without it is dead (fail-closed), so the Targets editor
// refuses to create NEW byedpi egresses when this is false. Absent on older
// daemons ⇒ unknown, in which case the UI does NOT gate (never lock an
// operator out of a control on a guess).
byedpi_installed?: boolean
}
/** POST /api/apply|confirm|rollback — mirrors the control socket result. */
+5
View File
@@ -481,6 +481,11 @@ export async function getStatus(): Promise<Status> {
// the reading ticks forward across polls exactly like the real daemon's does.
started_unix: MOCK_STARTED_UNIX,
uptime_seconds: Math.floor(Date.now() / 1000) - MOCK_STARTED_UNIX,
// ?nobyedpi flips the ciadpi-missing state so the gated Targets editor is
// exercisable in mock mode.
byedpi_installed: !new URLSearchParams(
typeof location === 'undefined' ? '' : location.search,
).has('nobyedpi'),
}
}
+10
View File
@@ -174,6 +174,12 @@
border-color: color-mix(in srgb, var(--accent) 55%, var(--groove));
color: var(--accent);
}
/* Semantics carry the colour: amber = degraded, not on fire (same recipe as the
dev-badge--warn / dns-badge--warn variants). */
.tg-badge--warn {
border-color: color-mix(in srgb, var(--amber) 55%, var(--groove));
color: var(--amber);
}
/* ---- chain signal path (the signature) ---- */
.tg-path {
@@ -538,6 +544,10 @@
color: var(--dim);
max-width: 62ch;
}
/* The hint escalates to amber when the chosen value cannot carry traffic. */
.tg-fhint--warn {
color: var(--amber);
}
/* editor footer */
.tg-ed-foot {
+74 -8
View File
@@ -8,6 +8,7 @@ import {
getGroupsHealth,
getGroupsTest,
getInterfaces,
getStatus,
postGroupsTest,
postNodesTest,
putConfig,
@@ -506,6 +507,31 @@ export default function Targets() {
}
}, [])
// Whether the `ciadpi` binary (the optional `byedpi` package) is present on
// the router. A byedpi egress without it is DEAD — fail-closed, everything
// bound to it is blocked — so the editor refuses to create NEW byedpi
// egresses when it is missing. null = unknown (older daemon without the
// field, or the read failed): unknown must NOT gate — never lock an operator
// out of a control on a guess. Existing byedpi egresses are never hidden or
// rewritten either way; they just carry a warning (config is sacred).
const [byedpiInstalled, setByedpiInstalled] = useState<boolean | null>(null)
useEffect(() => {
let alive = true
getStatus()
.then((s) => {
if (alive && typeof s.byedpi_installed === 'boolean')
setByedpiInstalled(s.byedpi_installed)
})
.catch(() => {
/* leave null → no gating */
})
return () => {
alive = false
}
}, [])
// Only an explicit "not installed" gates; null (unknown) does not.
const byedpiMissing = byedpiInstalled === false
// ---- toast + persistent apply banner --------------------------------------
const [toast, setToast] = useState<string | null>(null)
const toastTimer = useRef<number | undefined>(undefined)
@@ -1176,6 +1202,7 @@ export default function Targets() {
{egressEd?.mode === 'add' && (
<EgressEditor
interfaces={interfaces}
byedpiMissing={byedpiMissing}
taken={egressNames}
busy={busy}
onCancel={() => setEgressEd(null)}
@@ -1201,7 +1228,8 @@ export default function Targets() {
<li key={e.Name}>
<EgressEditor
initial={e}
interfaces={interfaces}
interfaces={interfaces}
byedpiMissing={byedpiMissing}
taken={without(egressNames, e.Name)}
busy={busy}
onCancel={() => setEgressEd(null)}
@@ -1216,6 +1244,7 @@ export default function Targets() {
<EgressRow
key={e.Name}
egress={e}
byedpiMissing={byedpiMissing}
busy={busy}
onEdit={() => setEgressEd({ mode: 'edit', name: e.Name })}
onDelete={() => removeEgress(e.Name)}
@@ -2461,21 +2490,29 @@ function ChainEditor({
function EgressRow({
egress,
byedpiMissing,
busy,
onEdit,
onDelete,
}: {
egress: Egress
// The ciadpi binary is confirmed absent. An existing byedpi egress is still
// SHOWN (saved config never silently disappears) — it just wears a warning,
// because everything routed to it is blocked until the package is installed.
byedpiMissing: boolean
busy: boolean
onEdit: () => void
onDelete: () => void
}) {
const dead = egress.Type === 'byedpi' && byedpiMissing
const detail = useMemo(() => {
switch (egress.Type) {
case 'interface':
return egress.Interface ? `iface ${egress.Interface}` : 'no interface set'
case 'byedpi':
return `127.0.0.1:${egress.Port || 1080}`
return dead
? `127.0.0.1:${egress.Port || 1080} — byedpi package not installed, nothing routed here can leave`
: `127.0.0.1:${egress.Port || 1080}`
case 'direct':
return 'straight to WAN'
default:
@@ -2483,7 +2520,7 @@ function EgressRow({
// to it is blocked. Say so on the row rather than printing a bare word.
return `${egress.Type || 'no type'} — nothing routed here can leave`
}
}, [egress])
}, [egress, dead])
const dpi = DPI_TYPES.has(egress.Type) && egress.DPI && egress.DPI !== 'off' ? egress.DPI : ''
return (
@@ -2493,6 +2530,7 @@ function EgressRow({
<span className="tg-row-name">{egress.Name}</span>
<span className="tg-badge tg-badge--accent">{EGRESS_TYPE_LABEL[egress.Type] ?? egress.Type}</span>
{dpi && <span className="tg-badge">dpi: {dpi}</span>}
{dead && <span className="tg-badge tg-badge--warn">ciadpi missing</span>}
</div>
<div className="tg-row-l2 mono">
<span className="tg-row-detail">{detail}</span>
@@ -2512,6 +2550,7 @@ function EgressRow({
function EgressEditor({
initial,
interfaces,
byedpiMissing,
taken,
busy,
onCancel,
@@ -2519,6 +2558,11 @@ function EgressEditor({
}: {
initial?: Egress
interfaces: Interface[]
// The ciadpi binary is confirmed absent, so choosing byedpi would create a
// dead egress (fail-closed: everything bound to it is blocked). The option is
// then disabled for a NEW choice — but an egress that is ALREADY byedpi keeps
// it selectable, so opening and re-saving never rewrites stored config.
byedpiMissing: boolean
taken: Set<string>
busy: boolean
onCancel: () => void
@@ -2531,6 +2575,10 @@ function EgressEditor({
const [dpi, setDpi] = useState(initial?.DPI || 'off')
const [err, setErr] = useState<string | null>(null)
const typeInfo = EGRESS_TYPES.find((t) => t.id === type)
// This egress was byedpi when the editor opened — its own type stays legal
// even with the package gone, so saved config can always round-trip.
const wasByedpi = initial?.Type === 'byedpi'
const byedpiLocked = byedpiMissing && !wasByedpi
const submit = async () => {
const nm = name.trim()
@@ -2538,6 +2586,10 @@ function EgressEditor({
if (taken.has(nm)) return setErr(`An egress named “${nm}” already exists.`)
if (type === 'interface' && !iface.trim())
return setErr('Enter the UCI interface name (e.g. wan, wg0).')
// Belt to the disabled option's braces: a stale select state must not save
// an egress the router cannot run.
if (type === 'byedpi' && byedpiLocked)
return setErr('Install the byedpi package to add a ByeDPI egress.')
setErr(null)
const base: Egress = { ...(initial ?? ({} as Egress)), Name: nm, Type: type }
// Only carry the fields the chosen type uses; clear the rest. `Target` belonged
@@ -2585,17 +2637,31 @@ function EgressEditor({
}}
disabled={busy}
>
{EGRESS_TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
{EGRESS_TYPES.map((t) => {
const locked = t.id === 'byedpi' && byedpiLocked
return (
<option key={t.id} value={t.id} disabled={locked}>
{locked ? `${t.label} (package not installed)` : t.label}
</option>
)
})}
{!typeInfo && <option value={type}>{type || '—'} (unknown)</option>}
</select>
<p className="tg-fhint">
{typeInfo?.blurb ??
'This engine builds no outbound for that type, so everything routed here is blocked. Pick one above.'}
</p>
{byedpiLocked && (
<p className="tg-fhint">
Install the <code>byedpi</code> package to enable the ByeDPI egress.
</p>
)}
{byedpiMissing && wasByedpi && type === 'byedpi' && (
<p className="tg-fhint tg-fhint--warn" role="alert">
The <code>byedpi</code> package (ciadpi) is not installed — everything routed to this
egress is blocked until you install it. The egress is kept as saved.
</p>
)}
</label>
{type === 'interface' && (
+26 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"sort"
"strconv"
@@ -62,6 +63,28 @@ func requirePOST(w http.ResponseWriter, r *http.Request) bool {
type statusResponse struct {
apply.Status
Version string `json:"version"`
// ByeDPIInstalled reports whether the `ciadpi` binary (the optional `byedpi`
// package) is present on this router. A `byedpi` egress without it is dead —
// fail-closed, everything bound to it is blocked — so the panel gates NEW
// byedpi egresses on this flag. Additive: older daemons simply omit it.
ByeDPIInstalled bool `json:"byedpi_installed"`
}
// ciadpiPath is where the optional `byedpi` package installs its binary (see
// openwrt/byedpi/Makefile: INSTALL_BIN → /usr/bin/ciadpi).
const ciadpiPath = "/usr/bin/ciadpi"
// byedpiInstalled reports whether the ciadpi desync proxy is available: on PATH
// (the daemon's PATH includes /usr/bin on OpenWrt) or at its packaged install
// path — the latter as a fallback for a daemon started with a stripped PATH.
// A package-level seam so tests can force either answer.
var byedpiInstalled = func() bool {
if _, err := exec.LookPath("ciadpi"); err == nil {
return true
}
_, err := os.Stat(ciadpiPath)
return err == nil
}
// handleStatus → GET /api/status: live daemon/data-plane status + version.
@@ -71,8 +94,9 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, statusResponse{
Status: s.a.Status(),
Version: constant.Version,
Status: s.a.Status(),
Version: constant.Version,
ByeDPIInstalled: byedpiInstalled(),
})
}