diff --git a/forgetest/forgetest/suite/commission.py b/forgetest/forgetest/suite/commission.py index e645725..96e3e11 100644 --- a/forgetest/forgetest/suite/commission.py +++ b/forgetest/forgetest/suite/commission.py @@ -1,6 +1,7 @@ """commission.* - the first-run commissioning: the record and the controller gate, the advisories, the account and its login, the HTTPS -boundary, SSH, the cloud switch, the mDNS name, and the factory return. +boundary, SSH, the cloud switch, the machine's name, and the factory +return. The daemon reads the commissioning record (commissioning.json in the data directory) and the account record (users) once, at its start, and @@ -11,7 +12,7 @@ never saves a test record over the real one, and a restore is complete when the daemon is up again. The layer content of this work (the account replay init script, the -sshd policy, the console banner, the avahi service) is part of the +sshd policy, the hostname and console banner scripts) is part of the platform identity that every fingerprint carries, not of a component. No coverage map names it: a change there makes every test necessary again. @@ -23,10 +24,9 @@ import http.client import json import os import random -import select +import re import socket import ssl -import struct import time import urllib.parse @@ -46,9 +46,6 @@ ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service") SAFETY_PHRASE = "I UNDERSTAND" LOGIN_FAILS = 5 LOGIN_LOCK_S = 30 -MDNS_NAME = "forgefirm.local" -MDNS_GROUP = "224.0.0.251" -MDNS_PORT = 5353 # ----------------------------------------------------------------- files @@ -777,7 +774,7 @@ def https_only_writes(ctx): # ------------------------------------------------------------------ ssh def sshd_policy(): - """The three ForgeFIRM keys of the effective sshd policy (sshd -T, + """The ForgeFIRM keys of the effective sshd policy (sshd -T, lowercase keys), or {"error": ...} when sshd cannot report it.""" for exe in ("/usr/sbin/sshd", "sshd"): rc, out = hw.run([exe, "-T"], timeout=15) @@ -789,7 +786,7 @@ def sshd_policy(): for line in out.splitlines(): parts = line.split(None, 1) if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords", - "passwordauthentication"): + "passwordauthentication", "banner"): policy[parts[0]] = parts[1].strip() return policy return {"error": "no sshd binary"} @@ -814,7 +811,9 @@ def restore_ssh(fc, before): "sshd listens on 22, with the effective policy (sshd -T) at PasswordAuthentication " "yes, and on a release image PermitRootLogin no and PermitEmptyPasswords no (the " "dev image turns root over SSH back on for the bench; the release gate checks " - "the release image's sshd_config as built). An enable outside 0 and 1 is refused (400). On a release " + "the release image's sshd_config as built), and with no pre-authentication " + "banner, so the machine says nothing to a client that has not logged in. " + "An enable outside 0 and 1 is refused (400). On a release " "image POST enable=0 removes the flag and stops sshd; on the dev image the boot " "rule keeps sshd up and the stop is never gated, so the test removes the flag " "by hand (as a reboot does, the flag is on tmpfs) and sshd stays. The prior " @@ -848,6 +847,13 @@ def ssh_until_reboot(ctx): ctx.log("sshd -T: %s", policy) ctx.check(policy.get("passwordauthentication") == "yes", "PasswordAuthentication is %r: the account cannot log in", policy.get("passwordauthentication")) + + # No pre-authentication banner: the machine says nothing to a + # client that has not logged in. The mark and the version go in + # the motd, which a login prints (image.health). + ctx.check(policy.get("banner") in (None, "none"), + "sshd sends a pre-authentication banner (%r)", policy.get("banner")) + if not before["dev_image"]: ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no", "a release image runs sshd with PermitRootLogin %r, PermitEmptyPasswords %r", @@ -1002,165 +1008,89 @@ def factory_return(ctx): ctx.log("PASS: the return is refused without confirm=1; the page confirms before it calls") -# ----------------------------------------------------------------- mDNS +# --------------------------------------------------- the machine's name -def mdns_query(name, qid=None): - """A DNS query packet for the A record of name with the unicast- - response bit set (RFC 6762 5.4), so the responder answers this - socket directly.""" - qid = random.randrange(1, 65536) if qid is None else qid - labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.strip(".").split(".")) - return struct.pack(">HHHHHH", qid, 0, 1, 0, 0, 0) + labels + b"\x00" + struct.pack(">HH", 1, 0x8001) +def mac_suffix(): + """The last four hex digits of the MAC address the hostname is built + from: wlan0, or eth0 on a machine with no WiFi.""" + for dev in ("wlan0", "eth0"): + raw = read_file("/sys/class/net/%s/address" % dev) + mac = (raw or b"").decode("ascii", "replace").strip().replace(":", "").lower() + if mac and mac != "0" * 12: + return mac[-4:] + return "" -def _dns_name(pkt, off): - """(name, offset after the name); follows compression pointers.""" - parts = [] - jumped = False - end = off - hops = 0 - while True: - if off >= len(pkt): - raise ValueError("truncated name") - n = pkt[off] - if n == 0: - off += 1 - break - if n & 0xC0 == 0xC0: - if off + 1 >= len(pkt): - raise ValueError("truncated pointer") - ptr = ((n & 0x3F) << 8) | pkt[off + 1] - if not jumped: - end = off + 2 - jumped = True - off = ptr - hops += 1 - if hops > 32: - raise ValueError("pointer loop") +def cmdlines(): + """The command line of every process on the machine, one string + each.""" + out = [] + for name in os.listdir("/proc"): + if not name.isdigit(): continue - off += 1 - parts.append(pkt[off:off + n].decode("ascii", "replace")) - off += n - if not jumped: - end = off - return ".".join(parts), end + raw = read_file("/proc/%s/cmdline" % name) + if raw: + out.append(raw.decode("utf-8", "replace").replace("\0", " ").strip()) + return out -def mdns_answers(pkt, qid=None): - """The A records of a DNS response: [(name, address)]. A qid, when - given, must match the packet's id; the answer flag (QR) must be set.""" - if len(pkt) < 12: - return [] - pid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", pkt[:12]) - if qid is not None and pid != qid: - return [] - if not flags & 0x8000: - return [] - off = 12 - try: - for _ in range(qd): - _name, off = _dns_name(pkt, off) - off += 4 - out = [] - for _ in range(an + ns + ar): - name, off = _dns_name(pkt, off) - if off + 10 > len(pkt): - break - rtype, rclass, _ttl, rdlen = struct.unpack(">HHIH", pkt[off:off + 10]) - off += 10 - rdata = pkt[off:off + rdlen] - off += rdlen - if rtype == 1 and rdlen == 4: - out.append((name.lower(), socket.inet_ntoa(rdata))) - return out - except ValueError: - return [] - - -def mdns_resolve(ip, name=MDNS_NAME, timeout=3.0, tries=3): - """Ask the LAN for the A record of name over mDNS from the interface - that holds ip, and collect the answers: {address}. The query goes to - the multicast group; the responder on this machine answers the - unicast-response bit directly (a legacy query from a port that is - not 5353 is answered the same way), and a listener on 5353 catches - a multicast answer too.""" - found = set() - qid = random.randrange(1, 65536) - q = mdns_query(name, qid) - tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - rx = None - try: - tx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - tx.bind((ip, 0)) - tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(ip)) - tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255) - tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) - try: - rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - if hasattr(socket, "SO_REUSEPORT"): - rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) - rx.bind(("", MDNS_PORT)) - rx.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, - socket.inet_aton(MDNS_GROUP) + socket.inet_aton(ip)) - except OSError: - rx = None - socks = [s for s in (tx, rx) if s is not None] - for _ in range(tries): - tx.sendto(q, (MDNS_GROUP, MDNS_PORT)) - deadline = time.time() + timeout - while time.time() < deadline: - ready, _w, _x = select.select(socks, [], [], max(0.05, deadline - time.time())) - for s in ready: - try: - pkt, _peer = s.recvfrom(4096) - except OSError: - continue - # a multicast answer carries id 0; the direct one echoes the query's - for n, addr in mdns_answers(pkt, qid if s is tx else None): - if n == name.lower(): - found.add(addr) - if found: - return found - finally: - tx.close() - if rx is not None: - rx.close() - return found - - -@test("commission.mdns-announce", title="The machine answers forgefirm.local", subsystem="commission", - kind="auto", est_min=1, +@test("commission.machine-name", title="The machine names itself from its MAC address", + subsystem="commission", kind="auto", est_min=1, requires=["forgectrl.auth"], - description="avahi-daemon runs, its configuration names the host forgefirm on the WiFi and " - "wired links, and the service file advertises the panel on 443 and 80. An mDNS " - "query for the A record of forgefirm.local, sent to the multicast group from " - "the board's own LAN interface, is answered with the board's LAN address; that " - "is what `ping forgefirm.local` on a workstation resolves. No component " - "covers this: the avahi files are layer content, in the platform identity of " + description="The machine calls itself forgefirm-, where xxxx is the last four hex " + "digits of its WiFi MAC address, so two machines on one network answer to " + "different names and no name carries a serial number. The live name, " + "/etc/hostname (a bind-mounted copy, because the rootfs is read-only) and the " + "MAC address agree; the DHCP client sends the name as the hostname option, " + "which is what a network with dynamic DNS publishes; the console banner offers " + "the machine's addresses and nothing else, with no marker line and no .local " + "name; and no mDNS responder is on the image. No component covers this: the " + "hostname and banner scripts are layer content, in the platform identity of " "every fingerprint.") -def mdns_announce(ctx): +def machine_name(ctx): ev = ctx.evidence - pids = hw.pidof("avahi-daemon") - ev["avahi_pids"] = pids - ctx.check(pids, "avahi-daemon is not running") - conf = read_file("/etc/avahi/avahi-daemon.conf") or b"" - ctx.check(b"host-name=forgefirm" in conf, "avahi-daemon.conf does not name the host forgefirm") - ctx.check(b"allow-interfaces=wlan0,eth0" in conf, "avahi-daemon.conf does not restrict the interfaces") - svc = read_file("/etc/avahi/services/forgefirm.service") or b"" - ev["service_file_bytes"] = len(svc) - ctx.check(b"_https._tcp" in svc and b"443" in svc, "the service file lacks HTTPS 443") - ctx.check(b"_http._tcp" in svc and b"80" in svc, "the service file lacks HTTP 80") + + # 1. the name, the file and the MAC address agree + suffix = mac_suffix() + ev["mac_suffix"] = suffix + ctx.check(suffix, "no MAC address to build a name from") + live = socket.gethostname() + on_file = ((read_file("/etc/hostname") or b"").decode("utf-8", "replace")).strip() + ev["hostname"] = live + ev["hostname_file"] = on_file + ctx.log("hostname %r (MAC suffix %s)", live, suffix or "none") + ctx.check(live == "forgefirm-" + suffix, + "the hostname is %r, expected forgefirm-%s", live, suffix) + ctx.check(on_file == live, "/etc/hostname holds %r, the live name is %r", on_file, live) + + # 2. the DHCP client sends it (option 12), so dynamic DNS can publish it + dhcp = [c for c in cmdlines() if c.split(" ")[0].split("/")[-1] == "udhcpc"] + ev["udhcpc"] = dhcp + ctx.check(dhcp, "no DHCP client is running") + ctx.check(any("-x hostname:" + live in c for c in dhcp), + "the DHCP client does not send the machine's name: %s", dhcp) + + # 3. the console banner: the addresses, and nothing that is not the banner + issue = (read_file("/etc/issue") or b"").decode("utf-8", "replace") + ev["issue"] = issue ip = lan_ip() ev["lan_ip"] = ip ctx.check(ip, "cannot determine the board's LAN address") - t0 = time.time() - found = mdns_resolve(ip) - ev["answers"] = sorted(found) - ev["resolve_s"] = round(time.time() - t0, 2) - ctx.log("%s -> %s (%.1f s)", MDNS_NAME, sorted(found) or "no answer", ev["resolve_s"]) - ctx.check(found, "no mDNS answer for %s from the LAN interface (%s)", MDNS_NAME, ip) - ctx.check(ip in found, "%s resolves to %s, not the LAN address %s", MDNS_NAME, sorted(found), ip) + ctx.check("Control panel:" in issue, "the console banner names no control panel") + ctx.check("https://%s/" % ip in issue, + "the console banner does not carry the LAN address %s", ip) + for junk in ("# end", "# ForgeFIRM addresses", ".local"): + ctx.check(junk not in issue, "the console banner shows %r", junk) + urls = re.findall(r"https://(\S+)/", issue) + ev["banner_urls"] = urls + for u in urls: + literal = u.startswith("[") or all(c.isdigit() or c == "." for c in u) + ctx.check(literal, "the console banner offers %r, which is not an address", u) + + # 4. no mDNS on the image; the name is the only name the machine has + ev["avahi_pids"] = hw.pidof("avahi-daemon") + ctx.check(not ev["avahi_pids"], "an mDNS responder is running: %s", ev["avahi_pids"]) + ctx.check(not os.path.exists("/etc/avahi"), "/etc/avahi is on the image") # --------------------------------------------------------- the first run @@ -1458,7 +1388,8 @@ def cert_page(ctx): ctx.check(st == 200, "GET %s/cert -> %s, expected 200", base, st) ctx.check("location" not in hdrs, "GET %s/cert redirected", base) ctx.check(fp in text, "the fingerprint is not on the page from %s", base) - ctx.check("forgefirm.local" in text, "forgefirm.local is not among the names on the page") + ctx.check(socket.gethostname() in text, + "the machine's hostname is not among the names on the page") st, body, hdrs = request(base, "GET", "/cert.pem") ctx.check(st == 200 and body.startswith(b"-----BEGIN CERTIFICATE-----"), "GET %s/cert.pem -> %s, not a PEM certificate", base, st) diff --git a/forgetest/forgetest/suite/commission_dark.py b/forgetest/forgetest/suite/commission_dark.py index 9c22d05..8762ae3 100644 --- a/forgetest/forgetest/suite/commission_dark.py +++ b/forgetest/forgetest/suite/commission_dark.py @@ -243,7 +243,7 @@ def check_cameras(ctx): @test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs", subsystem="commission", kind="auto", hardware="takeover", est_min=5, covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"), - ("forgectrl", "src/lenshome.c"), + ("forgectrl", "src/lenshome.*"), ("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")], requires=["forgectrl.auth", "motion.pacing"], description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the " diff --git a/forgetest/forgetest/suite/forgectrl.py b/forgetest/forgetest/suite/forgectrl.py index 970d7da..1c1b9bb 100644 --- a/forgetest/forgetest/suite/forgectrl.py +++ b/forgetest/forgetest/suite/forgectrl.py @@ -35,8 +35,9 @@ def lan_ip(): covers=_COVERS_AUTH, description="Every state-changing endpoint refuses an unauthenticated write (the factory " "return, the SSH switch and the wizard's own routes included); a non-literal " - "Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused; the " - "cooling report channel accepts the loopback peer and refuses a non-loopback " + "Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused, while " + "the machine's own hostname passes and that name with a domain on it does not; " + "the cooling report channel accepts the loopback peer and refuses a non-loopback " "one (over HTTP the write is sent to HTTPS first, 302; over HTTPS the route " "answers 403 loopback only); the fuse view is two-factor " "(token and the physical button) and refused without either; " @@ -96,6 +97,17 @@ def auth(ctx): ev["host_name"] = st ctx.log("GET /status Host=evil.example.net -> %s", st) ctx.check(st == 403, "a DNS-name Host was accepted (%s)", st) + # the machine's own name passes; the same name with a domain on it + # does not, because anyone can register one + own = socket.gethostname() + st, body = fc.get("/status", headers={"Host": own}) + ev["host_own_name"] = st + ctx.log("GET /status Host=%s -> %s", own, st) + ctx.check(st == 200, "the machine's own hostname was refused as a Host (%s)", st) + st, body = fc.get("/status", headers={"Host": own + ".example.net"}) + ev["host_own_name_domain"] = st + ctx.log("GET /status Host=%s.example.net -> %s", own, st) + ctx.check(st == 403, "a domain name built on the machine's name was accepted (%s)", st) st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"}) ev["origin_name"] = st ctx.log("GET /status Origin=http://evil.example.net -> %s", st) diff --git a/forgetest/forgetest/suite/image.py b/forgetest/forgetest/suite/image.py index 1431732..9465626 100644 --- a/forgetest/forgetest/suite/image.py +++ b/forgetest/forgetest/suite/image.py @@ -28,6 +28,12 @@ def _wdog1_wcr(): return None +def _plain(line): + """A line without its ANSI color sequences: what a terminal shows, + counted in columns rather than in bytes.""" + return re.sub(r"\x1b\[[0-9;]*m", "", line) + + def _read(path, default=None): try: with open(path, "r", encoding="utf-8", errors="replace") as f: @@ -104,7 +110,9 @@ def fds_of(pid): ("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"), ("linux-fslc", "**")], description="The image that is running is the image the manifest describes, with the " - "kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, " + "version stamped in /etc/forgefirm-version and under the machine mark in the " + "console banner and the motd, " + "the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, " "the daemon ownership, " "the init ordering, the file modes the release depends on, and the mounts: the " "rootfs read-only, /data writable, the account files and the banner rendered " @@ -113,11 +121,39 @@ def image_health(ctx): ev = ctx.evidence manifest = ctx.runner.manifest - # 1. version stamp + # 1. version stamp: the machine-readable file, and the two a person + # reads - the console banner (/etc/issue, which forgefirm-banner + # renders from the image's own text) and the motd a login prints. + # Each carries the machine mark with the version on the mark's own + # last line, right-justified to the mark's last column. ver = (_read("/etc/forgefirm-version", "") or "").strip() ev["forgefirm_version"] = ver ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version) ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", ver, manifest.version) + for path in ("/etc/issue", "/etc/motd"): + text = _read(path, "") or "" + ev[path] = text + lines = text.splitlines() + at = [i for i, line in enumerate(lines) if line.rstrip().endswith(ver)] + ctx.check(at, "%s carries no line ending in the version %r", path, ver) + if not at: + continue + i = at[0] + ctx.check(lines[i].rstrip() == lines[i], "%s pads the version line: %r", path, lines[i]) + ctx.check(_plain(lines[i]).strip() != ver, + "%s puts the version on a line of its own, not on the mark's last line", path) + # Right-justified to the mark's last column: the stamped line is + # exactly as wide as the widest line above it. Only the motd is + # measured, because /etc/issue holds the mark with its + # backslashes doubled for the getty that reads it, so its bytes + # are wider than the columns a person sees. + if path == "/etc/motd": + above = [len(_plain(line)) for line in lines[:i] if line.strip()] + width = max(above) if above else 0 + ev["motd_columns"] = [len(_plain(lines[i])), width] + ctx.check(len(_plain(lines[i])) == width, + "/etc/motd stamped line is %d columns, the mark above is %d", + len(_plain(lines[i])), width) # 2. kernel options cfg = kernel_config() @@ -251,7 +287,8 @@ def image_health(ctx): # 8. the mounts: the rootfs read-only, /data the writable partition, # the state a read-only rootfs hands off (the read-only-rootfs image - # feature, forgefirm-users, forgefirm-banner, the sshd host keys). The + # feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the + # sshd host keys). The # dev image alone mounts the factory slots under /factory. mounts = {} for line in (_read("/proc/mounts", "") or "").splitlines(): @@ -262,7 +299,8 @@ def image_health(ctx): def mount_opts(path): return (mounts.get(path) or {}).get("opts") or [] - ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd", "/etc/issue") if p in mounts} + ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd", + "/etc/hostname", "/etc/issue") if p in mounts} ctx.log("/ mounted %s; /data %s; /var/lib %s", ",".join(mount_opts("/")) or "(absent)", ",".join(mount_opts("/data")) or "(absent)", ",".join(mount_opts("/var/lib")) or "(absent)") ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/")) @@ -292,6 +330,7 @@ def image_health(ctx): ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f) for n in names: ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n) + ctx.check("/etc/hostname" in mounts, "/etc/hostname is not the bind-mounted name copy") ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy") if hw.pidof("sshd"): key = "/data/forgefirm/ssh/ssh_host_ed25519_key" diff --git a/forgetest/tests/test_commission_suite.py b/forgetest/tests/test_commission_suite.py index 92faa54..3c20f54 100644 --- a/forgetest/tests/test_commission_suite.py +++ b/forgetest/tests/test_commission_suite.py @@ -1,13 +1,11 @@ """The commission.* suite on the host: the registration (ids, kinds, the takeover tests, the operator tests' hands), the record builders, the -mDNS packet code, the cookie parsing, the LED cue, the settle rule for a +machine's name, the cookie parsing, the LED cue, the settle rule for a gated supervisor, and the cloud-off surface test driven end to end against the fake daemon.""" import json import os import shutil -import socket -import struct import tempfile import unittest @@ -19,7 +17,7 @@ from forgetest.suite import commission IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot", "commission.advisories-rehash", "commission.account-login", "commission.https-only-writes", "commission.ssh-until-reboot", "commission.cloud-disabled-surface", - "commission.factory-return", "commission.mdns-announce", "commission.first-run-flow", + "commission.factory-return", "commission.machine-name", "commission.first-run-flow", "commission.first-run-page", "commission.what-changed", "commission.record-export", "commission.mirror") OPERATOR = ("commission.first-run-flow", "commission.first-run-page") @@ -104,8 +102,8 @@ class RegistrationTests(unittest.TestCase): self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test self.assertFalse(t.hands) - def test_mdns_covers_nothing_by_design(self): - self.assertEqual(self.reg["commission.mdns-announce"].covers, ()) + def test_the_machine_name_covers_nothing_by_design(self): + self.assertEqual(self.reg["commission.machine-name"].covers, ()) def test_the_login_test_makes_its_own_account(self): # No bench credentials, no precheck: the test installs a temporary @@ -184,35 +182,45 @@ class RecordTests(unittest.TestCase): os.environ.pop("GF_RUN_DIR", None) -class MdnsTests(unittest.TestCase): - def test_query_is_a_unicast_response_question(self): - q = commission.mdns_query("forgefirm.local", qid=0x1234) - qid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", q[:12]) - self.assertEqual((qid, flags, qd, an, ns, ar), (0x1234, 0, 1, 0, 0, 0)) - self.assertEqual(q[12:], b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 0x8001)) +class MachineNameTests(unittest.TestCase): + def setUp(self): + self.root = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) - def _response(self, qid, name_bytes, addr, flags=0x8400, extra=b""): - q = b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 1) - rr = name_bytes + struct.pack(">HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(addr) - return struct.pack(">HHHHHH", qid, flags, 1, 1, 0, 0) + q + rr + extra + def _net(self, **devs): + """A /sys/class/net tree; commission.mac_suffix reads it through + commission.read_file, which takes an absolute path.""" + for dev, mac in devs.items(): + d = os.path.join(self.root, dev) + os.makedirs(d) + with open(os.path.join(d, "address"), "w") as f: + f.write(mac + "\n") + real = commission.read_file - def test_answers_follow_a_compression_pointer(self): - pkt = self._response(7, b"\xc0\x0c", "192.168.1.9") - self.assertEqual(commission.mdns_answers(pkt, 7), [("forgefirm.local", "192.168.1.9")]) + def read(path): + head = "/sys/class/net/" + if path.startswith(head): + return real(os.path.join(self.root, path[len(head):])) + return real(path) - def test_answers_with_the_name_spelled_out(self): - pkt = self._response(0, b"\x09forgefirm\x05local\x00", "10.0.0.5") - self.assertEqual(commission.mdns_answers(pkt), [("forgefirm.local", "10.0.0.5")]) + commission.read_file = read + self.addCleanup(setattr, commission, "read_file", real) - def test_wrong_id_or_a_query_yields_nothing(self): - pkt = self._response(7, b"\xc0\x0c", "192.168.1.9") - self.assertEqual(commission.mdns_answers(pkt, 8), []) - self.assertEqual(commission.mdns_answers(self._response(7, b"\xc0\x0c", "1.2.3.4", flags=0), 7), []) - self.assertEqual(commission.mdns_answers(b"\x00" * 5), []) + def test_the_wifi_address_names_the_machine(self): + self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55") + self.assertEqual(commission.mac_suffix(), "b00a") - def test_a_truncated_packet_yields_nothing(self): - pkt = self._response(7, b"\xc0\x0c", "192.168.1.9") - self.assertEqual(commission.mdns_answers(pkt[:20], 7), []) + def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self): + self._net(eth0="00:11:22:33:44:55") + self.assertEqual(commission.mac_suffix(), "4455") + + def test_an_unread_address_is_no_address(self): + self._net(wlan0="00:00:00:00:00:00") + self.assertEqual(commission.mac_suffix(), "") + + def test_no_interface_is_no_address(self): + self._net() + self.assertEqual(commission.mac_suffix(), "") class SmallHelpersTests(unittest.TestCase): diff --git a/kas/forgefirm-glowforge.lock.yml b/kas/forgefirm-glowforge.lock.yml index 7906aa3..1e1728b 100644 --- a/kas/forgefirm-glowforge.lock.yml +++ b/kas/forgefirm-glowforge.lock.yml @@ -11,4 +11,4 @@ overrides: meta-freescale-distro: commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345 meta-openglow: - commit: b7ad6d9377fbb4215dc4d898837906b06b47e5e9 + commit: ced2af274ba270fd8fc9a6601bbd3fc6247110a6 diff --git a/meta-forgefirm/conf/distro/forgefirm.conf b/meta-forgefirm/conf/distro/forgefirm.conf index 19ff2e1..554a70b 100644 --- a/meta-forgefirm/conf/distro/forgefirm.conf +++ b/meta-forgefirm/conf/distro/forgefirm.conf @@ -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-, 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-, 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 diff --git a/meta-forgefirm/recipes-connectivity/avahi/avahi/avahi-daemon.conf b/meta-forgefirm/recipes-connectivity/avahi/avahi/avahi-daemon.conf deleted file mode 100644 index 24fbc4a..0000000 --- a/meta-forgefirm/recipes-connectivity/avahi/avahi/avahi-daemon.conf +++ /dev/null @@ -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 diff --git a/meta-forgefirm/recipes-connectivity/avahi/avahi/forgefirm.service b/meta-forgefirm/recipes-connectivity/avahi/avahi/forgefirm.service deleted file mode 100644 index 7834edf..0000000 --- a/meta-forgefirm/recipes-connectivity/avahi/avahi/forgefirm.service +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - ForgeFIRM on %h - - - _https._tcp - 443 - - - - _http._tcp - 80 - - - diff --git a/meta-forgefirm/recipes-connectivity/avahi/avahi_%.bbappend b/meta-forgefirm/recipes-connectivity/avahi/avahi_%.bbappend deleted file mode 100644 index fc34546..0000000 --- a/meta-forgefirm/recipes-connectivity/avahi/avahi_%.bbappend +++ /dev/null @@ -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 -} diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc index fe0cb92..3d29384 100644 --- a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc @@ -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" diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-banner/files/forgefirm-banner b/meta-forgefirm/recipes-forgefirm/forgefirm-banner/files/forgefirm-banner index 2cf3279..93386d1 100644 --- a/meta-forgefirm/recipes-forgefirm/forgefirm-banner/files/forgefirm-banner +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-banner/files/forgefirm-banner @@ -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 diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-banner/forgefirm-banner.bb b/meta-forgefirm/recipes-forgefirm/forgefirm-banner/forgefirm-banner.bb index f37f4d2..74cb5e2 100644 --- a/meta-forgefirm/recipes-forgefirm/forgefirm-banner/forgefirm-banner.bb +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-banner/forgefirm-banner.bb @@ -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" diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-hostname/files/forgefirm-hostname b/meta-forgefirm/recipes-forgefirm/forgefirm-hostname/files/forgefirm-hostname new file mode 100644 index 0000000..9482865 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-hostname/files/forgefirm-hostname @@ -0,0 +1,68 @@ +#!/bin/sh +# Sets the machine's hostname to forgefirm-, 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 diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-hostname/files/forgefirm-hostname.init b/meta-forgefirm/recipes-forgefirm/forgefirm-hostname/files/forgefirm-hostname.init new file mode 100644 index 0000000..da12143 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-hostname/files/forgefirm-hostname.init @@ -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- 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 diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-hostname/forgefirm-hostname.bb b/meta-forgefirm/recipes-forgefirm/forgefirm-hostname/forgefirm-hostname.bb new file mode 100644 index 0000000..d9910a1 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-hostname/forgefirm-hostname.bb @@ -0,0 +1,28 @@ +SUMMARY = "ForgeFIRM hostname: forgefirm- from the MAC address" +DESCRIPTION = "Names the machine forgefirm-, 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 +} diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb index 62b33c5..f1a30ac 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb @@ -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- 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