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:
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user