mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 09:11:11 -07:00
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:
@@ -1,6 +1,7 @@
|
|||||||
"""commission.* - the first-run commissioning: the record and the
|
"""commission.* - the first-run commissioning: the record and the
|
||||||
controller gate, the advisories, the account and its login, the HTTPS
|
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
|
The daemon reads the commissioning record (commissioning.json in the
|
||||||
data directory) and the account record (users) once, at its start, and
|
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.
|
when the daemon is up again.
|
||||||
|
|
||||||
The layer content of this work (the account replay init script, the
|
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.
|
platform identity that every fingerprint carries, not of a component.
|
||||||
No coverage map names it: a change there makes every test necessary
|
No coverage map names it: a change there makes every test necessary
|
||||||
again.
|
again.
|
||||||
@@ -23,10 +24,9 @@ import http.client
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import select
|
import re
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import struct
|
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
@@ -46,9 +46,6 @@ ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
|
|||||||
SAFETY_PHRASE = "I UNDERSTAND"
|
SAFETY_PHRASE = "I UNDERSTAND"
|
||||||
LOGIN_FAILS = 5
|
LOGIN_FAILS = 5
|
||||||
LOGIN_LOCK_S = 30
|
LOGIN_LOCK_S = 30
|
||||||
MDNS_NAME = "forgefirm.local"
|
|
||||||
MDNS_GROUP = "224.0.0.251"
|
|
||||||
MDNS_PORT = 5353
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------- files
|
# ----------------------------------------------------------------- files
|
||||||
@@ -777,7 +774,7 @@ def https_only_writes(ctx):
|
|||||||
# ------------------------------------------------------------------ ssh
|
# ------------------------------------------------------------------ ssh
|
||||||
|
|
||||||
def sshd_policy():
|
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."""
|
lowercase keys), or {"error": ...} when sshd cannot report it."""
|
||||||
for exe in ("/usr/sbin/sshd", "sshd"):
|
for exe in ("/usr/sbin/sshd", "sshd"):
|
||||||
rc, out = hw.run([exe, "-T"], timeout=15)
|
rc, out = hw.run([exe, "-T"], timeout=15)
|
||||||
@@ -789,7 +786,7 @@ def sshd_policy():
|
|||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
parts = line.split(None, 1)
|
parts = line.split(None, 1)
|
||||||
if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords",
|
if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords",
|
||||||
"passwordauthentication"):
|
"passwordauthentication", "banner"):
|
||||||
policy[parts[0]] = parts[1].strip()
|
policy[parts[0]] = parts[1].strip()
|
||||||
return policy
|
return policy
|
||||||
return {"error": "no sshd binary"}
|
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 "
|
"sshd listens on 22, with the effective policy (sshd -T) at PasswordAuthentication "
|
||||||
"yes, and on a release image PermitRootLogin no and PermitEmptyPasswords no (the "
|
"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 "
|
"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 "
|
"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 "
|
"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 "
|
"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.log("sshd -T: %s", policy)
|
||||||
ctx.check(policy.get("passwordauthentication") == "yes",
|
ctx.check(policy.get("passwordauthentication") == "yes",
|
||||||
"PasswordAuthentication is %r: the account cannot log in", policy.get("passwordauthentication"))
|
"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"]:
|
if not before["dev_image"]:
|
||||||
ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no",
|
ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no",
|
||||||
"a release image runs sshd with PermitRootLogin %r, PermitEmptyPasswords %r",
|
"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")
|
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):
|
def mac_suffix():
|
||||||
"""A DNS query packet for the A record of name with the unicast-
|
"""The last four hex digits of the MAC address the hostname is built
|
||||||
response bit set (RFC 6762 5.4), so the responder answers this
|
from: wlan0, or eth0 on a machine with no WiFi."""
|
||||||
socket directly."""
|
for dev in ("wlan0", "eth0"):
|
||||||
qid = random.randrange(1, 65536) if qid is None else qid
|
raw = read_file("/sys/class/net/%s/address" % dev)
|
||||||
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.strip(".").split("."))
|
mac = (raw or b"").decode("ascii", "replace").strip().replace(":", "").lower()
|
||||||
return struct.pack(">HHHHHH", qid, 0, 1, 0, 0, 0) + labels + b"\x00" + struct.pack(">HH", 1, 0x8001)
|
if mac and mac != "0" * 12:
|
||||||
|
return mac[-4:]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _dns_name(pkt, off):
|
def cmdlines():
|
||||||
"""(name, offset after the name); follows compression pointers."""
|
"""The command line of every process on the machine, one string
|
||||||
parts = []
|
each."""
|
||||||
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")
|
|
||||||
continue
|
|
||||||
off += 1
|
|
||||||
parts.append(pkt[off:off + n].decode("ascii", "replace"))
|
|
||||||
off += n
|
|
||||||
if not jumped:
|
|
||||||
end = off
|
|
||||||
return ".".join(parts), end
|
|
||||||
|
|
||||||
|
|
||||||
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 = []
|
out = []
|
||||||
for _ in range(an + ns + ar):
|
for name in os.listdir("/proc"):
|
||||||
name, off = _dns_name(pkt, off)
|
if not name.isdigit():
|
||||||
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
|
continue
|
||||||
# a multicast answer carries id 0; the direct one echoes the query's
|
raw = read_file("/proc/%s/cmdline" % name)
|
||||||
for n, addr in mdns_answers(pkt, qid if s is tx else None):
|
if raw:
|
||||||
if n == name.lower():
|
out.append(raw.decode("utf-8", "replace").replace("\0", " ").strip())
|
||||||
found.add(addr)
|
return out
|
||||||
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",
|
@test("commission.machine-name", title="The machine names itself from its MAC address",
|
||||||
kind="auto", est_min=1,
|
subsystem="commission", kind="auto", est_min=1,
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
description="avahi-daemon runs, its configuration names the host forgefirm on the WiFi and "
|
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
|
||||||
"wired links, and the service file advertises the panel on 443 and 80. An mDNS "
|
"digits of its WiFi MAC address, so two machines on one network answer to "
|
||||||
"query for the A record of forgefirm.local, sent to the multicast group from "
|
"different names and no name carries a serial number. The live name, "
|
||||||
"the board's own LAN interface, is answered with the board's LAN address; that "
|
"/etc/hostname (a bind-mounted copy, because the rootfs is read-only) and the "
|
||||||
"is what `ping forgefirm.local` on a workstation resolves. No component "
|
"MAC address agree; the DHCP client sends the name as the hostname option, "
|
||||||
"covers this: the avahi files are layer content, in the platform identity of "
|
"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.")
|
"every fingerprint.")
|
||||||
def mdns_announce(ctx):
|
def machine_name(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
pids = hw.pidof("avahi-daemon")
|
|
||||||
ev["avahi_pids"] = pids
|
# 1. the name, the file and the MAC address agree
|
||||||
ctx.check(pids, "avahi-daemon is not running")
|
suffix = mac_suffix()
|
||||||
conf = read_file("/etc/avahi/avahi-daemon.conf") or b""
|
ev["mac_suffix"] = suffix
|
||||||
ctx.check(b"host-name=forgefirm" in conf, "avahi-daemon.conf does not name the host forgefirm")
|
ctx.check(suffix, "no MAC address to build a name from")
|
||||||
ctx.check(b"allow-interfaces=wlan0,eth0" in conf, "avahi-daemon.conf does not restrict the interfaces")
|
live = socket.gethostname()
|
||||||
svc = read_file("/etc/avahi/services/forgefirm.service") or b""
|
on_file = ((read_file("/etc/hostname") or b"").decode("utf-8", "replace")).strip()
|
||||||
ev["service_file_bytes"] = len(svc)
|
ev["hostname"] = live
|
||||||
ctx.check(b"_https._tcp" in svc and b"<port>443</port>" in svc, "the service file lacks HTTPS 443")
|
ev["hostname_file"] = on_file
|
||||||
ctx.check(b"_http._tcp" in svc and b"<port>80</port>" in svc, "the service file lacks HTTP 80")
|
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()
|
ip = lan_ip()
|
||||||
ev["lan_ip"] = ip
|
ev["lan_ip"] = ip
|
||||||
ctx.check(ip, "cannot determine the board's LAN address")
|
ctx.check(ip, "cannot determine the board's LAN address")
|
||||||
t0 = time.time()
|
ctx.check("Control panel:" in issue, "the console banner names no control panel")
|
||||||
found = mdns_resolve(ip)
|
ctx.check("https://%s/" % ip in issue,
|
||||||
ev["answers"] = sorted(found)
|
"the console banner does not carry the LAN address %s", ip)
|
||||||
ev["resolve_s"] = round(time.time() - t0, 2)
|
for junk in ("# end", "# ForgeFIRM addresses", ".local"):
|
||||||
ctx.log("%s -> %s (%.1f s)", MDNS_NAME, sorted(found) or "no answer", ev["resolve_s"])
|
ctx.check(junk not in issue, "the console banner shows %r", junk)
|
||||||
ctx.check(found, "no mDNS answer for %s from the LAN interface (%s)", MDNS_NAME, ip)
|
urls = re.findall(r"https://(\S+)/", issue)
|
||||||
ctx.check(ip in found, "%s resolves to %s, not the LAN address %s", MDNS_NAME, sorted(found), ip)
|
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
|
# --------------------------------------------------------- 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(st == 200, "GET %s/cert -> %s, expected 200", base, st)
|
||||||
ctx.check("location" not in hdrs, "GET %s/cert redirected", base)
|
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(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")
|
st, body, hdrs = request(base, "GET", "/cert.pem")
|
||||||
ctx.check(st == 200 and body.startswith(b"-----BEGIN CERTIFICATE-----"),
|
ctx.check(st == 200 and body.startswith(b"-----BEGIN CERTIFICATE-----"),
|
||||||
"GET %s/cert.pem -> %s, not a PEM certificate", base, st)
|
"GET %s/cert.pem -> %s, not a PEM certificate", base, st)
|
||||||
|
|||||||
@@ -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",
|
@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,
|
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
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")],
|
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
||||||
requires=["forgectrl.auth", "motion.pacing"],
|
requires=["forgectrl.auth", "motion.pacing"],
|
||||||
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
|
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ def lan_ip():
|
|||||||
covers=_COVERS_AUTH,
|
covers=_COVERS_AUTH,
|
||||||
description="Every state-changing endpoint refuses an unauthenticated write (the factory "
|
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 "
|
"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 "
|
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused, while "
|
||||||
"cooling report channel accepts the loopback peer and refuses a non-loopback "
|
"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 "
|
"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 "
|
"answers 403 loopback only); the fuse view is two-factor "
|
||||||
"(token and the physical button) and refused without either; "
|
"(token and the physical button) and refused without either; "
|
||||||
@@ -96,6 +97,17 @@ def auth(ctx):
|
|||||||
ev["host_name"] = st
|
ev["host_name"] = st
|
||||||
ctx.log("GET /status Host=evil.example.net -> %s", st)
|
ctx.log("GET /status Host=evil.example.net -> %s", st)
|
||||||
ctx.check(st == 403, "a DNS-name Host was accepted (%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"})
|
st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"})
|
||||||
ev["origin_name"] = st
|
ev["origin_name"] = st
|
||||||
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
|
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ def _wdog1_wcr():
|
|||||||
return None
|
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):
|
def _read(path, default=None):
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
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", "**"),
|
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
||||||
("linux-fslc", "**")],
|
("linux-fslc", "**")],
|
||||||
description="The image that is running is the image the manifest describes, with the "
|
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 daemon ownership, "
|
||||||
"the init ordering, the file modes the release depends on, and the mounts: the "
|
"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 "
|
"rootfs read-only, /data writable, the account files and the banner rendered "
|
||||||
@@ -113,11 +121,39 @@ def image_health(ctx):
|
|||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
manifest = ctx.runner.manifest
|
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()
|
ver = (_read("/etc/forgefirm-version", "") or "").strip()
|
||||||
ev["forgefirm_version"] = ver
|
ev["forgefirm_version"] = ver
|
||||||
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
|
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
|
||||||
ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", 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
|
# 2. kernel options
|
||||||
cfg = kernel_config()
|
cfg = kernel_config()
|
||||||
@@ -251,7 +287,8 @@ def image_health(ctx):
|
|||||||
|
|
||||||
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
||||||
# the state a read-only rootfs hands off (the read-only-rootfs image
|
# 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.
|
# dev image alone mounts the factory slots under /factory.
|
||||||
mounts = {}
|
mounts = {}
|
||||||
for line in (_read("/proc/mounts", "") or "").splitlines():
|
for line in (_read("/proc/mounts", "") or "").splitlines():
|
||||||
@@ -262,7 +299,8 @@ def image_health(ctx):
|
|||||||
def mount_opts(path):
|
def mount_opts(path):
|
||||||
return (mounts.get(path) or {}).get("opts") or []
|
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)",
|
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)")
|
",".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("/"))
|
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)
|
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
|
||||||
for n in names:
|
for n in names:
|
||||||
ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n)
|
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")
|
ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy")
|
||||||
if hw.pidof("sshd"):
|
if hw.pidof("sshd"):
|
||||||
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
|
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""The commission.* suite on the host: the registration (ids, kinds, the
|
"""The commission.* suite on the host: the registration (ids, kinds, the
|
||||||
takeover tests, the operator tests' hands), the record builders, 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
|
gated supervisor, and the cloud-off surface test driven end to end
|
||||||
against the fake daemon."""
|
against the fake daemon."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
@@ -19,7 +17,7 @@ from forgetest.suite import commission
|
|||||||
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
|
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
|
||||||
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
|
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
|
||||||
"commission.ssh-until-reboot", "commission.cloud-disabled-surface",
|
"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.first-run-page", "commission.what-changed", "commission.record-export",
|
||||||
"commission.mirror")
|
"commission.mirror")
|
||||||
OPERATOR = ("commission.first-run-flow", "commission.first-run-page")
|
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.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test
|
||||||
self.assertFalse(t.hands)
|
self.assertFalse(t.hands)
|
||||||
|
|
||||||
def test_mdns_covers_nothing_by_design(self):
|
def test_the_machine_name_covers_nothing_by_design(self):
|
||||||
self.assertEqual(self.reg["commission.mdns-announce"].covers, ())
|
self.assertEqual(self.reg["commission.machine-name"].covers, ())
|
||||||
|
|
||||||
def test_the_login_test_makes_its_own_account(self):
|
def test_the_login_test_makes_its_own_account(self):
|
||||||
# No bench credentials, no precheck: the test installs a temporary
|
# 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)
|
os.environ.pop("GF_RUN_DIR", None)
|
||||||
|
|
||||||
|
|
||||||
class MdnsTests(unittest.TestCase):
|
class MachineNameTests(unittest.TestCase):
|
||||||
def test_query_is_a_unicast_response_question(self):
|
def setUp(self):
|
||||||
q = commission.mdns_query("forgefirm.local", qid=0x1234)
|
self.root = tempfile.mkdtemp()
|
||||||
qid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", q[:12])
|
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||||
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))
|
|
||||||
|
|
||||||
def _response(self, qid, name_bytes, addr, flags=0x8400, extra=b""):
|
def _net(self, **devs):
|
||||||
q = b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 1)
|
"""A /sys/class/net tree; commission.mac_suffix reads it through
|
||||||
rr = name_bytes + struct.pack(">HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(addr)
|
commission.read_file, which takes an absolute path."""
|
||||||
return struct.pack(">HHHHHH", qid, flags, 1, 1, 0, 0) + q + rr + extra
|
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):
|
def read(path):
|
||||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
head = "/sys/class/net/"
|
||||||
self.assertEqual(commission.mdns_answers(pkt, 7), [("forgefirm.local", "192.168.1.9")])
|
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):
|
commission.read_file = read
|
||||||
pkt = self._response(0, b"\x09forgefirm\x05local\x00", "10.0.0.5")
|
self.addCleanup(setattr, commission, "read_file", real)
|
||||||
self.assertEqual(commission.mdns_answers(pkt), [("forgefirm.local", "10.0.0.5")])
|
|
||||||
|
|
||||||
def test_wrong_id_or_a_query_yields_nothing(self):
|
def test_the_wifi_address_names_the_machine(self):
|
||||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
|
||||||
self.assertEqual(commission.mdns_answers(pkt, 8), [])
|
self.assertEqual(commission.mac_suffix(), "b00a")
|
||||||
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_a_truncated_packet_yields_nothing(self):
|
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
|
||||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
self._net(eth0="00:11:22:33:44:55")
|
||||||
self.assertEqual(commission.mdns_answers(pkt[:20], 7), [])
|
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):
|
class SmallHelpersTests(unittest.TestCase):
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ overrides:
|
|||||||
meta-freescale-distro:
|
meta-freescale-distro:
|
||||||
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
||||||
meta-openglow:
|
meta-openglow:
|
||||||
commit: b7ad6d9377fbb4215dc4d898837906b06b47e5e9
|
commit: ced2af274ba270fd8fc9a6601bbd3fc6247110a6
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ DISTRO_FEATURES:remove = " \
|
|||||||
3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \
|
3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \
|
||||||
pulseaudio vulkan wayland x11 zeroconf "
|
pulseaudio vulkan wayland x11 zeroconf "
|
||||||
|
|
||||||
# mDNS: the image installs avahi-daemon by name (forgefirm-image.bb), so
|
# The name the image ships in /etc/hostname. forgefirm-hostname replaces
|
||||||
# the control panel answers at forgefirm.local. The avahi and zeroconf
|
# it at S38 in rcS with forgefirm-<xxxx>, from the machine's MAC address;
|
||||||
# features stay removed above: zeroconf would install
|
# this is what the few seconds before that show, and the fallback on a
|
||||||
# packagegroup-base-zeroconf (the daemon plus libnss-mdns), and avahi
|
# machine whose MAC address cannot be read.
|
||||||
# would switch other recipes' avahi options on. The build is trimmed to
|
hostname:pn-base-files = "forgefirm"
|
||||||
# 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
|
# No mDNS. The panel is reached at the machine's address, which every
|
||||||
# front ends, no libdns_sd compatibility library, no libevent binding.
|
# network resolves; the machine's own name (forgefirm-<xxxx>, from
|
||||||
# Python bindings are off in the recipe itself.
|
# forgefirm-hostname) goes out in the DHCP request, so a network with
|
||||||
PACKAGECONFIG:pn-avahi = ""
|
# dynamic DNS publishes it as well. The avahi and zeroconf features stay
|
||||||
# libnss-mdns is what avahi-daemon recommends: an NSS module so the
|
# removed above: zeroconf would install packagegroup-base-zeroconf (the
|
||||||
# machine itself resolves .local names. Nothing on the machine does.
|
# daemon plus libnss-mdns), and avahi would switch other recipes' avahi
|
||||||
BAD_RECOMMENDATIONS += "libnss-mdns"
|
# options on.
|
||||||
|
|
||||||
# TLS: forgectrl serves HTTPS on 443 with a self-signed certificate
|
# TLS: forgectrl serves HTTPS on 443 with a self-signed certificate
|
||||||
# through libmicrohttpd and ulfius, and links GnuTLS itself. GnuTLS is
|
# 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
|
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||||
# layer content hash because the component entry already identifies the
|
# layer content hash because the component entry already identifies the
|
||||||
# pinned source (forgefirm-image-manifest.bbclass).
|
# pinned source (forgefirm-image-manifest.bbclass).
|
||||||
SRCREV = "a2d73efeb9af376e122e0877a4988d92d021cfa7"
|
SRCREV = "9e5330ffbb27dcf2fb89f414f1e8fc38bfdfb6f7"
|
||||||
PV = "0.1.13"
|
PV = "0.1.14"
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Rewrites the address block of /etc/issue, the serial-console login
|
# Rewrites /etc/issue, the serial-console login banner: the lines the
|
||||||
# banner: the control panel by mDNS name, then one https:// URL per
|
# image build wrote, then one https:// URL per global address of wlan0
|
||||||
# global address of wlan0 (and eth0 when the machine has one). Every
|
# (and eth0 when the machine has one). The address is the only way in
|
||||||
# other line of /etc/issue stays as the image build wrote it. The block
|
# that the banner gives, because it is the one that works on every
|
||||||
# sits between the marker lines "# ForgeFIRM addresses" and "# end" and
|
# network. Called by the init script at boot and by the udhcpc hook on
|
||||||
# is appended when absent. Called by the init script at boot and by the
|
# every lease event. Idempotent: the file is written only when the text
|
||||||
# udhcpc hook on every lease event. Idempotent: the file is written only
|
# changes.
|
||||||
# when the block changes.
|
|
||||||
#
|
#
|
||||||
# The rootfs is read-only: at the first change after boot the file is
|
# 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
|
# bind-mounted from a copy under /run/forgefirm (tmpfs) and the banner
|
||||||
# written through the mount. Before that the image's own file shows.
|
# 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
|
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||||
|
|
||||||
ISSUE=/etc/issue
|
ISSUE=/etc/issue
|
||||||
STATE=/run/forgefirm/issue
|
STATE=/run/forgefirm/issue
|
||||||
MARK_BEGIN='# ForgeFIRM addresses'
|
BASE=/run/forgefirm/issue.base
|
||||||
MARK_END='# end'
|
|
||||||
PANEL='Control panel: https://forgefirm.local/'
|
|
||||||
|
|
||||||
# One URL per global address; an IPv6 address gets its URL brackets.
|
# One URL per global address; an IPv6 address gets its URL brackets.
|
||||||
# Tentative, deprecated and temporary addresses are left out.
|
# Tentative, deprecated and temporary addresses are left out.
|
||||||
@@ -32,45 +31,40 @@ addresses () {
|
|||||||
a = $2
|
a = $2
|
||||||
sub(/\/.*/, "", a)
|
sub(/\/.*/, "", a)
|
||||||
if ($1 == "inet6") a = "[" a "]"
|
if ($1 == "inet6") a = "[" a "]"
|
||||||
print "https://" a "/"
|
print " https://" a "/"
|
||||||
}'
|
}'
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
block () {
|
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
|
||||||
echo "$MARK_BEGIN"
|
# file by its device numbers alone).
|
||||||
echo "$PANEL"
|
is_mounted () {
|
||||||
addrs=$(addresses)
|
awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
|
||||||
if [ -n "$addrs" ]; then
|
|
||||||
echo "$addrs"
|
|
||||||
else
|
|
||||||
echo "no network address yet"
|
|
||||||
fi
|
|
||||||
echo "$MARK_END"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[ -f "$ISSUE" ] || exit 0
|
[ -f "$ISSUE" ] || exit 0
|
||||||
|
|
||||||
new=$(block)
|
# The image's own text: the file as it is before the first write of the
|
||||||
old=$(awk -v b="$MARK_BEGIN" -v e="$MARK_END" \
|
# boot. /run is a tmpfs, so the copy is made once per boot, and the
|
||||||
'$0 == b { p = 1 } p { print } $0 == e { p = 0 }' "$ISSUE")
|
# first caller is the one that finds the file untouched.
|
||||||
[ "$new" = "$old" ] && exit 0
|
mkdir -p "${STATE%/*}" || exit 1
|
||||||
|
[ -f "$BASE" ] || cp -p "$ISSUE" "$BASE" || exit 1
|
||||||
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
|
if ! is_mounted "$ISSUE"; then
|
||||||
# file by its device numbers alone).
|
cp -p "$BASE" "$STATE" \
|
||||||
if ! awk -v t="$ISSUE" '$2 == t { f = 1 } END { exit !f }' /proc/mounts; then
|
|
||||||
mkdir -p "${STATE%/*}" \
|
|
||||||
&& cp -p "$ISSUE" "$STATE" \
|
|
||||||
&& mount --bind "$STATE" "$ISSUE" || exit 1
|
&& mount --bind "$STATE" "$ISSUE" || exit 1
|
||||||
fi
|
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.$$"
|
tmp="$STATE.tmp.$$"
|
||||||
awk -v b="$MARK_BEGIN" -v e="$MARK_END" -v blk="$new" '
|
printf '%s\n\n' "$new" > "$tmp" || { rm -f "$tmp"; exit 1; }
|
||||||
$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; }
|
|
||||||
cat "$tmp" > "$ISSUE"
|
cat "$tmp" > "$ISSUE"
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
|
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
|
||||||
DESCRIPTION = "Keeps an address block in the serial-console login banner \
|
DESCRIPTION = "Keeps an address block in the serial-console login banner \
|
||||||
(/etc/issue): the control panel by mDNS name and by every global address \
|
(/etc/issue): one control panel URL per global address of wlan0 and eth0. \
|
||||||
of wlan0 and eth0. Refreshed at boot and on every DHCP lease event."
|
Refreshed at boot and on every DHCP lease event."
|
||||||
LICENSE = "MIT"
|
LICENSE = "MIT"
|
||||||
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
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
|
# forgefirm-users: renders the operator account record
|
||||||
# (/data/forgefirm/users, written by forgectrl) into the system account
|
# (/data/forgefirm/users, written by forgectrl) into the system account
|
||||||
# files at boot, before sshd, and on reload; also installs the warning an
|
# files at boot, before sshd, and on reload; also installs the warning an
|
||||||
# interactive root shell prints. forgefirm-banner: keeps the control
|
# interactive root shell prints. forgefirm-hostname: names the machine
|
||||||
# panel addresses in the serial-console banner (/etc/issue).
|
# 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.
|
# forgefirm-persist: the boot timestamp and the random seed on /data.
|
||||||
# avahi-daemon: mDNS, so the panel answers at https://forgefirm.local/
|
IMAGE_INSTALL:append = " forgefirm-users forgefirm-hostname forgefirm-banner forgefirm-persist"
|
||||||
# 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"
|
|
||||||
|
|
||||||
# The rootfs mounts read-only on both images; /data (p3) is the writable
|
# 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
|
# 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
|
# package whose post-install must run on the machine, and the removal of
|
||||||
# the packages a read-only rootfs cannot use (shadow, base-passwd,
|
# the packages a read-only rootfs cannot use (shadow, base-passwd,
|
||||||
# update-rc.d, update-alternatives; the account files stay). What must
|
# 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
|
# 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
|
# /etc/hostname and /etc/issue (forgefirm-users, forgefirm-hostname,
|
||||||
|
# forgefirm-banner), the sshd host keys
|
||||||
# (recipes-connectivity/openssh), the timestamp and the random seed
|
# (recipes-connectivity/openssh), the timestamp and the random seed
|
||||||
# (forgefirm-persist). The facts are on the docs site,
|
# (forgefirm-persist). The facts are on the docs site,
|
||||||
# technical/forgefirm/image-and-bsp; scripts/release.sh checks the built
|
# 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_EXTRA_SPACE = "40960"
|
||||||
IMAGE_ROOTFS_MAXSIZE = "204800"
|
IMAGE_ROOTFS_MAXSIZE = "204800"
|
||||||
|
|
||||||
# Version stamp: /etc/forgefirm-version (machine-readable), echoed on the
|
# The ForgeFIRM mark and the version stamp:
|
||||||
# serial-console login prompt (/etc/issue) and at SSH login (motd).
|
# /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
|
# Release images carry the release version; the dev image overrides the
|
||||||
# string with the build timestamp (the same DATETIME as the artifact
|
# string with the build timestamp (the same DATETIME as the artifact
|
||||||
# name) plus a dev tag.
|
# name) plus a dev tag.
|
||||||
@@ -123,9 +143,44 @@ FORGEFIRM_VERSION_STRING ?= "v${FORGEFIRM_RELEASE}"
|
|||||||
|
|
||||||
write_forgefirm_version() {
|
write_forgefirm_version() {
|
||||||
echo "${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/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'
|
||||||
|
___ [1;39m___ ___ ___ __ __[0m
|
||||||
|
| __|__ _ _ __ _ ___[1;39m| __|_ _| _ \ \/ |[0m
|
||||||
|
| _/ _ \ '_/ _` / -_) [1;39m_| | || / |\/| |[0m
|
||||||
|
|_|\___/_| \__, \___|[1;39m_| |___|_|_\_| |_|[0m
|
||||||
|
|___/
|
||||||
|
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 "" >> ${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"
|
write_forgefirm_version[vardepsexclude] += "DATETIME"
|
||||||
# No semicolon after a function name here or below (the vardeps rule in
|
# No semicolon after a function name here or below (the vardeps rule in
|
||||||
|
|||||||
Reference in New Issue
Block a user