diff --git a/openwrt/shaterd/Makefile b/openwrt/shaterd/Makefile index e20fa977..ddd48bef 100644 --- a/openwrt/shaterd/Makefile +++ b/openwrt/shaterd/Makefile @@ -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 PKG_LICENSE:=GPL-3.0-or-later diff --git a/panel/src/api.ts b/panel/src/api.ts index afc5d3c2..4586edcf 100644 --- a/panel/src/api.ts +++ b/panel/src/api.ts @@ -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. */ diff --git a/panel/src/mock.ts b/panel/src/mock.ts index 07cd57a5..30337052 100644 --- a/panel/src/mock.ts +++ b/panel/src/mock.ts @@ -481,6 +481,11 @@ export async function getStatus(): Promise { // 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'), } } diff --git a/panel/src/pages/Targets.css b/panel/src/pages/Targets.css index 49bfa9e9..40730d82 100644 --- a/panel/src/pages/Targets.css +++ b/panel/src/pages/Targets.css @@ -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 { diff --git a/panel/src/pages/Targets.tsx b/panel/src/pages/Targets.tsx index 91d5695d..91f7c37d 100644 --- a/panel/src/pages/Targets.tsx +++ b/panel/src/pages/Targets.tsx @@ -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(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(null) const toastTimer = useRef(undefined) @@ -1176,6 +1202,7 @@ export default function Targets() { {egressEd?.mode === 'add' && ( setEgressEd(null)} @@ -1201,7 +1228,8 @@ export default function Targets() {
  • setEgressEd(null)} @@ -1216,6 +1244,7 @@ export default function Targets() { 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({ {egress.Name} {EGRESS_TYPE_LABEL[egress.Type] ?? egress.Type} {dpi && dpi: {dpi}} + {dead && ciadpi missing}
    {detail} @@ -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 busy: boolean onCancel: () => void @@ -2531,6 +2575,10 @@ function EgressEditor({ const [dpi, setDpi] = useState(initial?.DPI || 'off') const [err, setErr] = useState(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) => ( - - ))} + {EGRESS_TYPES.map((t) => { + const locked = t.id === 'byedpi' && byedpiLocked + return ( + + ) + })} {!typeInfo && }

    {typeInfo?.blurb ?? 'This engine builds no outbound for that type, so everything routed here is blocked. Pick one above.'}

    + {byedpiLocked && ( +

    + Install the byedpi package to enable the ByeDPI egress. +

    + )} + {byedpiMissing && wasByedpi && type === 'byedpi' && ( +

    + The byedpi package (ciadpi) is not installed — everything routed to this + egress is blocked until you install it. The egress is kept as saved. +

    + )} {type === 'interface' && ( diff --git a/shater/panel/api.go b/shater/panel/api.go index 0ecfcc51..8c5bb53b 100644 --- a/shater/panel/api.go +++ b/shater/panel/api.go @@ -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(), }) }