From 9f336714a28d215a69123e0225cb6fe92b458582 Mon Sep 17 00:00:00 2001 From: pi-os3 Date: Wed, 4 Nov 2026 13:42:41 -0400 Subject: [PATCH 1/2] os/v3/puppy: the firewall drops every mDNS query, so 1bit-os.local is announced but never answers The c22 board (4 Nov 12:5x AST) logged `avahi-daemon up: 1bit-os.local is announced on this network` and a raw mDNS query from ryzen got no answer. #952/#953 put the responder in the image; what was never opened is the path for a *query*, and the cause is the same `rc.firewall` that `zz-onboard-ports` already works around for tcp. Read out of the packed c22 puppy SFS, `etc/init.d/rc.firewall`: -P INPUT DROP -A INPUT -p ALL -d 224.0.0.1 -j DROP -A INPUT -p UDP -j udp_inbound (drops 137/138, accepts DHCP 67->68, then -j RETURN) -A INPUT -m pkttype --pkt-type broadcast -j DROP -A OUTPUT -p ALL -j ACCEPT so every NEW inbound UDP packet falls through to the DROP policy, and a query on 5353 is exactly that; the appliance's own announcements leave fine, which is why avahi reported success while nothing answered. This file already opens tcp/13305 and tcp/22 for the same reason - mDNS is the UDP half. LAN-only, the same ranges hosts.allow already names below plus link-local for avahi's no-lease fallback, ahead of udp_inbound and the broadcast drop, idempotent via -C, and in `zz-onboard-ports` rather than `18onboard-mdns`, whose name sorts before `rc.firewall` and would be wiped by it. Measured in a booted c22 image (`~/wt/os3i/c23-mdns-probe6.sh`, one controlled announcement burst per arm, the rules' own packet counters): arm policy DROP the fix's INPUT rule shipped firewall 5 -> 12 (+7/2095) absent + one `-I INPUT 1 ... dport 5353` 12 (unchanged) 0 -> 7 packets / 2095 bytes rule removed again 12 -> 19 (+7/4963) absent the same seven packets that were dropped are the ones the rule accepts, byte for byte. Two caveats stated rather than implied: those packets are avahi's own multicast announcements looped back (IP_MULTICAST_LOOP), not a query from a peer - same packet class, same chain, and a peer's query is what the board row must still prove; and QEMU user networking cannot carry inbound UDP at all (a `hostfwd=udp` delivers nothing even with the firewall flushed), which is why this is measured on the appliance's own interface. Gate: a `U3 mdns rule` row in vm-check.sh that fails an image without the rule. closes #1011 --- os/v3/puppy/embed/etc/init.d/zz-onboard-ports | 32 ++++++++++++++++++- os/v3/puppy/vm-check.sh | 9 ++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/os/v3/puppy/embed/etc/init.d/zz-onboard-ports b/os/v3/puppy/embed/etc/init.d/zz-onboard-ports index c27ea1d8..301605ea 100755 --- a/os/v3/puppy/embed/etc/init.d/zz-onboard-ports +++ b/os/v3/puppy/embed/etc/init.d/zz-onboard-ports @@ -14,7 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# /etc/init.d/zz-onboard-ports - the appliance's two inbound ports, opened after Puppy's firewall. +# /etc/init.d/zz-onboard-ports - the appliance's inbound ports and its mDNS responder, opened after Puppy's +# firewall. # # Measured in a booted image: `iptables -S` shows `-P INPUT DROP` with `tcp_inbound` RETURNing to that # policy, so every new inbound TCP connection from outside is dropped. lemond binds 0.0.0.0:13305 and the @@ -35,6 +36,35 @@ open_port() { open_port 13305 # Lemonade (U3) open_port 22 # ssh, the owner's key (U7) +# mDNS (cycle 23; c22 board 4 Nov: avahi logged "1bit-os.local is announced" and a raw mDNS query from ryzen got +# no answer at all). The reason is in the same rc.firewall this file already had to work around, read out of the +# packed c22 puppy sfs: +# +# -P INPUT DROP +# -A INPUT -p ALL -d 224.0.0.1 -j DROP (only .1; a query to 224.0.0.251 gets past this) +# -A INPUT -p UDP -j udp_inbound (drops 137/138, accepts DHCP 67->68, then -j RETURN) +# -A INPUT -m pkttype --pkt-type broadcast -j DROP +# +# so every NEW inbound UDP packet falls through to the DROP policy, and 5353 is UDP. A query is exactly that +# packet, which is why the responder never saw one; its own announcements did reach the wire, because the same +# file ends with `-A OUTPUT -p ALL -j ACCEPT` - so this needs no OUTPUT rule, only the query that confirms it. +# +# LAN-only, the same ranges as hosts.allow below, plus link-local (avahi's fallback when there is no lease): +# announcing to a phone on the LAN is the point, being a public responder is not. `-I INPUT 1` puts these ahead +# of udp_inbound and of the broadcast drop, and -C keeps a re-run a no-op. +open_mdns() { + for src in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16; do + iptables -C INPUT -p udp --dport 5353 -s "$src" -j ACCEPT 2> /dev/null && continue + iptables -I INPUT 1 -p udp --dport 5353 -s "$src" -j ACCEPT 2> /dev/null && continue + echo "zz-onboard-ports: could not open udp/5353 for $src" >&2 + return 1 + done +} +# shortcut: IPv4 only. 18onboard-mdns's responder answers on both families, but this image's IPv6 is +# unconfigured (no global address, and the board reaches it at 192.168.50.110), so a v6 rule would be untested +# code; add fe80::/10 to this loop when the appliance gets an IPv6 address. +open_mdns + # And the other half of reachability, measured the same way: Puppy ships /etc/hosts.deny = "ALL: ALL" with # /etc/hosts.allow = "ALL: LOCAL", and LOCAL means this machine, so a client on the LAN is refused by # tcpwrappers before ssh's key exchange even starts - ssh says `kex_exchange_identification: Connection diff --git a/os/v3/puppy/vm-check.sh b/os/v3/puppy/vm-check.sh index b0dae9b7..0b6a6efb 100755 --- a/os/v3/puppy/vm-check.sh +++ b/os/v3/puppy/vm-check.sh @@ -645,6 +645,15 @@ rows = [ # row runs is a race, but an ACCEPT for tcp/13305 is what makes U3 reachable either way. ("U3 port rule", "iptables -C INPUT -p tcp --dport 13305 -j ACCEPT && echo port-13305-open; " "iptables -C INPUT -p tcp --dport 22 -j ACCEPT && echo port-22-open", None), + # Cycle 23 (c22 board, 4 Nov): avahi logged "1bit-os.local is announced" and a raw mDNS query from ryzen got no + # answer at all. The cause is in the same rc.firewall the two rows above work around: every NEW inbound UDP + # packet goes to `udp_inbound`, which accepts only DHCP and RETURNs to the DROP policy, and a query on 5353 is + # UDP - while the announcements themselves left fine (`-A OUTPUT -p ALL -j ACCEPT`). The durable assertion is + # the rule, for the same reason as the ports above; whether the responder is up is read in the same row. + ("U3 mdns rule", "iptables -S INPUT | grep -E -- '-p udp.*--dport 5353.*-j ACCEPT' | head -2; " + "iptables -C INPUT -p udp --dport 5353 -s 10.0.0.0/8 -j ACCEPT 2>/dev/null && echo udp-5353-open; " + "pidof avahi-daemon > /dev/null && echo avahi-up; tail -1 /var/log/mdns.log 2>&1", + "udp-5353-open"), ("net addr", "ip -4 -o addr show 2>/dev/null | tr -s ' '", None), ("net route", "ip -4 route 2>/dev/null | head -3", None), ] From 9c0b734872aabde54cc938cf9b487eb90ca3ddcd Mon Sep 17 00:00:00 2001 From: pi-os3 Date: Wed, 4 Nov 2026 13:45:44 -0400 Subject: [PATCH 2/2] os/v3/puppy: a self-check for the inbound rules, so the mDNS block cannot regress silently `mdns-ports-selftest.sh` runs the shipped `zz-onboard-ports` against a stub `iptables` (a rule table in a file, `-C` answered from it): no root, no ruleset, no device. It asserts four `udp/5353` inserts with the four intended sources, that a second run inserts nothing, and that the two TCP ports this file already opened are still opened. Measured both ways before committing: shipped script run 1: 6 inserts, 4 of them udp/5353 run 2: 0 inserts, 4 -C -> RESULT: PASS origin/main's run 1: 2 inserts, 0 of them udp/5353 -> RESULT: FAIL `sh -n`, `shellcheck -S warning` and `tools/copyright.py --check` are clean. --- os/v3/puppy/mdns-ports-selftest.sh | 78 ++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100755 os/v3/puppy/mdns-ports-selftest.sh diff --git a/os/v3/puppy/mdns-ports-selftest.sh b/os/v3/puppy/mdns-ports-selftest.sh new file mode 100755 index 00000000..34e27a45 --- /dev/null +++ b/os/v3/puppy/mdns-ports-selftest.sh @@ -0,0 +1,78 @@ +#!/bin/sh +# Copyright 2026 bong-water-water-bong +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# mdns-ports-selftest.sh [script] - the self-check for zz-onboard-ports' inbound rules, with a stub `iptables`. +# +# No root, no real ruleset, no device: the stub keeps a rule table in a file and answers `-C` from it, so this +# asserts what the shipped script actually issues. The mDNS block is the kind of code that fails silently (a rule +# with the wrong source, or one re-added on every boot), so it gets a check that runs anywhere: +# +# * one insert per LAN range, four of them, with the four sources the file intends; +# * a second run against the same ruleset inserts nothing (`-C` first, which is what keeps a re-run a no-op); +# * the two pre-existing TCP ports are still opened - this script must not have moved them. +# +# The measured behaviour on a booted image (`vm-check`'s `U3 mdns rule` row, and the counter test in #1016's body) +# is what proves the appliance accepts the packet; this proves the script's own shape. +set -u +prog=${0##*/} +here=$(cd "$(dirname "$0")" && pwd) +script=${1:-$here/embed/etc/init.d/zz-onboard-ports} +[ -f "$script" ] || { echo "$prog: no $script" >&2; exit 2; } + +work=$(mktemp -d "${TMPDIR:-/tmp}/mdns-ports.XXXXXX") || exit 2 +trap 'rm -rf "$work"' EXIT INT TERM +mkdir -p "$work/bin" +export CALLS="$work/calls" STATE="$work/state" +: > "$CALLS"; : > "$STATE" + +cat > "$work/bin/iptables" <<'STUB' +#!/bin/sh +# the rule's identity is " ", so `-I INPUT 1 ` and `-C INPUT ` compare directly +echo "$*" >> "$CALLS" +cmd=$1; shift +chain=$1; shift +case $cmd in + -C) grep -qxF "$chain $*" "$STATE" 2>/dev/null && exit 0; exit 1 ;; + -I) shift; echo "$chain $*" >> "$STATE"; exit 0 ;; + -S) printf '%s\n' '-A INPUT -p tcp -m tcp --dport 13305 -j ACCEPT' '-A INPUT -p udp -m udp --dport 5353 -j ACCEPT'; exit 0 ;; + *) exit 0 ;; +esac +STUB +chmod +x "$work/bin/iptables" +PATH="$work/bin:$PATH" sh "$script" > "$work/run1.log" 2>&1 +i1=$(grep -c -- '^-I ' "$CALLS") +c1=$(grep -c -- '-dport 5353' "$CALLS") +u1=$(grep -c -- '^-I .* -p udp --dport 5353 ' "$CALLS") +PATH="$work/bin:$PATH" sh "$script" > "$work/run2.log" 2>&1 +i2=$(grep -c -- '^-I ' "$CALLS") +u2=$(grep -c -- '^-I .* -p udp --dport 5353 ' "$CALLS") +c2=$(grep -c -- '-dport 5353' "$CALLS") + +echo "run 1: $i1 insert(s), $u1 of them udp/5353, $c1 udp/5353 command(s)" +echo "run 2 (same ruleset): $((i2 - i1)) insert(s), $((c2 - c1)) udp/5353 command(s) - all -C, none -I" +fail= +[ "$i1" -ge 6 ] || fail="$fail first-run-inserts=$i1(expected 6: two TCP ports, four udp/5353)" +[ "$u1" -eq 4 ] || fail="$fail udp5353-inserts=$u1(expected 4)" +[ "$u2" -eq 4 ] || fail="$fail re-run-inserted-udp5353=$u2(not idempotent)" +[ "$((c2 - c1))" -eq 4 ] || fail="$fail re-run-udp5353-commands=$((c2 - c1))(expected 4 -C and no -I)" +for src in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16; do + grep -qxF "INPUT -p udp --dport 5353 -s $src -j ACCEPT" "$STATE" || fail="$fail missing-rule-$src" +done +if [ -n "$fail" ]; then + echo "RESULT: FAIL -$fail" + exit 1 +fi +echo "RESULT: PASS (four udp/5353 rules inserted once, the re-run inserts nothing, sources as intended)"