feat(wake): W7 A10 idempotent installer + mosaic-wake.service (component-manifest, Gate-A, blank-reset retire, snapshot-guard, fail-closed install-validate) (#911)
Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
This commit was merged in pull request #911.
This commit is contained in:
@@ -39,10 +39,19 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
|||||||
# never an environment variable — so this opt-out can never sit silently
|
# never an environment variable — so this opt-out can never sit silently
|
||||||
# inherited in a shell profile. See #869 Point-1 C2.
|
# inherited in a shell profile. See #869 Point-1 C2.
|
||||||
ALLOW_INACTIVE_ENFORCEMENT=0
|
ALLOW_INACTIVE_ENFORCEMENT=0
|
||||||
|
# Component-scoped install (#892 W7): `install.sh --component <name>` runs an
|
||||||
|
# additive, self-contained component installer and EXITS — it never enters the
|
||||||
|
# full-framework sync below and never modifies framework-manifest ownership
|
||||||
|
# behavior (#869: the diff is ADDITIVE). Parsed as a two-token flag here.
|
||||||
|
COMPONENT=""
|
||||||
|
_prev_arg=""
|
||||||
for _arg in "$@"; do
|
for _arg in "$@"; do
|
||||||
case "$_arg" in
|
case "$_arg" in
|
||||||
--allow-inactive-enforcement) ALLOW_INACTIVE_ENFORCEMENT=1 ;;
|
--allow-inactive-enforcement) ALLOW_INACTIVE_ENFORCEMENT=1 ;;
|
||||||
|
--component=*) COMPONENT="${_arg#--component=}" ;;
|
||||||
esac
|
esac
|
||||||
|
[[ "$_prev_arg" == "--component" ]] && COMPONENT="$_arg"
|
||||||
|
_prev_arg="$_arg"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Shared framework path-ownership manifest reader (#791). Parity with
|
# Shared framework path-ownership manifest reader (#791). Parity with
|
||||||
@@ -605,6 +614,46 @@ run_migrations() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Component-scoped install (#892 W7) — additive early dispatch.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# `install.sh --component <name>` delegates to the component's own idempotent,
|
||||||
|
# fail-closed installer and EXITS. This path is ADDITIVE (#869): it does NOT run
|
||||||
|
# the full-framework sync, does NOT alter framework-manifest ownership behavior,
|
||||||
|
# and touches NOTHING the #869 install-ordering-guard covers (no runtime-asset
|
||||||
|
# linking, no lease-enforcement hook wiring). Each component installer is
|
||||||
|
# INTERSECTED-AND-VALIDATED against the single SSOT framework-manifest.txt, so a
|
||||||
|
# component manifest can never authorize a write outside framework ownership.
|
||||||
|
run_component_install() {
|
||||||
|
local name="$1"
|
||||||
|
case "$name" in
|
||||||
|
wake)
|
||||||
|
local wi="$SOURCE_DIR/tools/wake/wake-install.sh"
|
||||||
|
if [[ ! -x "$wi" && ! -f "$wi" ]]; then
|
||||||
|
fail "Component 'wake' installer not found at $wi"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
step "Installing Mosaic component: wake"
|
||||||
|
WAKE_INSTALL_SOURCE="$SOURCE_DIR" WAKE_INSTALL_TARGET="$TARGET_DIR" \
|
||||||
|
bash "$wi" install
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
fail "--component requires a name (e.g. --component wake)."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "Unknown component '$name'. Supported: wake."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -n "$COMPONENT" ]]; then
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
run_component_install "$COMPONENT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
# Main
|
# Main
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
31
packages/mosaic/framework/systemd/user/mosaic-wake.service
Normal file
31
packages/mosaic/framework/systemd/user/mosaic-wake.service
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[Unit]
|
||||||
|
# Mosaic wake DETECTOR daemon (A1/W7 of the wake canon, EPIC #892). A LONG-LIVED
|
||||||
|
# single-instance detector: tools/wake/detector.sh run. This is a SERVICE, not a
|
||||||
|
# timer — the per-class SLO lives INSIDE the daemon's run-loop (WAKE_DETECTOR_INTERVAL
|
||||||
|
# poll cadence + the per-cycle off-host beacon emit), never as a systemd
|
||||||
|
# OnUnitActiveSec interval. The blank-reset cadence idiom therefore does NOT apply
|
||||||
|
# to this unit; it applies only to the legacy mosaic-heartbeat@ timer during retire.
|
||||||
|
Description=Mosaic wake detector daemon (framework tools/wake/detector.sh run)
|
||||||
|
After=default.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Strip loader / noninteractive-shell controls before ExecStart, matching the
|
||||||
|
# lease-broker and tmux fleet units in this same directory (defense-in-depth
|
||||||
|
# against an injected BASH_ENV/ENV/LD_PRELOAD in the user manager environment).
|
||||||
|
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||||
|
# Operator-owned runtime configuration. This EnvironmentFile carries only the
|
||||||
|
# WAKE_* NAMES and the pluggable adapter COMMANDS (the off-host beacon/alarm sink
|
||||||
|
# and the HMAC key NAME) — NEVER the HMAC key material and NEVER the alarm
|
||||||
|
# endpoint value. Both are resolved BY NAME at runtime via load_credentials, so
|
||||||
|
# no secret and no endpoint is ever written into this unit. The installer's
|
||||||
|
# fail-closed install-validate (wake-install.sh validate-targets) is what proves
|
||||||
|
# the required names are configured + reachable BEFORE this unit is enabled; the
|
||||||
|
# '-' prefix keeps a missing file from masking that dedicated validation.
|
||||||
|
EnvironmentFile=-%h/.config/mosaic/wake/detector.env
|
||||||
|
ExecStart=/bin/bash --noprofile --norc %h/.config/mosaic/tools/wake/detector.sh run
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -72,6 +72,26 @@ SEQ_FILE="$BEACON_DIR/seq"
|
|||||||
# default keeps the primitive self-contained + testable.
|
# default keeps the primitive self-contained + testable.
|
||||||
RECEIVED_FILE="${WAKE_BEACON_RECEIVED:-$BEACON_DIR/received.json}"
|
RECEIVED_FILE="${WAKE_BEACON_RECEIVED:-$BEACON_DIR/received.json}"
|
||||||
|
|
||||||
|
# The non-circular HMAC signer (A5/W3). Reused VERBATIM here for beacon
|
||||||
|
# authentication (W7 monitor-integration hardening): emit signs the beacon core
|
||||||
|
# via `sign.sh sign-digest`, record verifies via the existing `sign.sh verify`
|
||||||
|
# path. Both resolve the key BY NAME (load_credentials store) — never inline.
|
||||||
|
SIGN_SH="$SCRIPT_DIR/sign.sh"
|
||||||
|
|
||||||
|
# Beacon HMAC key NAME (by-name, never the key itself). Signing is engaged ONLY
|
||||||
|
# when a key name is configured — WAKE_BEACON_HMAC_KEY_NAME, else WAKE_HMAC_KEY_NAME.
|
||||||
|
# When configured, record REQUIRES an authentic, bound envelope (a spoofed or
|
||||||
|
# unsigned beacon is REJECTED). When unset, the legacy unsigned path is preserved
|
||||||
|
# (W7 install-validate is what guarantees a key is configured in production).
|
||||||
|
_beacon_key_name() { printf '%s' "${WAKE_BEACON_HMAC_KEY_NAME:-${WAKE_HMAC_KEY_NAME:-}}"; }
|
||||||
|
|
||||||
|
# Canonical beacon CORE (stdin: a full beacon record; stdout: sorted-key JSON with
|
||||||
|
# the signature envelope and the monitor-stamped ingested_ts stripped). emit signs
|
||||||
|
# this exact projection; record recomputes it identically, so the MAC binds the
|
||||||
|
# beacon's payload and an envelope cannot be lifted onto a different beacon.
|
||||||
|
_beacon_core() { jq -cS 'del(.beacon_envelope) | del(.ingested_ts)'; }
|
||||||
|
_beacon_sha256() { openssl dgst -sha256 -r 2>/dev/null | awk '{print $1}'; }
|
||||||
|
|
||||||
_need_jq() {
|
_need_jq() {
|
||||||
command -v jq >/dev/null 2>&1 || {
|
command -v jq >/dev/null 2>&1 || {
|
||||||
echo "beacon.sh: jq is required" >&2
|
echo "beacon.sh: jq is required" >&2
|
||||||
@@ -215,6 +235,22 @@ cmd_emit() {
|
|||||||
independence:$independence, degraded:$degraded}
|
independence:$independence, degraded:$degraded}
|
||||||
+ (if $supervision_root == "" then {} else {supervision_root:$supervision_root} end)')"
|
+ (if $supervision_root == "" then {} else {supervision_root:$supervision_root} end)')"
|
||||||
|
|
||||||
|
# HMAC-SIGN the beacon (W7 monitor-integration hardening) when a key name is
|
||||||
|
# configured. The signer is the existing non-circular sign.sh, keyed BY NAME —
|
||||||
|
# emit inlines no key. The signed envelope binds the beacon core (seq/emit_ts/
|
||||||
|
# content_hash), so the off-host monitor can reject a spoofed beacon at record.
|
||||||
|
local beacon_key; beacon_key="$(_beacon_key_name)"
|
||||||
|
if [ -n "$beacon_key" ]; then
|
||||||
|
command -v openssl >/dev/null 2>&1 || { echo "beacon.sh: openssl is required to HMAC-sign the beacon (key '$beacon_key' configured)." >&2; exit 3; }
|
||||||
|
local core envelope
|
||||||
|
core="$(printf '%s' "$record" | _beacon_core)"
|
||||||
|
if ! envelope="$(printf '%s' "$core" | "$SIGN_SH" sign-digest --key-name "$beacon_key" --observed-seq "$seq" --emit-ts "$emit_ts" 2>/dev/null)" || [ -z "$envelope" ]; then
|
||||||
|
echo "beacon.sh: FAIL LOUD — could not HMAC-sign the beacon with key '$beacon_key' (resolve it by-name in the credential store). Refusing to emit an UNSIGNED beacon when signing is configured." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
record="$(printf '%s' "$record" | jq -c --argjson env "$envelope" '. + {beacon_envelope:$env}')"
|
||||||
|
fi
|
||||||
|
|
||||||
# DEGRADED beacons are FLAGGED loudly (§1.3) — an isolated host must never
|
# DEGRADED beacons are FLAGGED loudly (§1.3) — an isolated host must never
|
||||||
# silently present a weaker different-supervision-root leg as full independence.
|
# silently present a weaker different-supervision-root leg as full independence.
|
||||||
if [ "$degraded" -eq 1 ]; then
|
if [ "$degraded" -eq 1 ]; then
|
||||||
@@ -262,6 +298,38 @@ cmd_record() {
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# HMAC-VERIFY the beacon (W7 monitor-integration hardening) — additive to the
|
||||||
|
# integer/monotonicity validation above. When a key name is configured, a beacon
|
||||||
|
# is accepted ONLY if it carries an envelope that is (1) AUTHENTIC under the
|
||||||
|
# by-name key (via the existing sign.sh verify path) and (2) BOUND to THIS beacon
|
||||||
|
# (its signed content_hash == sha256(core) and its seq/emit_ts match). A spoofed
|
||||||
|
# or unsigned beacon is REJECTED, so a forged liveness signal cannot roll the
|
||||||
|
# monitor's dead-man clock forward.
|
||||||
|
local beacon_key; beacon_key="$(_beacon_key_name)"
|
||||||
|
if [ -n "$beacon_key" ]; then
|
||||||
|
command -v openssl >/dev/null 2>&1 || { echo "beacon.sh record: openssl is required to HMAC-verify the beacon (key '$beacon_key' configured)." >&2; exit 3; }
|
||||||
|
local env
|
||||||
|
env="$(printf '%s' "$incoming" | jq -c '.beacon_envelope // empty' 2>/dev/null)"
|
||||||
|
if [ -z "$env" ]; then
|
||||||
|
echo "beacon.sh record: FAIL LOUD — beacon carries no signature envelope but signing is configured (key '$beacon_key'). Rejecting an UNSIGNED/spoofed beacon." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if ! printf '%s' "$env" | "$SIGN_SH" verify --key-name "$beacon_key" >/dev/null 2>&1; then
|
||||||
|
echo "beacon.sh record: FAIL LOUD — beacon signature is NOT authentic under key '$beacon_key' (bad HMAC). Rejecting a spoofed beacon." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
local core core_sha env_chash env_seq env_ts
|
||||||
|
core="$(printf '%s' "$incoming" | _beacon_core)"
|
||||||
|
core_sha="$(printf '%s' "$core" | _beacon_sha256)"
|
||||||
|
env_chash="$(printf '%s' "$env" | jq -r '.signed.content_hash // ""')"
|
||||||
|
env_seq="$(printf '%s' "$env" | jq -r '.signed.observed_seq // ""')"
|
||||||
|
env_ts="$(printf '%s' "$env" | jq -r '.signed.emit_ts // ""')"
|
||||||
|
if [ "$core_sha" != "$env_chash" ] || [ "$env_seq" != "$in_seq" ] || [ "$env_ts" != "$in_ts" ]; then
|
||||||
|
echo "beacon.sh record: FAIL LOUD — beacon signature is valid but NOT bound to this beacon (envelope lifted onto altered fields). Rejecting a spoofed beacon." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$(dirname "$RECEIVED_FILE")"
|
mkdir -p "$(dirname "$RECEIVED_FILE")"
|
||||||
# MONOTONIC receiver: keep the highest-seq beacon. A lower-or-equal seq is a
|
# MONOTONIC receiver: keep the highest-seq beacon. A lower-or-equal seq is a
|
||||||
# stale/replayed beacon and is IGNORED (does not roll the liveness clock back).
|
# stale/replayed beacon and is IGNORED (does not roll the liveness clock back).
|
||||||
@@ -274,7 +342,12 @@ cmd_record() {
|
|||||||
echo "beacon.sh record: ignoring stale/replayed beacon seq $in_seq (<= recorded $last_seq)" >&2
|
echo "beacon.sh record: ignoring stale/replayed beacon seq $in_seq (<= recorded $last_seq)" >&2
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
printf '%s\n' "$incoming" | _atomic_write "$RECEIVED_FILE"
|
# Stamp a MONITOR-SIDE ingested_ts at receive time (W7 monitor-integration
|
||||||
|
# hardening). Staleness (check) is computed from THIS receive-time, never the
|
||||||
|
# host-supplied emit_ts — so a host shipping a far-future emit_ts to defer its
|
||||||
|
# own staleness cannot fool the off-host dead-man clock.
|
||||||
|
local ingested_ts; ingested_ts="$(date +%s)"
|
||||||
|
printf '%s' "$incoming" | jq -c --argjson t "$ingested_ts" '. + {ingested_ts:$t}' | _atomic_write "$RECEIVED_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- off-host monitor side: the DEAD-MAN / ABSENCE check -------------------
|
# --- off-host monitor side: the DEAD-MAN / ABSENCE check -------------------
|
||||||
@@ -342,16 +415,20 @@ cmd_check() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
local last_ts last_seq host_id age
|
local last_ts last_seq host_id age
|
||||||
last_ts="$(jq -r '.emit_ts // empty' "$RECEIVED_FILE" 2>/dev/null)"
|
# Staleness clock = the MONITOR's receive-time (ingested_ts), stamped by record.
|
||||||
|
# Fall back to emit_ts only for a legacy record written before ingested_ts
|
||||||
|
# existed. A host-supplied far-future emit_ts therefore CANNOT defer staleness:
|
||||||
|
# once ingested_ts is present it governs, and emit_ts is never used for age.
|
||||||
|
last_ts="$(jq -r '.ingested_ts // .emit_ts // empty' "$RECEIVED_FILE" 2>/dev/null)"
|
||||||
last_seq="$(jq -r '.beacon_seq // empty' "$RECEIVED_FILE" 2>/dev/null)"
|
last_seq="$(jq -r '.beacon_seq // empty' "$RECEIVED_FILE" 2>/dev/null)"
|
||||||
host_id="$(jq -r '.host_id // "unknown"' "$RECEIVED_FILE" 2>/dev/null)"
|
host_id="$(jq -r '.host_id // "unknown"' "$RECEIVED_FILE" 2>/dev/null)"
|
||||||
case "$last_ts" in
|
case "$last_ts" in
|
||||||
'' | *[!0-9]*)
|
'' | *[!0-9]*)
|
||||||
local payload
|
local payload
|
||||||
payload="$(jq -cn --argjson now "$now" --argjson slo "$slo" \
|
payload="$(jq -cn --argjson now "$now" --argjson slo "$slo" \
|
||||||
'{kind:"beacon-absence-alarm", reason:"received beacon has no valid emit_ts (corrupt)",
|
'{kind:"beacon-absence-alarm", reason:"received beacon has no valid receive timestamp (corrupt)",
|
||||||
detected_ts:$now, slo_seconds:$slo}')"
|
detected_ts:$now, slo_seconds:$slo}')"
|
||||||
_fire_alarm "corrupt received beacon (no emit_ts)" "$payload"
|
_fire_alarm "corrupt received beacon (no ingested_ts/emit_ts)" "$payload"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -370,7 +447,7 @@ cmd_check() {
|
|||||||
--argjson age "$age" \
|
--argjson age "$age" \
|
||||||
--argjson slo "$slo" \
|
--argjson slo "$slo" \
|
||||||
'{kind:"beacon-absence-alarm", reason:"beacon stale past SLO",
|
'{kind:"beacon-absence-alarm", reason:"beacon stale past SLO",
|
||||||
host_id:$host_id, last_beacon_seq:$last_seq, last_emit_ts:$last_ts,
|
host_id:$host_id, last_beacon_seq:$last_seq, last_received_ts:$last_ts,
|
||||||
detected_ts:$now, age_seconds:$age, slo_seconds:$slo}')"
|
detected_ts:$now, age_seconds:$age, slo_seconds:$slo}')"
|
||||||
_fire_alarm "beacon stale (${age}s > SLO ${slo}s), host=$host_id last_seq=${last_seq:-?}" "$payload"
|
_fire_alarm "beacon stale (${age}s > SLO ${slo}s), host=$host_id last_seq=${last_seq:-?}" "$payload"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -17,8 +17,15 @@
|
|||||||
# 0.4.0 W5 — synthetic-canary FN-oracle + source-parity reconciler.
|
# 0.4.0 W5 — synthetic-canary FN-oracle + source-parity reconciler.
|
||||||
# 0.5.0 W6 — off-host dead-man beacon emitter + pluggable alarm-sink adapter
|
# 0.5.0 W6 — off-host dead-man beacon emitter + pluggable alarm-sink adapter
|
||||||
# + beacon-absence alarm (fail-loud on unconfigured/unreachable).
|
# + beacon-absence alarm (fail-loud on unconfigured/unreachable).
|
||||||
|
# 0.6.0 W7 — A10 idempotent, fail-closed component installer (Gate-A
|
||||||
|
# intersect+validate against the framework-manifest SSOT), the
|
||||||
|
# mosaic-wake.service detector daemon, the blank-reset retire idiom
|
||||||
|
# for the legacy heartbeat timer + snapshot-guard, and fail-closed
|
||||||
|
# alarm-target/HMAC-key install-validation. Also folds in the two W6
|
||||||
|
# monitor-integration observations (monitor-side ingested_ts
|
||||||
|
# staleness + beacon HMAC-verify at record).
|
||||||
component=wake
|
component=wake
|
||||||
version=0.5.0
|
version=0.6.0
|
||||||
|
|
||||||
# Watch-list schema this component consumes, and the INCLUSIVE range of
|
# Watch-list schema this component consumes, and the INCLUSIVE range of
|
||||||
# schema_version values it supports. A wake-watch-list.json whose schema_version
|
# schema_version values it supports. A wake-watch-list.json whose schema_version
|
||||||
@@ -58,5 +65,15 @@ schema_max=1
|
|||||||
# A same-host sibling is REJECTED as non-independent; an
|
# A same-host sibling is REJECTED as non-independent; an
|
||||||
# isolated host degrades to a FLAGGED different-supervision-root
|
# isolated host degrades to a FLAGGED different-supervision-root
|
||||||
# beacon; capture-pane is a liveness HINT only. (W6)
|
# beacon; capture-pane is a liveness HINT only. (W6)
|
||||||
# Out of scope (later waves): installer (W7 wires + install-validates the beacon
|
# wake-install.sh A10 — idempotent, fail-closed COMPONENT installer. Selects the
|
||||||
# target that beacon.sh's fail-loud primitive is designed for).
|
# component file set and INTERSECTS-AND-VALIDATES it against the
|
||||||
|
# single SSOT framework-manifest.txt (Gate A) — this VERSION
|
||||||
|
# manifest authorizes no path. Ships the blank-reset retire
|
||||||
|
# idiom (exactly-one OnUnitActiveUSec) for the legacy heartbeat
|
||||||
|
# timer, the snapshot-guard (no reap without a snapshot), and
|
||||||
|
# fail-closed alarm-target + HMAC-key install-validation (the
|
||||||
|
# installer wires + install-validates the beacon target that
|
||||||
|
# beacon.sh's fail-loud primitive is designed for). (W7)
|
||||||
|
# Companion (framework subtree, not under tools/wake/): systemd/user/mosaic-wake.service
|
||||||
|
# — the long-lived detector daemon unit (per-class SLO lives in
|
||||||
|
# the daemon, NOT a systemd interval). (W7)
|
||||||
|
|||||||
@@ -271,6 +271,70 @@ echo "== B10: fresh beacon within SLO -> ALIVE (exit 0, no alarm) =="
|
|||||||
[ ! -s "$ALARM_OUT" ] || fail_msg "B10: a fresh beacon must NOT fire an alarm"
|
[ ! -s "$ALARM_OUT" ] || fail_msg "B10: a fresh beacon must NOT fire an alarm"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
|
# ── W7 monitor-integration hardening (folded-in W6 observations, #910 review) ──
|
||||||
|
|
||||||
|
echo "== B11: staleness from monitor ingested_ts -> a far-future emit_ts STILL goes stale =="
|
||||||
|
(
|
||||||
|
H="$(fresh_home b11)"
|
||||||
|
export ALARM_OUT="$H/alarm.json"
|
||||||
|
export WAKE_BEACON_RECEIVED="$H/received.json"
|
||||||
|
export WAKE_ALARM_SINK_CMD="$CAPTURE_ALARM"
|
||||||
|
now="$(date +%s)"
|
||||||
|
# A host ships a FAR-FUTURE emit_ts (now + 100000s) to try to defer staleness.
|
||||||
|
# record stamps the monitor's own ingested_ts; check must use THAT, not emit_ts.
|
||||||
|
jq -cn --argjson ts "$((now + 100000))" \
|
||||||
|
'{kind:"wake-beacon", beacon_seq:9, emit_ts:$ts, host_id:"h", independence:"off-host", degraded:false}' \
|
||||||
|
| "$BEACON" record
|
||||||
|
# Simulate the monitor having received it 100s ago (ingested_ts backdated) while
|
||||||
|
# the host's emit_ts stays far in the future. Under emit_ts-based staleness this
|
||||||
|
# would read as ALIVE (age hugely negative); under receive-time it is STALE.
|
||||||
|
jq -c --argjson ing "$((now - 100))" '.ingested_ts = $ing' "$WAKE_BEACON_RECEIVED" >"$H/tmp" && mv "$H/tmp" "$WAKE_BEACON_RECEIVED"
|
||||||
|
out="$("$BEACON" check --slo-seconds 5 2>&1)"; rc=$?
|
||||||
|
[ "$rc" -eq 1 ] || fail_msg "B11: a far-future emit_ts must NOT defer staleness — receive-time governs (expected absence exit 1); got $rc [$out]"
|
||||||
|
echo "$out" | grep -qi 'ALARM FIRED' || fail_msg "B11: the receive-time-stale beacon must fire the absence alarm [$out]"
|
||||||
|
jq -e '.age_seconds >= 100' "$ALARM_OUT" >/dev/null 2>&1 || fail_msg "B11: staleness age must be measured from ingested_ts (>=100s), not emit_ts [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
||||||
|
) && ok
|
||||||
|
|
||||||
|
echo "== B12: HMAC-verify at record -> a spoofed (bad-sig) beacon is REJECTED =="
|
||||||
|
if ! command -v openssl >/dev/null 2>&1; then
|
||||||
|
echo "SKIP: openssl not available" >&2
|
||||||
|
else
|
||||||
|
(
|
||||||
|
H="$(fresh_home b12)"
|
||||||
|
export WAKE_STATE_HOME="$H"
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$H/credentials.json"
|
||||||
|
export WAKE_BEACON_HMAC_KEY_NAME="beacon"
|
||||||
|
jq -cn --arg k "test-beacon-hmac-key-do-not-echo" '{wake:{hmac_keys:{"beacon":$k}}}' >"$MOSAIC_CREDENTIALS_FILE"
|
||||||
|
export WAKE_BEACON_INDEPENDENCE="off-host"
|
||||||
|
# A capturing sink that keeps the exact shipped (signed) beacon record.
|
||||||
|
SHIPPED="$H/shipped.json"
|
||||||
|
SINK="$H/sink.sh"; printf '#!/usr/bin/env bash\ncat >"%s"\n' "$SHIPPED" >"$SINK"; chmod +x "$SINK"
|
||||||
|
export WAKE_BEACON_SINK_CMD="$SINK"
|
||||||
|
export WAKE_BEACON_RECEIVED="$H/received.json"
|
||||||
|
"$BEACON" emit >/dev/null 2>&1 || fail_msg "B12: a signed emit must succeed with a by-name key"
|
||||||
|
jq -e '.beacon_envelope' "$SHIPPED" >/dev/null 2>&1 || fail_msg "B12: a configured key must produce a signed beacon_envelope [$(cat "$SHIPPED" 2>/dev/null)]"
|
||||||
|
# (a) the authentic signed beacon is ACCEPTED at record.
|
||||||
|
"$BEACON" record <"$SHIPPED" >/dev/null 2>&1 || fail_msg "B12: an authentic signed beacon must be accepted at record"
|
||||||
|
[ -s "$WAKE_BEACON_RECEIVED" ] || fail_msg "B12: the authentic beacon must be stored"
|
||||||
|
# (b) a SPOOFED beacon (fields altered, envelope lifted) is REJECTED.
|
||||||
|
rm -f "$WAKE_BEACON_RECEIVED"
|
||||||
|
spoof="$H/spoof.json"
|
||||||
|
jq -c '.beacon_seq = 99999 | .host_id = "attacker"' "$SHIPPED" >"$spoof"
|
||||||
|
out="$("$BEACON" record <"$spoof" 2>&1)"; rc=$?
|
||||||
|
[ "$rc" -ne 0 ] || fail_msg "B12: a spoofed beacon (altered fields) must be REJECTED at record; got rc=$rc [$out]"
|
||||||
|
echo "$out" | grep -qi 'spoofed beacon' || fail_msg "B12: the rejection must name the spoof [$out]"
|
||||||
|
[ ! -s "$WAKE_BEACON_RECEIVED" ] || fail_msg "B12: a rejected spoof must NOT be stored (dead-man clock not advanced)"
|
||||||
|
# (c) an UNSIGNED beacon is rejected when signing is configured.
|
||||||
|
unsigned="$H/unsigned.json"
|
||||||
|
jq -c 'del(.beacon_envelope)' "$SHIPPED" >"$unsigned"
|
||||||
|
out2="$("$BEACON" record <"$unsigned" 2>&1)"; rc2=$?
|
||||||
|
[ "$rc2" -ne 0 ] || fail_msg "B12: an unsigned beacon must be REJECTED when signing is configured; got rc=$rc2 [$out2]"
|
||||||
|
# beacon.sh must not inline the key material.
|
||||||
|
grep -qF "test-beacon-hmac-key-do-not-echo" "$BEACON" && fail_msg "B12: beacon.sh must NOT inline the HMAC key"
|
||||||
|
true
|
||||||
|
) && ok
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [ -s "$FAILFILE" ]; then
|
if [ -s "$FAILFILE" ]; then
|
||||||
echo "wake beacon harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
echo "wake beacon harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
||||||
|
|||||||
249
packages/mosaic/framework/tools/wake/test-wake-install.sh
Executable file
249
packages/mosaic/framework/tools/wake/test-wake-install.sh
Executable file
@@ -0,0 +1,249 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-wake-install.sh — RED-FIRST invariant harness for W7 (EPIC #892, A10): the
|
||||||
|
# idempotent, fail-closed wake COMPONENT INSTALLER (wake-install.sh) + the W6
|
||||||
|
# monitor-integration hardening in beacon.sh (folded-in observation set).
|
||||||
|
#
|
||||||
|
# Each group asserts ONE enforcement-path invariant and is designed to go RED if
|
||||||
|
# that invariant regresses (a targeted mutation to wake-install.sh / beacon.sh
|
||||||
|
# makes exactly its group fail):
|
||||||
|
# I1 idempotency — a second component install produces NO diff / no rewrite (i)
|
||||||
|
# I2 Gate A — a candidate the framework-manifest SSOT does not own is REFUSED,
|
||||||
|
# fail-closed, with NO partial write (the wake manifest authorizes nothing) (i)
|
||||||
|
# I3 blank-reset — the reset-line idiom collapses the cadence to EXACTLY ONE
|
||||||
|
# OnUnitActiveUSec; without it, two values leak (negative control) (iii)
|
||||||
|
# I4 snapshot-guard — a reap with NO prior snapshot is REFUSED (fail-closed);
|
||||||
|
# after a snapshot it is allowed (iv)
|
||||||
|
# I5 fail-closed install-validate — unconfigured HMAC key OR unconfigured/
|
||||||
|
# unreachable alarm target FAILS LOUD (non-zero); configured passes; and
|
||||||
|
# wake-install.sh inlines NO secret/endpoint (v)
|
||||||
|
# I6 reset->verify->retire lifecycle — overlap leaves the legacy timer running;
|
||||||
|
# only a §4-vector pass retires it, and only snapshot-guarded (iii)
|
||||||
|
# I7 ingested_ts staleness — staleness is computed from the monitor's
|
||||||
|
# receive-time, so a host shipping a FAR-FUTURE emit_ts STILL goes stale (vi-a)
|
||||||
|
# I8 beacon HMAC-verify at record — a spoofed (bad-sig) beacon is REJECTED (vi-b)
|
||||||
|
# (I7/I8 exercise the W6 monitor-integration hardening folded into beacon.sh; the
|
||||||
|
# same invariants are also asserted in depth by test-wake-beacon.sh B11/B12.)
|
||||||
|
#
|
||||||
|
# Isolated per-group XDG/systemd/target dirs; no live network, no operator queue,
|
||||||
|
# no real systemd manager touched (the blank-reset verify uses the deterministic
|
||||||
|
# merge simulation, which mirrors `systemctl show -p OnUnitActiveUSec`).
|
||||||
|
#
|
||||||
|
# SC2030/SC2031 disabled: each group runs in its own ( ) subshell and re-exports
|
||||||
|
# its env, so environments are isolated by design (same idiom as the W2-W6 harnesses).
|
||||||
|
# shellcheck disable=SC2030,SC2031
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WI="$SCRIPT_DIR/wake-install.sh"
|
||||||
|
BEACON="$SCRIPT_DIR/beacon.sh"
|
||||||
|
FRAMEWORK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not available" >&2; exit 0; }
|
||||||
|
|
||||||
|
TMP_ROOT="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP_ROOT"' EXIT
|
||||||
|
|
||||||
|
FAILFILE="$TMP_ROOT/failures"
|
||||||
|
: >"$FAILFILE"
|
||||||
|
pass=0
|
||||||
|
fail_msg() { echo " FAIL: $*" >&2; echo "x" >>"$FAILFILE"; }
|
||||||
|
ok() { pass=$((pass + 1)); }
|
||||||
|
|
||||||
|
fresh() { local d="$TMP_ROOT/$1"; rm -rf "$d"; mkdir -p "$d"; printf '%s' "$d"; }
|
||||||
|
|
||||||
|
echo "== I1: idempotency — a second component install produces NO diff =="
|
||||||
|
(
|
||||||
|
TGT="$(fresh i1-target)"
|
||||||
|
export WAKE_INSTALL_SOURCE="$FRAMEWORK_ROOT"
|
||||||
|
export WAKE_INSTALL_TARGET="$TGT"
|
||||||
|
out1="$(bash "$WI" install 2>/dev/null)"
|
||||||
|
w1="$(echo "$out1" | sed -n 's/.*written=\([0-9]*\).*/\1/p')"
|
||||||
|
[ "${w1:-0}" -gt 0 ] || fail_msg "I1: first install must write the wake component (written=$w1)"
|
||||||
|
# Snapshot the installed tree, run again, and require byte-identical output.
|
||||||
|
cp1="$(fresh i1-copy)"; cp -a "$TGT/." "$cp1/"
|
||||||
|
out2="$(bash "$WI" install 2>/dev/null)"
|
||||||
|
w2="$(echo "$out2" | sed -n 's/.*written=\([0-9]*\).*/\1/p')"
|
||||||
|
[ "${w2:-1}" -eq 0 ] || fail_msg "I1: a re-run must write ZERO files (idempotent); got written=$w2"
|
||||||
|
if ! diff -r "$cp1" "$TGT" >/dev/null 2>&1; then
|
||||||
|
fail_msg "I1: second install changed the target tree (not idempotent)"
|
||||||
|
fi
|
||||||
|
) && ok
|
||||||
|
|
||||||
|
echo "== I2: Gate A — a non-framework-owned candidate is REFUSED (fail-closed, no partial write) =="
|
||||||
|
(
|
||||||
|
TGT="$(fresh i2-target)"
|
||||||
|
# A manifest that DE-AUTHORIZES the wake tools: tools/wake/** is operator-owned.
|
||||||
|
# The install MUST refuse the whole thing and write nothing (deny-wins).
|
||||||
|
BAD_MANIFEST="$TMP_ROOT/i2-manifest.txt"
|
||||||
|
cat >"$BAD_MANIFEST" <<'EOF'
|
||||||
|
[framework]
|
||||||
|
tools/**
|
||||||
|
systemd/**
|
||||||
|
defaults/**
|
||||||
|
[operator]
|
||||||
|
tools/wake/**
|
||||||
|
EOF
|
||||||
|
export WAKE_INSTALL_SOURCE="$FRAMEWORK_ROOT"
|
||||||
|
export WAKE_INSTALL_TARGET="$TGT"
|
||||||
|
export WAKE_INSTALL_MANIFEST="$BAD_MANIFEST"
|
||||||
|
out="$(bash "$WI" install 2>&1)"; rc=$?
|
||||||
|
[ "$rc" -ne 0 ] || fail_msg "I2: install must FAIL when a wake candidate is not framework-owned (got rc=$rc)"
|
||||||
|
echo "$out" | grep -qi 'Gate A VIOLATION' || fail_msg "I2: refusal must name the Gate A violation [$out]"
|
||||||
|
# No partial write: the target must have received NO wake tool file.
|
||||||
|
[ ! -e "$TGT/tools/wake/beacon.sh" ] || fail_msg "I2: a refused install must not have written any wake file (partial write leaked)"
|
||||||
|
# And the positive control: with the REAL manifest, the same candidates install.
|
||||||
|
unset WAKE_INSTALL_MANIFEST
|
||||||
|
bash "$WI" install >/dev/null 2>&1 || fail_msg "I2: install must SUCCEED under the real framework-manifest"
|
||||||
|
[ -f "$TGT/tools/wake/beacon.sh" ] || fail_msg "I2: real-manifest install must write the wake tools"
|
||||||
|
) && ok
|
||||||
|
|
||||||
|
echo "== I3: blank-reset — reset idiom yields EXACTLY ONE OnUnitActiveUSec (negative control leaks two) =="
|
||||||
|
(
|
||||||
|
UD="$(fresh i3-units)"
|
||||||
|
export WAKE_SYSTEMD_USER_DIR="$UD"
|
||||||
|
UNIT="mosaic-heartbeat@a.timer"
|
||||||
|
# Base unit already carries a cadence (the legacy value being superseded).
|
||||||
|
mkdir -p "$UD"
|
||||||
|
cat >"$UD/$UNIT" <<'EOF'
|
||||||
|
[Timer]
|
||||||
|
OnUnitActiveSec=15min
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
# Blank-reset drop-in: empty reset then the new value.
|
||||||
|
bash "$WI" blank-reset "$UD/$UNIT.d/interval.conf" "30min" >/dev/null 2>&1 \
|
||||||
|
|| fail_msg "I3: blank-reset drop-in write failed"
|
||||||
|
out="$(bash "$WI" verify-single "$UNIT" 2>&1)"; rc=$?
|
||||||
|
[ "$rc" -eq 0 ] || fail_msg "I3: blank-reset must resolve exactly one OnUnitActiveUSec (rc=$rc) [$out]"
|
||||||
|
echo "$out" | grep -qi 'EXACTLY ONE' || fail_msg "I3: verify must confirm exactly-one [$out]"
|
||||||
|
# NEGATIVE CONTROL: a drop-in WITHOUT the reset line appends -> TWO values -> fail.
|
||||||
|
cat >"$UD/$UNIT.d/interval.conf" <<'EOF'
|
||||||
|
[Timer]
|
||||||
|
OnUnitActiveSec=30min
|
||||||
|
EOF
|
||||||
|
out2="$(bash "$WI" verify-single "$UNIT" 2>&1)"; rc2=$?
|
||||||
|
[ "$rc2" -ne 0 ] || fail_msg "I3: without the reset line two cadences must leak (verify should FAIL) [$out2]"
|
||||||
|
) && ok
|
||||||
|
|
||||||
|
echo "== I4: snapshot-guard — reap without a snapshot is REFUSED; allowed after snapshot =="
|
||||||
|
(
|
||||||
|
UD="$(fresh i4-units)"; SNAP="$(fresh i4-snap)"
|
||||||
|
export WAKE_SYSTEMD_USER_DIR="$UD"
|
||||||
|
export WAKE_SNAPSHOT_DIR="$SNAP"
|
||||||
|
UNIT="mosaic-heartbeat@b.timer"
|
||||||
|
printf '[Timer]\nOnUnitActiveSec=10min\n' >"$UD/$UNIT"
|
||||||
|
# (a) reap WITHOUT snapshot -> refuse, unit still present.
|
||||||
|
out="$(bash "$WI" reap-unit "$UNIT" 2>&1)"; rc=$?
|
||||||
|
[ "$rc" -ne 0 ] || fail_msg "I4: reap without a snapshot must be REFUSED (rc=$rc)"
|
||||||
|
echo "$out" | grep -qi 'snapshot-guard' || fail_msg "I4: refusal must name the snapshot-guard [$out]"
|
||||||
|
[ -f "$UD/$UNIT" ] || fail_msg "I4: a refused reap must NOT delete the unit"
|
||||||
|
# (b) snapshot, then reap -> allowed, unit gone, snapshot retained.
|
||||||
|
bash "$WI" snapshot-unit "$UNIT" >/dev/null 2>&1 || fail_msg "I4: snapshot-unit failed"
|
||||||
|
bash "$WI" reap-unit "$UNIT" >/dev/null 2>&1 || fail_msg "I4: reap after snapshot must succeed"
|
||||||
|
[ ! -f "$UD/$UNIT" ] || fail_msg "I4: reap after snapshot must remove the unit"
|
||||||
|
[ -f "$SNAP/$UNIT" ] || fail_msg "I4: the snapshot must be retained after reap"
|
||||||
|
) && ok
|
||||||
|
|
||||||
|
echo "== I5: fail-closed install-validate — unconfigured HMAC/alarm FAIL LOUD; no secret inlined =="
|
||||||
|
(
|
||||||
|
H="$(fresh i5)"
|
||||||
|
CRED="$H/credentials.json"
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$CRED"
|
||||||
|
SECRET_KEY="s3cr3t-HMAC-DO-NOT-ECHO-xyz"
|
||||||
|
SECRET_ENDPOINT="https://monitor.invalid/alarm/DO-NOT-INLINE-abc123"
|
||||||
|
# --- HMAC key: absent store -> fail loud.
|
||||||
|
export WAKE_HMAC_KEY_NAME="primary"
|
||||||
|
out="$(bash "$WI" validate-hmac-key 2>&1)"; rc=$?
|
||||||
|
[ "$rc" -ne 0 ] || fail_msg "I5: missing credential store must FAIL LOUD for the HMAC key (rc=$rc)"
|
||||||
|
echo "$out" | grep -qi 'UNSIGNED' || fail_msg "I5: HMAC failure must warn about unsigned wakes [$out]"
|
||||||
|
# Now provide the key by-name -> pass, and the value must NEVER be echoed.
|
||||||
|
jq -cn --arg k "$SECRET_KEY" '{wake:{hmac_keys:{"primary":$k}}}' >"$CRED"
|
||||||
|
out2="$(bash "$WI" validate-hmac-key 2>&1)"; rc2=$?
|
||||||
|
[ "$rc2" -eq 0 ] || fail_msg "I5: a by-name-resolvable HMAC key must pass (rc=$rc2) [$out2]"
|
||||||
|
echo "$out2" | grep -qF "$SECRET_KEY" && fail_msg "I5: validate must NEVER echo the HMAC key material"
|
||||||
|
# --- alarm target: unconfigured -> fail loud (silent no-alarm host).
|
||||||
|
unset WAKE_ALARM_SINK_CMD
|
||||||
|
out3="$(bash "$WI" validate-alarm-target 2>&1)"; rc3=$?
|
||||||
|
[ "$rc3" -ne 0 ] || fail_msg "I5: unconfigured alarm target must FAIL LOUD (rc=$rc3)"
|
||||||
|
echo "$out3" | grep -qi 'silent no-alarm host' || fail_msg "I5: alarm failure must name the silent-no-alarm hazard [$out3]"
|
||||||
|
# unreachable -> fail loud.
|
||||||
|
export WAKE_ALARM_SINK_CMD="false"
|
||||||
|
out4="$(bash "$WI" validate-alarm-target 2>&1)"; rc4=$?
|
||||||
|
[ "$rc4" -ne 0 ] || fail_msg "I5: unreachable alarm sink must FAIL LOUD (rc=$rc4)"
|
||||||
|
echo "$out4" | grep -qi 'UNREACHABLE' || fail_msg "I5: alarm failure must name the unreachable target [$out4]"
|
||||||
|
# reachable -> pass.
|
||||||
|
export WAKE_ALARM_SINK_CMD="cat >/dev/null"
|
||||||
|
bash "$WI" validate-alarm-target >/dev/null 2>&1 || fail_msg "I5: a configured+reachable alarm sink must pass"
|
||||||
|
# The installer file must inline NO secret/endpoint.
|
||||||
|
grep -qF "$SECRET_ENDPOINT" "$WI" && fail_msg "I5: wake-install.sh must NOT inline any alarm endpoint"
|
||||||
|
grep -qE 'hmac_keys[^A-Za-z_].*=[^=].*[A-Za-z0-9]{8,}' "$WI" && fail_msg "I5: wake-install.sh must NOT inline key material"
|
||||||
|
true
|
||||||
|
) && ok
|
||||||
|
|
||||||
|
echo "== I6: reset->verify->retire — overlap keeps legacy running; only §4-vector pass retires =="
|
||||||
|
(
|
||||||
|
UD="$(fresh i6-units)"; SNAP="$(fresh i6-snap)"
|
||||||
|
export WAKE_SYSTEMD_USER_DIR="$UD"
|
||||||
|
export WAKE_SNAPSHOT_DIR="$SNAP"
|
||||||
|
TIMER="mosaic-heartbeat@c.timer"
|
||||||
|
printf '[Timer]\nOnUnitActiveSec=15min\n' >"$UD/$TIMER"
|
||||||
|
# (a) overlap phase (NO --vector-passed): reset+verify, legacy LEFT RUNNING.
|
||||||
|
out="$(bash "$WI" reset-verify-retire c --interval 30min 2>&1)"; rc=$?
|
||||||
|
[ "$rc" -eq 0 ] || fail_msg "I6: overlap reset-verify must succeed (rc=$rc) [$out]"
|
||||||
|
[ -f "$UD/$TIMER" ] || fail_msg "I6: overlap phase must LEAVE the legacy timer running (retire withheld)"
|
||||||
|
[ -f "$SNAP/$TIMER" ] || fail_msg "I6: the legacy timer must be snapshotted before any retire"
|
||||||
|
echo "$out" | grep -qi 'LEFT RUNNING' || fail_msg "I6: overlap must announce retire is withheld [$out]"
|
||||||
|
# (b) §4-vector pass: retire LAST, snapshot-guarded.
|
||||||
|
out2="$(bash "$WI" reset-verify-retire c --interval 30min --vector-passed 2>&1)"; rc2=$?
|
||||||
|
[ "$rc2" -eq 0 ] || fail_msg "I6: vector-passed retire must succeed (rc=$rc2) [$out2]"
|
||||||
|
[ ! -f "$UD/$TIMER" ] || fail_msg "I6: on §4-vector pass the legacy timer must be RETIRED"
|
||||||
|
[ -f "$SNAP/$TIMER" ] || fail_msg "I6: the snapshot must survive the retire"
|
||||||
|
) && ok
|
||||||
|
|
||||||
|
# ── W6 monitor-integration hardening folded into beacon.sh (vi) ────────────────
|
||||||
|
|
||||||
|
echo "== I7: ingested_ts staleness — a far-future emit_ts still goes stale (receive-time governs) =="
|
||||||
|
(
|
||||||
|
H="$(fresh i7)"
|
||||||
|
export WAKE_BEACON_RECEIVED="$H/received.json"
|
||||||
|
export WAKE_ALARM_SINK_CMD="cat >$H/alarm.json"
|
||||||
|
now="$(date +%s)"
|
||||||
|
# Host lies with a far-future emit_ts; record stamps the monitor's ingested_ts.
|
||||||
|
jq -cn --argjson ts "$((now + 100000))" \
|
||||||
|
'{kind:"wake-beacon", beacon_seq:3, emit_ts:$ts, host_id:"h", independence:"off-host", degraded:false}' \
|
||||||
|
| bash "$BEACON" record
|
||||||
|
# Simulate the beacon having been received 100s ago (backdate ingested_ts only).
|
||||||
|
jq -c --argjson ing "$((now - 100))" '.ingested_ts = $ing' "$WAKE_BEACON_RECEIVED" >"$H/t" && mv "$H/t" "$WAKE_BEACON_RECEIVED"
|
||||||
|
out="$(bash "$BEACON" check --slo-seconds 5 2>&1)"; rc=$?
|
||||||
|
[ "$rc" -eq 1 ] || fail_msg "I7: far-future emit_ts must NOT defer staleness — receive-time governs (expected exit 1); got $rc [$out]"
|
||||||
|
) && ok
|
||||||
|
|
||||||
|
echo "== I8: beacon HMAC-verify at record — a spoofed (bad-sig) beacon is REJECTED =="
|
||||||
|
if ! command -v openssl >/dev/null 2>&1; then
|
||||||
|
echo "SKIP: openssl not available" >&2
|
||||||
|
else
|
||||||
|
(
|
||||||
|
H="$(fresh i8)"
|
||||||
|
export WAKE_STATE_HOME="$H"
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$H/credentials.json"
|
||||||
|
export WAKE_BEACON_HMAC_KEY_NAME="beacon"
|
||||||
|
jq -cn --arg k "i8-hmac-key" '{wake:{hmac_keys:{"beacon":$k}}}' >"$MOSAIC_CREDENTIALS_FILE"
|
||||||
|
export WAKE_BEACON_INDEPENDENCE="off-host"
|
||||||
|
export WAKE_BEACON_SINK_CMD="cat >$H/shipped.json"
|
||||||
|
export WAKE_BEACON_RECEIVED="$H/received.json"
|
||||||
|
bash "$BEACON" emit >/dev/null 2>&1 || fail_msg "I8: signed emit must succeed"
|
||||||
|
bash "$BEACON" record <"$H/shipped.json" >/dev/null 2>&1 || fail_msg "I8: an authentic signed beacon must be accepted"
|
||||||
|
rm -f "$WAKE_BEACON_RECEIVED"
|
||||||
|
jq -c '.beacon_seq = 88888 | .host_id = "attacker"' "$H/shipped.json" >"$H/spoof.json"
|
||||||
|
out="$(bash "$BEACON" record <"$H/spoof.json" 2>&1)"; rc=$?
|
||||||
|
[ "$rc" -ne 0 ] || fail_msg "I8: a spoofed beacon must be REJECTED at record (rc=$rc) [$out]"
|
||||||
|
[ ! -s "$WAKE_BEACON_RECEIVED" ] || fail_msg "I8: a rejected spoof must NOT advance the dead-man clock"
|
||||||
|
) && ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ -s "$FAILFILE" ]; then
|
||||||
|
echo "wake install harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "wake install harness: all invariants passed ($pass groups)"
|
||||||
444
packages/mosaic/framework/tools/wake/wake-install.sh
Executable file
444
packages/mosaic/framework/tools/wake/wake-install.sh
Executable file
@@ -0,0 +1,444 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# wake-install.sh — A10 of the wake canon (EPIC #892, W7): the idempotent,
|
||||||
|
# fail-closed COMPONENT INSTALLER for the wake component.
|
||||||
|
#
|
||||||
|
# This is the ENFORCEMENT-PATH installer (#869 publish-gate discipline): every
|
||||||
|
# path is fail-closed, idempotent, and never silently falls through. It is driven
|
||||||
|
# by `framework/install.sh --component wake`, and every subcommand is also invoked
|
||||||
|
# directly by the red-first harness (test-wake-install.sh).
|
||||||
|
#
|
||||||
|
# CONTRACT ANCHORS (PACKAGING-PLAN §3/§5 + CONVERGED-DESIGN):
|
||||||
|
# (i) Idempotent component install + Gate A. The wake component ships its own
|
||||||
|
# manifest.txt as VERSION METADATA ONLY. Path-ownership stays the SINGLE
|
||||||
|
# SSOT framework-manifest.txt (consumed by BOTH bash + TS). The component
|
||||||
|
# file set is INTERSECTED-AND-VALIDATED against framework-manifest
|
||||||
|
# ownership: the wake manifest MUST NOT independently authorize any
|
||||||
|
# write/prune outside framework-manifest ownership. Re-running is a no-op.
|
||||||
|
# (iii) Blank-reset idiom on the LEGACY mosaic-heartbeat@<agent>.timer cadence
|
||||||
|
# drop-in during the §5 overlap->retire lifecycle: SNAPSHOT the legacy
|
||||||
|
# unit; write any per-class fallback-cadence drop-in in the blank-reset
|
||||||
|
# form (an empty OnUnitActiveSec= reset line before the new value, so
|
||||||
|
# exactly ONE OnUnitActiveUSec results); on §4-vector pass, REMOVE the
|
||||||
|
# legacy mosaic-heartbeat@ units (retire-LAST). Post-apply verify = exactly
|
||||||
|
# one OnUnitActiveUSec.
|
||||||
|
# (iv) snapshot-guard: block any reap / clean-checkout of a deployed unit
|
||||||
|
# WITHOUT a snapshot first (the deployed-from-uncommitted failure class
|
||||||
|
# must not recur). Fail-closed: no snapshot => refuse to reap.
|
||||||
|
# (v) Fail-closed alarm-target + HMAC-key install-validation (G1/G2a): the
|
||||||
|
# operator's W6 alarm-sink target (resolved BY NAME via load_credentials)
|
||||||
|
# must be CONFIGURED + reachable at install; the W3/W7 HMAC key must
|
||||||
|
# resolve BY NAME. Missing/unreachable => FAIL LOUD. This installer ships
|
||||||
|
# NO endpoint value and NO secret, and echoes neither.
|
||||||
|
#
|
||||||
|
# Operator-agnostic (framework firewall): no operator paths/names/secrets/hosts.
|
||||||
|
# All state via XDG/env; the HMAC key + alarm endpoint are resolved BY NAME.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# ─── paths (all overridable so the harness can isolate every run) ────────────
|
||||||
|
# Source framework tree (the checkout being installed FROM).
|
||||||
|
WAKE_INSTALL_SOURCE="${WAKE_INSTALL_SOURCE:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
||||||
|
# Target mosaic home (installed INTO).
|
||||||
|
WAKE_INSTALL_TARGET="${WAKE_INSTALL_TARGET:-${MOSAIC_HOME:-$HOME/.config/mosaic}}"
|
||||||
|
# framework-manifest.txt SSOT (Gate A). Override lets the harness prove a
|
||||||
|
# de-authorizing manifest makes the install REFUSE (no write outside ownership).
|
||||||
|
WAKE_INSTALL_MANIFEST="${WAKE_INSTALL_MANIFEST:-$WAKE_INSTALL_SOURCE/framework-manifest.txt}"
|
||||||
|
# systemd user unit dir the legacy timer / new units are deployed under.
|
||||||
|
WAKE_SYSTEMD_USER_DIR="${WAKE_SYSTEMD_USER_DIR:-$HOME/.config/systemd/user}"
|
||||||
|
# Retained snapshot store for the snapshot-guard (iv) — outside any repo.
|
||||||
|
WAKE_SNAPSHOT_DIR="${WAKE_SNAPSHOT_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/wake/unit-snapshots}"
|
||||||
|
|
||||||
|
# shellcheck source=../_lib/manifest.sh disable=SC1091
|
||||||
|
. "$SCRIPT_DIR/../_lib/manifest.sh"
|
||||||
|
|
||||||
|
if [[ -t 2 ]]; then
|
||||||
|
_C_G='\033[0;32m' _C_Y='\033[0;33m' _C_R='\033[0;31m' _C_0='\033[0m'
|
||||||
|
else
|
||||||
|
_C_G='' _C_Y='' _C_R='' _C_0=''
|
||||||
|
fi
|
||||||
|
wi_ok() { printf " ${_C_G}OK${_C_0} %s\n" "$1" >&2; }
|
||||||
|
wi_warn() { printf " ${_C_Y}WARN${_C_0} %s\n" "$1" >&2; }
|
||||||
|
wi_fail() { printf " ${_C_R}FAIL${_C_0} %s\n" "$1" >&2; }
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# (i) Idempotent component-manifest install + Gate A
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# The wake component's candidate file set, as source-relative paths. It is DERIVED
|
||||||
|
# (from the shipped filesystem under tools/wake/ + the two cross-subtree files the
|
||||||
|
# component owns) — the wake VERSION manifest authorizes NOTHING here. Every
|
||||||
|
# candidate is intersected-and-validated against framework-manifest ownership in
|
||||||
|
# wi_install before a single byte is written (Gate A).
|
||||||
|
_wi_component_candidates() {
|
||||||
|
local src="$WAKE_INSTALL_SOURCE" abs
|
||||||
|
if [[ -d "$src/tools/wake" ]]; then
|
||||||
|
while IFS= read -r -d '' abs; do
|
||||||
|
printf '%s\n' "${abs#"$src"/}"
|
||||||
|
done < <(find "$src/tools/wake" -type f -print0 | sort -z)
|
||||||
|
fi
|
||||||
|
# Cross-subtree files that logically belong to the wake component but live in
|
||||||
|
# shared framework subtrees. Listed here for ENUMERATION only — ownership is
|
||||||
|
# still decided solely by framework-manifest.txt in wi_install.
|
||||||
|
printf '%s\n' "systemd/user/mosaic-wake.service"
|
||||||
|
printf '%s\n' "defaults/wake-watch-list.schema.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# wi_install — idempotent component install. Gate A: refuse to write any candidate
|
||||||
|
# the framework-manifest SSOT does not own. Idempotent: an unchanged file is
|
||||||
|
# skipped (no rewrite, no mtime churn), so a second run produces no diff.
|
||||||
|
wi_install() {
|
||||||
|
local src="$WAKE_INSTALL_SOURCE" dst="$WAKE_INSTALL_TARGET"
|
||||||
|
# Load + validate the SSOT ownership manifest (fail-closed on missing/empty).
|
||||||
|
manifest_load "$WAKE_INSTALL_MANIFEST" || {
|
||||||
|
wi_fail "framework-manifest.txt failed to load ($WAKE_INSTALL_MANIFEST) — refusing to install (fail-closed)."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
local rel written=0 skipped=0 missing=0
|
||||||
|
# PASS 1 — Gate A validation FIRST, before any write. If ANY candidate resolves
|
||||||
|
# outside framework-manifest ownership, refuse the WHOLE install (no partial
|
||||||
|
# write): the wake component must never author a write the SSOT does not own.
|
||||||
|
while IFS= read -r rel; do
|
||||||
|
[[ -n "$rel" ]] || continue
|
||||||
|
if ! manifest_is_framework "$rel"; then
|
||||||
|
wi_fail "Gate A VIOLATION — wake candidate '$rel' is NOT framework-owned per framework-manifest.txt. The wake component manifest must not authorize a write outside framework-manifest ownership. Refusing the entire component install (fail-closed)."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done < <(_wi_component_candidates)
|
||||||
|
|
||||||
|
# PASS 2 — copy. Every path is already proven framework-owned above.
|
||||||
|
while IFS= read -r rel; do
|
||||||
|
[[ -n "$rel" ]] || continue
|
||||||
|
if [[ ! -f "$src/$rel" ]]; then
|
||||||
|
# A cross-subtree candidate that isn't shipped in this source is not an
|
||||||
|
# error (it may not exist yet); note and continue. tools/wake/** entries
|
||||||
|
# always exist because they were enumerated from the filesystem.
|
||||||
|
missing=$((missing + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ -f "$dst/$rel" ]] && cmp -s "$src/$rel" "$dst/$rel"; then
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
[[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}"
|
||||||
|
cp "$src/$rel" "$dst/$rel"
|
||||||
|
case "$rel" in *.sh) chmod +x "$dst/$rel" 2>/dev/null || true ;; esac
|
||||||
|
written=$((written + 1))
|
||||||
|
done < <(_wi_component_candidates)
|
||||||
|
|
||||||
|
wi_ok "wake component install: $written written, $skipped unchanged, $missing not-shipped (Gate A: all writes framework-owned)."
|
||||||
|
# Machine-readable summary for the harness (idempotency assertion keys off it).
|
||||||
|
printf 'wake-install: written=%s skipped=%s missing=%s\n' "$written" "$skipped" "$missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# (v) Fail-closed alarm-target + HMAC-key install-validation (G1/G2a)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Resolve the credential store the same way load_credentials / sign.sh do, WITHOUT
|
||||||
|
# ever echoing a resolved value. Sourcing credentials.sh only sets
|
||||||
|
# MOSAIC_CREDENTIALS_FILE; we never call load_credentials for a secret here.
|
||||||
|
_wi_cred_file() {
|
||||||
|
local cred_lib="$SCRIPT_DIR/../_lib/credentials.sh"
|
||||||
|
if [[ -f "$cred_lib" ]]; then
|
||||||
|
# shellcheck source=../_lib/credentials.sh disable=SC1091
|
||||||
|
. "$cred_lib"
|
||||||
|
fi
|
||||||
|
printf '%s' "${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# wi_validate_hmac_key — the W3/W7 HMAC key MUST resolve BY NAME
|
||||||
|
# (.wake.hmac_keys.<name>) in the operator credential store. Missing => FAIL LOUD:
|
||||||
|
# the installer must never deploy a config that would emit UNSIGNED wakes. Only a
|
||||||
|
# boolean/verdict is printed — the key material is NEVER echoed.
|
||||||
|
wi_validate_hmac_key() {
|
||||||
|
command -v jq >/dev/null 2>&1 || { wi_fail "jq is required for install-validate."; return 3; }
|
||||||
|
local name="${WAKE_HMAC_KEY_NAME:-default}" cred_file present
|
||||||
|
cred_file="$(_wi_cred_file)"
|
||||||
|
if [[ ! -f "$cred_file" ]]; then
|
||||||
|
wi_fail "HMAC-key validate — credential store not found ($cred_file): cannot resolve key name '$name'. Refusing to deploy a config that would emit UNSIGNED wakes (fail-closed)."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# jq -e sets exit status; we capture ONLY presence, never the value.
|
||||||
|
if present="$(jq -re --arg n "$name" '(.wake.hmac_keys[$n] // "") | if . == "" then empty else "present" end' "$cred_file" 2>/dev/null)" && [[ "$present" == present ]]; then
|
||||||
|
wi_ok "HMAC key '$name' resolves by-name in the credential store (value NOT read/echoed)."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
wi_fail "HMAC-key validate — key name '$name' is NOT configured in the credential store (.wake.hmac_keys). A missing key would emit UNSIGNED wakes. FAIL LOUD (fail-closed)."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# wi_validate_alarm_target — the operator's W6 alarm-sink target must be
|
||||||
|
# CONFIGURED (WAKE_ALARM_SINK_CMD set) and REACHABLE (a probe payload through the
|
||||||
|
# pluggable adapter exits 0). The adapter resolves its endpoint BY NAME internally
|
||||||
|
# (load_credentials); this installer ships/writes NO endpoint value. An
|
||||||
|
# unconfigured OR unreachable target FAILS LOUD — no silent no-alarm host (G1/G2a).
|
||||||
|
wi_validate_alarm_target() {
|
||||||
|
if [[ -z "${WAKE_ALARM_SINK_CMD:-}" ]]; then
|
||||||
|
wi_fail "alarm-target validate — WAKE_ALARM_SINK_CMD is UNSET: no off-host alarm route is configured. A host with no alarm sink is a silent no-alarm host. FAIL LOUD (G1/G2a, fail-closed)."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Reachability probe: send a benign install-probe payload through the adapter.
|
||||||
|
# A non-zero exit = UNREACHABLE target => fail loud. Adapter output is
|
||||||
|
# discarded so no resolved endpoint value can leak into installer output.
|
||||||
|
local probe='{"kind":"wake-install-probe"}'
|
||||||
|
if ! printf '%s\n' "$probe" | sh -c "$WAKE_ALARM_SINK_CMD" >/dev/null 2>&1; then
|
||||||
|
wi_fail "alarm-target validate — the alarm sink is UNREACHABLE (WAKE_ALARM_SINK_CMD probe exited non-zero): the alarm cannot route to a human/other-host. FAIL LOUD (G1/G2a, fail-closed)."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
wi_ok "alarm-sink target is configured + reachable (endpoint resolved by-name by the adapter; NOT read/echoed here)."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# wi_validate_targets — both gates. Either failure fails the whole validate loud.
|
||||||
|
wi_validate_targets() {
|
||||||
|
local rc=0
|
||||||
|
wi_validate_hmac_key || rc=1
|
||||||
|
wi_validate_alarm_target || rc=1
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
wi_fail "install-validate FAILED — refusing to enable the wake service with an unconfigured signing key or a silent no-alarm host (fail-closed)."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
wi_ok "install-validate PASSED — HMAC key + alarm sink are both fail-closed-ready."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# (iv) snapshot-guard — never reap a deployed unit without a snapshot first
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
_wi_snapshot_path() { printf '%s/%s' "$WAKE_SNAPSHOT_DIR" "$1"; }
|
||||||
|
|
||||||
|
# wi_snapshot_unit UNIT — retain a byte copy (+ the whole .d drop-in tree) of a
|
||||||
|
# deployed unit BEFORE any reap/clean-checkout. Idempotent (re-snapshotting an
|
||||||
|
# unchanged unit just refreshes it). Private (0700/0600): a unit can reference
|
||||||
|
# operator paths.
|
||||||
|
wi_snapshot_unit() {
|
||||||
|
local unit="$1"
|
||||||
|
[[ -n "$unit" ]] || { wi_fail "snapshot-unit: UNIT name required."; return 2; }
|
||||||
|
local src="$WAKE_SYSTEMD_USER_DIR/$unit" snap
|
||||||
|
snap="$(_wi_snapshot_path "$unit")"
|
||||||
|
if [[ ! -e "$src" ]]; then
|
||||||
|
wi_fail "snapshot-unit — deployed unit '$unit' not found under $WAKE_SYSTEMD_USER_DIR; nothing to snapshot."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local old_umask; old_umask="$(umask)"; umask 077
|
||||||
|
mkdir -p "$(dirname "$snap")"
|
||||||
|
cp "$src" "$snap"
|
||||||
|
# Capture the drop-in dir too (cadence drop-ins live in <unit>.d/).
|
||||||
|
if [[ -d "$WAKE_SYSTEMD_USER_DIR/$unit.d" ]]; then
|
||||||
|
rm -rf "$snap.d"; mkdir -p "$snap.d"
|
||||||
|
cp -a "$WAKE_SYSTEMD_USER_DIR/$unit.d/." "$snap.d/"
|
||||||
|
fi
|
||||||
|
umask "$old_umask"
|
||||||
|
wi_ok "snapshot-unit — '$unit' snapshotted to $snap (reap is now unblocked for this unit)."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# wi_has_snapshot UNIT — rc 0 iff a snapshot for UNIT exists.
|
||||||
|
wi_has_snapshot() {
|
||||||
|
[[ -f "$(_wi_snapshot_path "$1")" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# wi_reap_unit UNIT — remove a deployed unit (+ its .d drop-ins). FAIL-CLOSED: a
|
||||||
|
# reap with NO prior snapshot is REFUSED. This is the guard against the
|
||||||
|
# deployed-from-uncommitted failure class recurring: you cannot clean-checkout /
|
||||||
|
# reap a deployed-but-uncommitted unit until it is snapshotted.
|
||||||
|
wi_reap_unit() {
|
||||||
|
local unit="$1"
|
||||||
|
[[ -n "$unit" ]] || { wi_fail "reap-unit: UNIT name required."; return 2; }
|
||||||
|
if ! wi_has_snapshot "$unit"; then
|
||||||
|
wi_fail "snapshot-guard — REFUSING to reap '$unit': no snapshot exists (a reap/clean-checkout of a deployed-but-uncommitted unit without a snapshot is the exact failure class this guard forbids). Run 'wake-install.sh snapshot-unit $unit' first."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$WAKE_SYSTEMD_USER_DIR/$unit"
|
||||||
|
rm -rf "$WAKE_SYSTEMD_USER_DIR/$unit.d"
|
||||||
|
wi_ok "reap-unit — '$unit' reaped (snapshot retained at $(_wi_snapshot_path "$unit"))."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# (iii) blank-reset idiom + exactly-one-OnUnitActiveUSec verify + retire
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# wi_write_blank_reset_dropin DROPIN_FILE INTERVAL — write a [Timer] cadence
|
||||||
|
# drop-in in the BLANK-RESET form: an empty OnUnitActiveSec= reset line CLEARS any
|
||||||
|
# previously accumulated (list-valued) cadence, then the new value sets exactly
|
||||||
|
# one. This is byte-identical to the installer-7 idiom and guarantees exactly ONE
|
||||||
|
# effective OnUnitActiveUSec after systemd merges the base unit + all drop-ins.
|
||||||
|
wi_write_blank_reset_dropin() {
|
||||||
|
local file="$1" interval="$2"
|
||||||
|
[[ -n "$file" && -n "$interval" ]] || { wi_fail "blank-reset: DROPIN_FILE and INTERVAL required."; return 2; }
|
||||||
|
mkdir -p "$(dirname "$file")"
|
||||||
|
# The empty reset line MUST precede the new value (order is load-bearing).
|
||||||
|
cat >"$file" <<EOF
|
||||||
|
[Timer]
|
||||||
|
OnUnitActiveSec=
|
||||||
|
OnUnitActiveSec=$interval
|
||||||
|
EOF
|
||||||
|
wi_ok "blank-reset drop-in written: $file (OnUnitActiveSec reset -> $interval)."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# _wi_effective_onunitactive UNIT_FILE DROPIN_DIR — echo, one per line, the
|
||||||
|
# EFFECTIVE OnUnitActiveSec values after simulating systemd's list-merge: process
|
||||||
|
# the base unit then every drop-in in the .d dir in lexical (systemd) order; an
|
||||||
|
# EMPTY assignment RESETS the accumulated list, a non-empty one APPENDS. Mirrors
|
||||||
|
# `systemctl show -p OnUnitActiveUSec` semantics without needing a live manager.
|
||||||
|
_wi_effective_onunitactive() {
|
||||||
|
local unit_file="$1" dropin_dir="$2"
|
||||||
|
local -a eff=()
|
||||||
|
_wi_merge_file() {
|
||||||
|
local f="$1" line val
|
||||||
|
[[ -f "$f" ]] || return 0
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
line="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
case "$line" in
|
||||||
|
OnUnitActiveSec=*)
|
||||||
|
val="${line#OnUnitActiveSec=}"
|
||||||
|
val="${val#"${val%%[![:space:]]*}"}"
|
||||||
|
val="${val%"${val##*[![:space:]]}"}"
|
||||||
|
if [[ -z "$val" ]]; then
|
||||||
|
eff=() # empty assignment RESETS the list
|
||||||
|
else
|
||||||
|
eff+=("$val") # non-empty APPENDS
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <"$f"
|
||||||
|
}
|
||||||
|
_wi_merge_file "$unit_file"
|
||||||
|
if [[ -d "$dropin_dir" ]]; then
|
||||||
|
local d
|
||||||
|
while IFS= read -r d; do
|
||||||
|
[[ -n "$d" ]] && _wi_merge_file "$d"
|
||||||
|
done < <(find "$dropin_dir" -maxdepth 1 -type f -name '*.conf' | LC_ALL=C sort)
|
||||||
|
fi
|
||||||
|
local v
|
||||||
|
for v in ${eff[@]+"${eff[@]}"}; do printf '%s\n' "$v"; done
|
||||||
|
}
|
||||||
|
|
||||||
|
# wi_verify_single_active UNIT — post-apply verify: exactly ONE effective
|
||||||
|
# OnUnitActiveUSec. Prefers a live `systemctl --user show` when available +
|
||||||
|
# loaded; otherwise falls back to the deterministic merge simulation above.
|
||||||
|
# rc 0 = exactly one; rc 1 = zero or more-than-one (fail loud).
|
||||||
|
wi_verify_single_active() {
|
||||||
|
local unit="$1"
|
||||||
|
[[ -n "$unit" ]] || { wi_fail "verify-single: UNIT name required."; return 2; }
|
||||||
|
local count=""
|
||||||
|
if [[ "${WAKE_VERIFY_USE_SYSTEMCTL:-0}" == "1" ]] && command -v systemctl >/dev/null 2>&1; then
|
||||||
|
# Live path: systemd already merged base+drop-ins. One line, one value.
|
||||||
|
local shown
|
||||||
|
if shown="$(systemctl --user show "$unit" -p OnUnitActiveUSec --value 2>/dev/null)"; then
|
||||||
|
# --value prints the merged value (may be a space-joined list if >1).
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
set -- $shown
|
||||||
|
count=$#
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -z "$count" ]]; then
|
||||||
|
count="$(_wi_effective_onunitactive "$WAKE_SYSTEMD_USER_DIR/$unit" "$WAKE_SYSTEMD_USER_DIR/$unit.d" | grep -c .)"
|
||||||
|
fi
|
||||||
|
if [[ "$count" -eq 1 ]]; then
|
||||||
|
wi_ok "verify-single — '$unit' resolves EXACTLY ONE OnUnitActiveUSec (blank-reset idiom held)."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
wi_fail "verify-single — '$unit' resolves $count OnUnitActiveUSec values (expected exactly 1). The blank-reset idiom did not collapse the cadence. FAIL LOUD."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# wi_reset_verify_retire AGENT [--interval V] [--vector-passed] — the §5
|
||||||
|
# reset->verify->retire lifecycle for the legacy mosaic-heartbeat@<agent> timer.
|
||||||
|
# This is the TESTABLE acceptance path:
|
||||||
|
# 1. SNAPSHOT the legacy timer (snapshot-guard precondition for any later reap).
|
||||||
|
# 2. IF --interval is given, write the fallback-cadence drop-in in blank-reset
|
||||||
|
# form, then VERIFY exactly-one-OnUnitActiveUSec.
|
||||||
|
# 3. ONLY on --vector-passed (the §4-vector pass) REMOVE the legacy units, and
|
||||||
|
# only via the snapshot-guarded reap (retire-LAST).
|
||||||
|
wi_reset_verify_retire() {
|
||||||
|
local agent="" interval="" vector_passed=0
|
||||||
|
agent="${1:-}"; shift || true
|
||||||
|
[[ -n "$agent" ]] || { wi_fail "reset-verify-retire: AGENT required."; return 2; }
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--interval) interval="${2:-}"; shift 2 ;;
|
||||||
|
--vector-passed) vector_passed=1; shift ;;
|
||||||
|
*) wi_fail "reset-verify-retire: unknown option '$1'."; return 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
local legacy_timer="mosaic-heartbeat@$agent.timer"
|
||||||
|
local legacy_service="mosaic-heartbeat@$agent.service"
|
||||||
|
|
||||||
|
# 1) SNAPSHOT FIRST — unconditionally, before touching or retiring anything.
|
||||||
|
wi_snapshot_unit "$legacy_timer" || {
|
||||||
|
wi_fail "reset-verify-retire — could not snapshot the legacy timer '$legacy_timer'; refusing to proceed (snapshot-guard, fail-closed)."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2) blank-reset the fallback cadence (if a per-class fallback timer is used),
|
||||||
|
# then verify exactly one effective OnUnitActiveUSec.
|
||||||
|
if [[ -n "$interval" ]]; then
|
||||||
|
wi_write_blank_reset_dropin "$WAKE_SYSTEMD_USER_DIR/$legacy_timer.d/interval.conf" "$interval" || return 1
|
||||||
|
wi_verify_single_active "$legacy_timer" || {
|
||||||
|
wi_fail "reset-verify-retire — blank-reset verify failed for '$legacy_timer'; refusing to retire on an unverified cadence (fail-closed)."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3) RETIRE LAST — only on the §4-vector pass, and only via the snapshot-guarded
|
||||||
|
# reap. Without --vector-passed the legacy units are LEFT RUNNING (overlap).
|
||||||
|
if [[ "$vector_passed" -eq 1 ]]; then
|
||||||
|
wi_reap_unit "$legacy_timer" || return 1
|
||||||
|
# The paired service may not be independently deployed; reap it best-effort
|
||||||
|
# but still snapshot-guarded if present.
|
||||||
|
if [[ -e "$WAKE_SYSTEMD_USER_DIR/$legacy_service" ]]; then
|
||||||
|
wi_snapshot_unit "$legacy_service" && wi_reap_unit "$legacy_service" || return 1
|
||||||
|
fi
|
||||||
|
wi_ok "reset-verify-retire — legacy '$agent' heartbeat units RETIRED (post §4-vector pass, snapshot-guarded)."
|
||||||
|
else
|
||||||
|
wi_ok "reset-verify-retire — overlap phase: cadence reset+verified, legacy '$agent' units LEFT RUNNING (retire withheld until §4-vector pass)."
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# CLI dispatch — only when executed directly, never when sourced.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
cmd="${1:-}"; shift || true
|
||||||
|
case "$cmd" in
|
||||||
|
install) wi_install "$@" ;;
|
||||||
|
validate-targets) wi_validate_targets "$@" ;;
|
||||||
|
validate-hmac-key) wi_validate_hmac_key "$@" ;;
|
||||||
|
validate-alarm-target) wi_validate_alarm_target "$@" ;;
|
||||||
|
snapshot-unit) wi_snapshot_unit "$@" ;;
|
||||||
|
reap-unit) wi_reap_unit "$@" ;;
|
||||||
|
blank-reset) wi_write_blank_reset_dropin "$@" ;;
|
||||||
|
verify-single) wi_verify_single_active "$@" ;;
|
||||||
|
reset-verify-retire) wi_reset_verify_retire "$@" ;;
|
||||||
|
-h | --help | help | '')
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
Usage: wake-install.sh <command> [args]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
install Idempotent component-manifest install + Gate A.
|
||||||
|
validate-targets Fail-closed HMAC-key + alarm-target validate (v).
|
||||||
|
validate-hmac-key HMAC key resolves by-name, else fail loud.
|
||||||
|
validate-alarm-target Alarm sink configured + reachable, else fail loud.
|
||||||
|
snapshot-unit UNIT Snapshot a deployed unit (snapshot-guard precondition).
|
||||||
|
reap-unit UNIT Reap a deployed unit; REFUSES without a snapshot.
|
||||||
|
blank-reset DROPIN_FILE INTERVAL Write a blank-reset cadence drop-in.
|
||||||
|
verify-single UNIT Verify exactly one effective OnUnitActiveUSec.
|
||||||
|
reset-verify-retire AGENT [--interval V] [--vector-passed]
|
||||||
|
The §5 reset->verify->retire lifecycle.
|
||||||
|
EOF
|
||||||
|
[[ "$cmd" == -h || "$cmd" == --help || "$cmd" == help ]] && exit 0
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
wi_fail "unknown command '$cmd'"
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh"
|
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -81,6 +81,17 @@ const PROBE_PATHS = [
|
|||||||
'skills/mosaic-context-refresh/SKILL.md',
|
'skills/mosaic-context-refresh/SKILL.md',
|
||||||
'tools/git/pr-create.sh',
|
'tools/git/pr-create.sh',
|
||||||
'tools/_lib/manifest.sh',
|
'tools/_lib/manifest.sh',
|
||||||
|
// #892 W7 (Gate A): every wake component candidate the component installer may
|
||||||
|
// write MUST resolve framework-owned on BOTH resolvers. If either drifted to
|
||||||
|
// operator, the component install would refuse a legitimate write (or, worse,
|
||||||
|
// a divergent resolver could authorize one the SSOT forbids). These prove the
|
||||||
|
// wake component authorizes nothing outside framework-manifest ownership.
|
||||||
|
'tools/wake/beacon.sh',
|
||||||
|
'tools/wake/wake-install.sh',
|
||||||
|
'tools/wake/manifest.txt',
|
||||||
|
'tools/wake/test-wake-install.sh',
|
||||||
|
'systemd/user/mosaic-wake.service',
|
||||||
|
'defaults/wake-watch-list.schema.json',
|
||||||
'defaults/SOUL.md',
|
'defaults/SOUL.md',
|
||||||
'fleet/README.md',
|
'fleet/README.md',
|
||||||
'fleet/roles/coder.md',
|
'fleet/roles/coder.md',
|
||||||
|
|||||||
Reference in New Issue
Block a user