Name every machine after its own MAC address, and drop mDNS

One name for every machine was wrong: an operator with two of them on a
network had one forgefirm.local, and mDNS does not work on many networks
at all. The machine now calls itself forgefirm-<xxxx>, from the last four
hex digits of its WiFi MAC address, and sends that name with its DHCP
request, so a network with dynamic DNS publishes it and a router lists
the machine by name. The name is the same at every boot, two machines
take different names, and no serial number leaves the machine.

forgefirm-hostname (new): reads the wlan0 MAC address (eth0 on a machine
with no WiFi) at S38 in rcS, after udev has probed the network drivers
and before poky's hostname.sh reads the file and before the network
starts. The rootfs is read-only, so the name is written through a
bind-mounted copy under /run/forgefirm. A bounded wait covers a slow
probe. hostname:pn-base-files is "forgefirm": the name before S38, and
the fallback when no MAC address can be read.

avahi is deleted - the bbappend, the daemon configuration, the service
file, the image install and the distro block. The address is the way in
that works on every network, and the DHCP name covers the rest.

forgefirm-banner: the marker lines are gone. "# ForgeFIRM addresses" and
"# end" delimited the address block inside /etc/issue, and getty prints
every line of that file, so both markers were on the console. The script
now keeps the image's own text in a second copy under /run/forgefirm,
captured once per boot before the first write, and renders the whole
banner from it. The block is the addresses alone: no mDNS name.

forgefirm-image.bb: the ForgeFIRM mark, under the OpenGlow one the base
image carries, with the version on the mark's own last line,
right-justified to the mark's last column. The mark is written once and
rendered per reader, because /etc/issue is parsed by busybox getty (a
backslash or a percent sign starts an escape, so the art goes in with
every backslash doubled) while /etc/motd is written out as it is. Widths
are measured in columns, not bytes: the color sequences take no room on
the screen. /etc/issue.net stays unused - the machine tells a client that
has not logged in nothing.

Acceptance: commission.mdns-announce is replaced by
commission.machine-name, which checks the name against the MAC address,
the bind-mounted /etc/hostname, the DHCP client's hostname option, the
banner's addresses, and that no mDNS responder is on the image; it covers
nothing by design, like the test it replaces. forgectrl.auth gains the
own-name Host check and its refusal with a domain on it. image.health
checks the /etc/hostname mount and the version on the mark's last line in
both files. commission.ssh-until-reboot asserts there is no
pre-authentication banner. commission_dark's lens coverage widens to
src/lenshome.* so src/lenshome.h is covered; the lint is clean at 83
tests.

Pins: forgectrl 0.1.14 (9e5330f, the hostname certificate and the Host
rule), meta-openglow ced2af2 (the DHCP hostname option and the motd mark)
in the kas lock.

Proven on the bench reference, hot-deployed and rebooted (image
20260910000208 dev): hostname forgefirm-b00a from MAC 2c:6b:7d:0d:b0:0a,
live and in the bind-mounted file; the DHCP client running with
-x hostname:forgefirm-b00a; the console banner and the motd carrying both
marks with the version aligned to the mark's last column, no marker line
and no .local name; forgectrl regenerating its certificate for the new
name. Host tests: 357 forgetest unit tests, forgectrl clean under
-Werror, tls_test and sanitize_test.
This commit is contained in:
ScottW514
2026-09-10 07:18:27 -04:00
parent d858a23f45
commit f0c40e7d4f
17 changed files with 433 additions and 338 deletions
+13 -13
View File
@@ -8,19 +8,19 @@ DISTRO_FEATURES:remove = " \
3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \
pulseaudio vulkan wayland x11 zeroconf "
# mDNS: the image installs avahi-daemon by name (forgefirm-image.bb), so
# the control panel answers at forgefirm.local. The avahi and zeroconf
# features stay removed above: zeroconf would install
# packagegroup-base-zeroconf (the daemon plus libnss-mdns), and avahi
# would switch other recipes' avahi options on. The build is trimmed to
# the daemon: no D-Bus (so no libavahi-client, no avahi-utils, no bus
# activation; the daemon reads /etc/avahi/services itself), no GTK or Qt
# front ends, no libdns_sd compatibility library, no libevent binding.
# Python bindings are off in the recipe itself.
PACKAGECONFIG:pn-avahi = ""
# libnss-mdns is what avahi-daemon recommends: an NSS module so the
# machine itself resolves .local names. Nothing on the machine does.
BAD_RECOMMENDATIONS += "libnss-mdns"
# The name the image ships in /etc/hostname. forgefirm-hostname replaces
# it at S38 in rcS with forgefirm-<xxxx>, from the machine's MAC address;
# this is what the few seconds before that show, and the fallback on a
# machine whose MAC address cannot be read.
hostname:pn-base-files = "forgefirm"
# No mDNS. The panel is reached at the machine's address, which every
# network resolves; the machine's own name (forgefirm-<xxxx>, from
# forgefirm-hostname) goes out in the DHCP request, so a network with
# dynamic DNS publishes it as well. The avahi and zeroconf features stay
# removed above: zeroconf would install packagegroup-base-zeroconf (the
# daemon plus libnss-mdns), and avahi would switch other recipes' avahi
# options on.
# TLS: forgectrl serves HTTPS on 443 with a self-signed certificate
# through libmicrohttpd and ulfius, and links GnuTLS itself. GnuTLS is
@@ -1,33 +0,0 @@
# ForgeFIRM avahi-daemon configuration (avahi-daemon.conf(5)).
# The machine answers forgefirm.local on the WiFi link (and on eth0 when
# the machine has one), over IPv4 and IPv6. It publishes its addresses
# and the services in /etc/avahi/services, nothing else: no workstation
# record, no host information, no wide-area lookups, no reflector.
[server]
host-name=forgefirm
use-ipv4=yes
use-ipv6=yes
allow-interfaces=wlan0,eth0
ratelimit-interval-usec=1000000
ratelimit-burst=1000
[wide-area]
enable-wide-area=no
[publish]
publish-hinfo=no
publish-workstation=no
publish-addresses=yes
publish-domain=yes
[reflector]
enable-reflector=no
[rlimits]
rlimit-core=0
rlimit-data=8388608
rlimit-fsize=0
rlimit-nofile=768
rlimit-stack=8388608
rlimit-nproc=3
@@ -1,21 +0,0 @@
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<!-- ForgeFIRM control panel: HTTPS on 443 and HTTP on 80, served by
forgectrl. %h is the host name (avahi.service(5)). -->
<service-group>
<name replace-wildcards="yes">ForgeFIRM on %h</name>
<service>
<type>_https._tcp</type>
<port>443</port>
</service>
<service>
<type>_http._tcp</type>
<port>80</port>
</service>
</service-group>
@@ -1,17 +0,0 @@
FILESEXTRAPATHS:prepend := "${THISDIR}/${BPN}:"
# mDNS for the control panel: the machine answers forgefirm.local and
# advertises the panel on HTTPS 443 and HTTP 80. Only avahi-daemon is
# installed (forgefirm-image.bb); the build options that keep it to the
# daemon are in conf/distro/forgefirm.conf. The daemon reads the service
# file itself: no D-Bus is involved.
SRC_URI += " \
file://avahi-daemon.conf \
file://forgefirm.service \
"
do_install:append() {
install -m 0644 ${WORKDIR}/avahi-daemon.conf ${D}${sysconfdir}/avahi/avahi-daemon.conf
install -d ${D}${sysconfdir}/avahi/services
install -m 0644 ${WORKDIR}/forgefirm.service ${D}${sysconfdir}/avahi/services/forgefirm.service
}
@@ -2,5 +2,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "a2d73efeb9af376e122e0877a4988d92d021cfa7"
PV = "0.1.13"
SRCREV = "9e5330ffbb27dcf2fb89f414f1e8fc38bfdfb6f7"
PV = "0.1.14"
@@ -1,24 +1,23 @@
#!/bin/sh
# Rewrites the address block of /etc/issue, the serial-console login
# banner: the control panel by mDNS name, then one https:// URL per
# global address of wlan0 (and eth0 when the machine has one). Every
# other line of /etc/issue stays as the image build wrote it. The block
# sits between the marker lines "# ForgeFIRM addresses" and "# end" and
# is appended when absent. Called by the init script at boot and by the
# udhcpc hook on every lease event. Idempotent: the file is written only
# when the block changes.
# Rewrites /etc/issue, the serial-console login banner: the lines the
# image build wrote, then one https:// URL per global address of wlan0
# (and eth0 when the machine has one). The address is the only way in
# that the banner gives, because it is the one that works on every
# network. Called by the init script at boot and by the udhcpc hook on
# every lease event. Idempotent: the file is written only when the text
# changes.
#
# The rootfs is read-only: at the first change after boot the file is
# bind-mounted from a copy under /run/forgefirm (tmpfs) and the block is
# written through the mount. Before that the image's own file shows.
# bind-mounted from a copy under /run/forgefirm (tmpfs) and the banner
# is written through the mount. Before that the image's own file shows.
# The image's own text is kept at that moment in a second copy, so the
# banner needs no marker line in the file and shows nothing but itself.
PATH=/sbin:/usr/sbin:/bin:/usr/bin
ISSUE=/etc/issue
STATE=/run/forgefirm/issue
MARK_BEGIN='# ForgeFIRM addresses'
MARK_END='# end'
PANEL='Control panel: https://forgefirm.local/'
BASE=/run/forgefirm/issue.base
# One URL per global address; an IPv6 address gets its URL brackets.
# Tentative, deprecated and temporary addresses are left out.
@@ -32,45 +31,40 @@ addresses () {
a = $2
sub(/\/.*/, "", a)
if ($1 == "inet6") a = "[" a "]"
print "https://" a "/"
print " https://" a "/"
}'
done
}
block () {
echo "$MARK_BEGIN"
echo "$PANEL"
addrs=$(addresses)
if [ -n "$addrs" ]; then
echo "$addrs"
else
echo "no network address yet"
fi
echo "$MARK_END"
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
# file by its device numbers alone).
is_mounted () {
awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
}
[ -f "$ISSUE" ] || exit 0
new=$(block)
old=$(awk -v b="$MARK_BEGIN" -v e="$MARK_END" \
'$0 == b { p = 1 } p { print } $0 == e { p = 0 }' "$ISSUE")
[ "$new" = "$old" ] && exit 0
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
# file by its device numbers alone).
if ! awk -v t="$ISSUE" '$2 == t { f = 1 } END { exit !f }' /proc/mounts; then
mkdir -p "${STATE%/*}" \
&& cp -p "$ISSUE" "$STATE" \
# The image's own text: the file as it is before the first write of the
# boot. /run is a tmpfs, so the copy is made once per boot, and the
# first caller is the one that finds the file untouched.
mkdir -p "${STATE%/*}" || exit 1
[ -f "$BASE" ] || cp -p "$ISSUE" "$BASE" || exit 1
if ! is_mounted "$ISSUE"; then
cp -p "$BASE" "$STATE" \
&& mount --bind "$STATE" "$ISSUE" || exit 1
fi
base=$(cat "$BASE")
addrs=$(addresses)
if [ -n "$addrs" ]; then
new=$(printf '%s\n\nControl panel:\n%s\n' "$base" "$addrs")
else
new=$(printf '%s\n\nControl panel: no network address yet\n' "$base")
fi
[ "$new" = "$(cat "$ISSUE")" ] && exit 0
tmp="$STATE.tmp.$$"
awk -v b="$MARK_BEGIN" -v e="$MARK_END" -v blk="$new" '
$0 == b { print blk; seen = 1; skip = 1; next }
$0 == e && skip { skip = 0; next }
!skip { print }
END { if (!seen) print blk }
' "$ISSUE" > "$tmp" || { rm -f "$tmp"; exit 1; }
printf '%s\n\n' "$new" > "$tmp" || { rm -f "$tmp"; exit 1; }
cat "$tmp" > "$ISSUE"
rm -f "$tmp"
exit 0
@@ -1,7 +1,7 @@
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
DESCRIPTION = "Keeps an address block in the serial-console login banner \
(/etc/issue): the control panel by mDNS name and by every global address \
of wlan0 and eth0. Refreshed at boot and on every DHCP lease event."
(/etc/issue): one control panel URL per global address of wlan0 and eth0. \
Refreshed at boot and on every DHCP lease event."
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
@@ -0,0 +1,68 @@
#!/bin/sh
# Sets the machine's hostname to forgefirm-<xxxx>, where xxxx is the
# last four hex digits of the wlan0 MAC address (eth0 on a machine with
# no WiFi). The name is the same at every boot, two machines on one
# network answer to different names, and the name carries no serial
# number. The DHCP client sends it as the hostname option
# (/etc/network/interfaces), so a network with dynamic DNS resolves it.
#
# The rootfs is read-only: /etc/hostname shows a copy under
# /run/forgefirm (tmpfs), bind-mounted, and the name is written through
# the mount. The init script calls this at boot, before the network
# starts and before anything reads the name.
PATH=/sbin:/usr/sbin:/bin:/usr/bin
FILE=/etc/hostname
STATE=/run/forgefirm/hostname
PREFIX=forgefirm
WAIT_S=10
# The MAC address of the first interface that has one. An interface
# before its driver reads the address reports all zeros.
mac () {
for dev in wlan0 eth0; do
a=$(cat "/sys/class/net/$dev/address" 2>/dev/null) || continue
case "$a" in
''|00:00:00:00:00:00) continue ;;
esac
printf '%s\n' "$a"
return 0
done
return 1
}
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
# file by its device numbers alone).
is_mounted () {
awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
}
# An interface registers when its driver probes, which udev does earlier
# in rcS. The wait is for a slow probe and ends at once in the normal
# case.
n=0
while [ "$n" -lt "$WAIT_S" ] && ! mac >/dev/null; do
sleep 1
n=$((n + 1))
done
suffix=$(mac | tr -d ':' | tr 'A-Z' 'a-z' | cut -c9-12)
if [ -n "$suffix" ]; then
name="$PREFIX-$suffix"
else
name="$PREFIX"
fi
[ "$(hostname)" = "$name" ] || hostname "$name"
# The file follows the live name, so every reader agrees.
[ -f "$FILE" ] || exit 0
[ "$(cat "$FILE" 2>/dev/null)" = "$name" ] && exit 0
if ! is_mounted "$FILE"; then
mkdir -p "${STATE%/*}" \
&& cp -p "$FILE" "$STATE" \
&& mount --bind "$STATE" "$FILE" || exit 1
fi
printf '%s\n' "$name" > "$FILE" || exit 1
exit 0
@@ -0,0 +1,31 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: forgefirm-hostname
# Required-Start: $local_fs
# Required-Stop:
# Default-Start: S
# Default-Stop:
# Short-Description: ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address
### END INIT INFO
# Writes the hostname before the network starts. At boot this runs at
# S38 in rcS: /run is mounted (mountall, S03), udev has probed the
# network drivers (S04), and poky's own hostname.sh (S39) reads the file
# this leaves.
case "$1" in
start|restart|reload|force-reload)
/usr/sbin/forgefirm-hostname
;;
stop)
;;
status)
hostname
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
exit 0
@@ -0,0 +1,28 @@
SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address"
DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \
four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \
The name is the same at every boot, two machines on one network answer to \
different names, and the name carries no serial number. The DHCP client \
sends it as the hostname option, so a network with dynamic DNS resolves it."
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
SRC_URI = " \
file://forgefirm-hostname \
file://forgefirm-hostname.init \
"
S = "${WORKDIR}"
inherit update-rc.d
INITSCRIPT_NAME = "forgefirm-hostname"
# 38 in rcS: after udev (S04) has probed the network drivers, before
# poky's hostname.sh (S39) reads /etc/hostname and before the network
# starts (rc5 S01).
INITSCRIPT_PARAMS = "start 38 S ."
do_install() {
install -Dm 0755 ${WORKDIR}/forgefirm-hostname ${D}${sbindir}/forgefirm-hostname
install -Dm 0755 ${WORKDIR}/forgefirm-hostname.init ${D}${sysconfdir}/init.d/forgefirm-hostname
}
@@ -48,15 +48,12 @@ IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fw
# forgefirm-users: renders the operator account record
# (/data/forgefirm/users, written by forgectrl) into the system account
# files at boot, before sshd, and on reload; also installs the warning an
# interactive root shell prints. forgefirm-banner: keeps the control
# panel addresses in the serial-console banner (/etc/issue).
# interactive root shell prints. forgefirm-hostname: names the machine
# forgefirm-<xxxx> from its MAC address, before the network starts.
# forgefirm-banner: keeps the control panel addresses in the
# serial-console banner (/etc/issue).
# forgefirm-persist: the boot timestamp and the random seed on /data.
# avahi-daemon: mDNS, so the panel answers at https://forgefirm.local/
# and shows up in service browsers. The daemon is installed by name (the
# zeroconf distro feature stays off: it would bring libnss-mdns); the
# build options and the configuration are in conf/distro/forgefirm.conf
# and recipes-connectivity/avahi.
IMAGE_INSTALL:append = " forgefirm-users forgefirm-banner forgefirm-persist avahi-daemon"
IMAGE_INSTALL:append = " forgefirm-users forgefirm-hostname forgefirm-banner forgefirm-persist"
# The rootfs mounts read-only on both images; /data (p3) is the writable
# partition. read-only-rootfs is poky's feature for it: the root line of
@@ -67,8 +64,9 @@ IMAGE_INSTALL:append = " forgefirm-users forgefirm-banner forgefirm-persist avah
# package whose post-install must run on the machine, and the removal of
# the packages a read-only rootfs cannot use (shadow, base-passwd,
# update-rc.d, update-alternatives; the account files stay). What must
# last or change at run time is handled file by file: the account files
# and /etc/issue (forgefirm-users, forgefirm-banner), the sshd host keys
# last or change at run time is handled file by file: the account files,
# /etc/hostname and /etc/issue (forgefirm-users, forgefirm-hostname,
# forgefirm-banner), the sshd host keys
# (recipes-connectivity/openssh), the timestamp and the random seed
# (forgefirm-persist). The facts are on the docs site,
# technical/forgefirm/image-and-bsp; scripts/release.sh checks the built
@@ -108,8 +106,30 @@ IMAGE_OVERHEAD_FACTOR = "1.0"
IMAGE_ROOTFS_EXTRA_SPACE = "40960"
IMAGE_ROOTFS_MAXSIZE = "204800"
# Version stamp: /etc/forgefirm-version (machine-readable), echoed on the
# serial-console login prompt (/etc/issue) and at SSH login (motd).
# The ForgeFIRM mark and the version stamp:
# /etc/forgefirm-version (machine-readable), and, under the OpenGlow mark
# the base image carries (base-files, meta-openglow), the ForgeFIRM mark
# with the version on its last line, right-justified to the mark's last
# column, in the two files a person reads - the serial-console login
# prompt (/etc/issue) and the motd, which every login prints, the network
# ones included. The mark names the firmware, so the version stands
# alone.
#
# The mark is written once here and rendered for each reader, because the
# two files are read by different programs:
# /etc/issue busybox getty parses it, and both a backslash and a
# percent sign start an escape (libbb/login.c,
# print_login_issue). An unrecognized escape prints the
# character and swallows the backslash, so the art goes in
# with every backslash doubled.
# /etc/motd a login writes it out as it is: nothing is doubled.
# Both keep their color: a getty passes an escape character through, and
# so does a login writing the motd. Only the ssh client escapes one, and
# it does that to a banner alone.
# The pre-authentication banner (/etc/issue.net) stays unused: the ssh
# client prints a control character in a banner as an octal escape, and
# the machine tells a client that has not logged in nothing anyway.
#
# Release images carry the release version; the dev image overrides the
# string with the build timestamp (the same DATETIME as the artifact
# name) plus a dev tag.
@@ -123,9 +143,44 @@ FORGEFIRM_VERSION_STRING ?= "v${FORGEFIRM_RELEASE}"
write_forgefirm_version() {
echo "${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/forgefirm-version
echo "ForgeFIRM ${FORGEFIRM_VERSION_STRING}" >> ${IMAGE_ROOTFS}${sysconfdir}/issue
mark=${WORKDIR}/forgefirm-mark
cat > $mark <<'MARK'
___ ___ ___ ___ __ __
| __|__ _ _ __ _ ___| __|_ _| _ \ \/ |
| _/ _ \ '_/ _` / -_) _| | || / |\/| |
|_|\___/_| \__, \___|_| |___|_|_\_| |_|
|___/
MARK
# The version rides the mark's own last line, its last character on
# the mark's last column: under the FIRM half, in the room the
# descender of the Forge half leaves. The mark carries the name, so
# the version stands alone. Measured in columns, not in bytes: the
# color sequences take no room on the screen, and the doubling below
# is undone by the getty that reads it. A version with no room left
# keeps one space and runs past the mark rather than being cut.
stamped=${WORKDIR}/forgefirm-mark-stamped
awk -v v="${FORGEFIRM_VERSION_STRING}" '
{ line[NR] = $0
bare = $0
gsub(/\033\[[0-9;]*m/, "", bare)
col[NR] = length(bare)
if (col[NR] > w) w = col[NR] }
END { for (i = 1; i < NR; i++) print line[i]
pad = w - col[NR] - length(v)
if (pad < 1) pad = 1
gap = ""
while (length(gap) < pad) gap = gap " "
print line[NR] gap v }' $mark > $stamped
sed 's|\\|\\\\|g' $stamped >> ${IMAGE_ROOTFS}${sysconfdir}/issue
echo "" >> ${IMAGE_ROOTFS}${sysconfdir}/issue
echo "ForgeFIRM ${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/motd
cat $stamped >> ${IMAGE_ROOTFS}${sysconfdir}/motd
echo "" >> ${IMAGE_ROOTFS}${sysconfdir}/motd
rm -f $mark $stamped
}
write_forgefirm_version[vardepsexclude] += "DATETIME"
# No semicolon after a function name here or below (the vardeps rule in