fix(zigbee2mqtt): drop BusyBox-incompatible grep from watchdog
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful

Second review round on #441. The first point was a real blocking defect
that would have shipped silently.

BusyBox grep does not support --line-buffered. Confirmed by running it:

    $ grep --line-buffered -oE '...' <<< 'Received payload: ...'
    grep: unrecognized option: line-buffered
    GREP_EXIT=2

The sidecar runs alpine:3.21 and `apk add mosquitto-clients` does not
pull GNU grep, so the `tail -F | grep --line-buffered | while read` pipe
would have produced zero lines forever while the container still logged
"following <file>" and appeared healthy — a silent no-op of the entire
feature, undetectable from the diff.

Fixed by removing the grep stage rather than installing GNU grep: match
in the read loop with a `case` glob and extract the fields with parameter
expansion. `tail` writes per line and `read` consumes per line, so there
is no buffering question and no extra package. Added a numeric guard on
the extracted address so a future log-format change cannot feed garbage
downstream.

Also from this round:

- Write the cooldown stamp only on a SUCCESSFUL publish. Stamping before
  the result was known burned a device's wake window whenever
  mosquitto_pub failed, then refused to retry for the full 300s despite
  no interview having been requested.
- Derive the request topic from BASE_TOPIC (env, defaulting to
  zigbee2mqtt) instead of hardcoding it, so it cannot silently drift from
  mqtt.base_topic. Noted that the MQTT ACL is scoped to zigbee2mqtt/# and
  would need changing too.
- Harden the sidecar with allowPrivilegeEscalation: false and
  capabilities drop ALL. Deliberately no runAsNonRoot: apk add needs
  root, and Z2M writes database.db and the log dir as root, so a non-root
  uid could not read either input.
- Runbook callout said "Step 2" where it meant item 2 within Step 5.

Verified end-to-end in an alpine:3.21 pod against this script (the
earlier "verified live" evidence came from the session prototype, which
resolved friendly names and is not this code — see PR discussion):
counts only PENDING devices, fires on a frame from a PENDING device with
mosquitto_pub returning success, ignores a frame from an
already-SUCCESSFUL device, and survives a malformed address without
firing or crashing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 08:22:40 +02:00
co-authored by Claude Opus 5
parent ead4aa3f01
commit 3961640130
3 changed files with 50 additions and 8 deletions
@@ -164,6 +164,17 @@ spec:
secretKeyRef:
name: mosquitto-zigbee2mqtt-credentials
key: password
# Must track mqtt.base_topic in zigbee2mqtt-config.yaml.
- name: BASE_TOPIC
value: zigbee2mqtt
# No runAsNonRoot: `apk add` needs root, and Z2M writes /app/data as
# root (database.db and the log dir are root-owned 0644/0755), so a
# non-root uid could not read either input. Dropping privilege
# escalation and all capabilities is still worth having.
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
volumeMounts:
- name: data
mountPath: /app/data
@@ -54,6 +54,11 @@ DATA=/app/data
DB="$DATA/database.db"
MQTT_HOST=${MQTT_HOST:-mosquitto}
MQTT_USER=${MQTT_USER:-zigbee2mqtt}
# Must track mqtt.base_topic in zigbee2mqtt-config.yaml. Overridable via env so
# the two can be kept in step from the Deployment rather than by editing this
# script; the MQTT ACL for this user is scoped to `zigbee2mqtt/#`, so a change
# here needs a matching ACL change too.
BASE_TOPIC=${BASE_TOPIC:-zigbee2mqtt}
COOLDOWN=${COOLDOWN:-300} # min seconds between interview attempts per device
STATEDIR=/tmp/interview-cooldown
@@ -115,12 +120,33 @@ while :; do
# tail -F (--follow=name --retry) rides out an in-place rotation; the outer
# loop handles Z2M starting a whole new log directory.
#
# Matching happens in the read loop rather than via a `grep` stage on purpose.
# This runs on alpine:3.21, whose `grep` is the BusyBox applet, and BusyBox
# grep does NOT support --line-buffered:
# grep: unrecognized option: line-buffered (exit 2)
# so a `tail -F | grep --line-buffered | while read` pipe yields ZERO lines
# forever while the sidecar still logs "following <file>" and looks healthy —
# a silent no-op of the whole feature. Dropping the stage avoids both that and
# any block-buffering question, since `tail` writes per line and `read`
# consumes per line, with no extra package to install.
tail -F "$LOGFILE" 2>/dev/null |
grep --line-buffered -oE 'Received payload: clusterID=[0-9]+, address=[0-9]+' |
while IFS= read -r line; do
nwk=${line##*address=}
cluster=${line#*clusterID=}
cluster=${cluster%%,*}
case "$line" in
*"Received payload: clusterID="*) ;;
*) continue ;;
esac
# ... clusterID=6, address=12468, groupID=0, endpoint=1, ...
rest=${line#*"Received payload: clusterID="}
cluster=${rest%%,*}
rest=${rest#*address=}
nwk=${rest%%,*}
# Guard against a format change silently yielding garbage.
case "$nwk" in
''|*[!0-9]*) continue ;;
esac
ieee=$(ieee_needing_interview "$nwk")
[ -z "${ieee:-}" ] && continue # already interviewed, or not a device we track
@@ -133,7 +159,6 @@ while :; do
# this on every attribute read it performs.
[ $((now - last)) -lt "$COOLDOWN" ] && continue
fi
printf '%s' "$now" > "$stamp"
log "frame from $ieee (nwk=$nwk cluster=$cluster) while awake -> requesting interview"
# NOTE: mosquitto_pub can only take a password via argv (-P, or an -L URL);
@@ -141,11 +166,16 @@ while :; do
# client. Not a cross-container exposure: the pod does not set
# shareProcessNamespace, so /proc is not shared with the Z2M container.
if mosquitto_pub -h "$MQTT_HOST" -u "$MQTT_USER" -P "$MQTT_PASSWORD" \
-t 'zigbee2mqtt/bridge/request/device/interview' \
-t "$BASE_TOPIC/bridge/request/device/interview" \
-m "{\"id\":\"$ieee\"}" 2>/dev/null; then
# Stamp only on a SUCCESSFUL publish. Stamping unconditionally would burn
# the device's wake window on a failed publish (broker hiccup, DNS blip)
# and then refuse to retry for the full cooldown, despite no interview
# ever having been requested.
printf '%s' "$now" > "$stamp"
log " interview requested for $ieee"
else
log " WARNING: mosquitto_pub failed for $ieee"
log " WARNING: mosquitto_pub failed for $ieee; not starting cooldown so the next frame retries"
fi
done
@@ -144,7 +144,8 @@ empty, so each device must be re-interviewed before it reports fully. Order:
Keep `permit_join` **off** (it is, by config) — this is re-interviewing, not
re-pairing; nothing leaves and rejoins the network.
> **Step 2 is now automated — do not chase these by hand.** The
> **Item 2 above (battery/sleepy devices) is now automated — do not chase
> those by hand.** The
> `interview-watch` sidecar (`zigbee2mqtt-interview-watch.sh`) fires a forced
> interview whenever a still-`PENDING`/`FAILED` device transmits, which is the
> only moment a sleepy device can complete one. Devices heal as they are used or