From b0cd84245daaee8a7ea1b96b30f1be99bf43ff18 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Tue, 21 Jul 2026 04:23:04 -0700 Subject: [PATCH] Merge nucleic/gentle-willow-quail-cjp3 into dev --- mkimage/hooks/00-identity.sh | 5 ++ mkimage/profiles/base.naros-pkgs | 1 + mkimage/profiles/vm-desktop.naros-pkgs | 1 + mkimage/profiles/vm.naros-pkgs | 1 + packages/naros-identity/control | 17 +++++ packages/naros-identity/postinst | 35 +++++++++ packages/naros-identity/prerm | 28 +++++++ packages/naros-identity/stage.sh | 25 +++++++ packages/naros-tier-base/control | 2 +- src/naros-identity/build.sh | 41 +++++++++++ .../naros-identity-modules.service | 31 ++++++++ src/naros-identity/uname.c | 73 +++++++++++++++++++ 12 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 packages/naros-identity/control create mode 100644 packages/naros-identity/postinst create mode 100644 packages/naros-identity/prerm create mode 100644 packages/naros-identity/stage.sh create mode 100755 src/naros-identity/build.sh create mode 100644 src/naros-identity/naros-identity-modules.service create mode 100644 src/naros-identity/uname.c diff --git a/mkimage/hooks/00-identity.sh b/mkimage/hooks/00-identity.sh index 590be6e..93c1e8c 100755 --- a/mkimage/hooks/00-identity.sh +++ b/mkimage/hooks/00-identity.sh @@ -44,6 +44,10 @@ echo "$ROLE" > "$R/etc/naros/role" # Capability manifest (NAROS.md §6.3). Base fields here; toolchain entries are appended # by the tiers that install them (agent-tier hook, N2). Versions of Nucleic packages are # queryable via dpkg, listed here for one-stop reads. +# +# kernel_tag is the suffix naros-identity's preloaded shim appends to uname(2)'s release +# (§2.3) — recorded so a reader can tell the narOS marker apart from the host kernel's own +# version, and strip it. narOS builds no kernel; the tag is identity, not a kernel build. nash_ver="$(chroot "$R" dpkg-query -W -f '${Version}' nash 2>/dev/null || echo null)" [ "$nash_ver" = null ] || nash_ver="\"$nash_ver\"" cat > "$R/etc/naros/manifest.json" < "$R/etc/naros/manifest.json" < +Section: utils +Priority: optional +Depends: libc6 +Description: narOS kernel identity shim (NAROS.md §2.3) + narOS ships no kernel of its own — containers share the host's and the VM tiers + boot an externally-fetched vmlinux — so uname(2) reports a kernel with no narOS + in it, and every consumer that derives an OS string from it (notably the + `OS Version:` line agent harnesses build from os.type()+os.release()) misses the + identity that /etc/os-release carries. This package preloads a small interposer + via /etc/ld.so.preload that appends the narOS release tag to utsname.release, + the way a distro kernel package does. sysname stays "Linux" so build tooling + that switches on it is unaffected; NAROS_UNAME_PASSTHROUGH=1 disables the tag + for callers that resolve /lib/modules/`uname -r`. diff --git a/packages/naros-identity/postinst b/packages/naros-identity/postinst new file mode 100644 index 0000000..0b25107 --- /dev/null +++ b/packages/naros-identity/postinst @@ -0,0 +1,35 @@ +#!/bin/sh +# Register the identity shim in /etc/ld.so.preload (NAROS.md §2.3). +# +# Idempotent, and additive rather than authoritative: the file is rewritten preserving any +# other entries, so this package never owns unrelated preloads. The write goes through a +# temp file + rename because /etc/ld.so.preload is read by the loader on EVERY exec — a +# partially written list would be observed by whatever runs during the write. +set -e + +LIB=/usr/lib/naros/libnaros-uname.so +PRELOAD=/etc/ld.so.preload +TMP="$PRELOAD.naros-tmp" + +case "$1" in + configure) + # Belt and braces: never point the loader at a library that is not on disk. + if [ ! -f "$LIB" ]; then + echo "naros-identity: $LIB missing, not registering preload" >&2 + exit 0 + fi + if [ -f "$PRELOAD" ] && grep -qxF "$LIB" "$PRELOAD"; then + exit 0 + fi + if [ -f "$PRELOAD" ]; then + cat "$PRELOAD" > "$TMP" + else + : > "$TMP" + fi + echo "$LIB" >> "$TMP" + chmod 0644 "$TMP" + mv "$TMP" "$PRELOAD" + ;; +esac + +exit 0 diff --git a/packages/naros-identity/prerm b/packages/naros-identity/prerm new file mode 100644 index 0000000..b273e75 --- /dev/null +++ b/packages/naros-identity/prerm @@ -0,0 +1,28 @@ +#!/bin/sh +# Deregister the identity shim from /etc/ld.so.preload before its files are removed +# (NAROS.md §2.3), so the loader never names a library that is no longer on disk. +# +# Runs on remove/deconfigure only: on `upgrade` the entry must persist, since the +# replacement .so lands at the same path and the new postinst is a no-op. +set -e + +LIB=/usr/lib/naros/libnaros-uname.so +PRELOAD=/etc/ld.so.preload +TMP="$PRELOAD.naros-tmp" + +case "$1" in + remove | deconfigure) + [ -f "$PRELOAD" ] || exit 0 + # grep exits 1 when nothing survives the filter; that is the empty case, not an error. + grep -vxF "$LIB" "$PRELOAD" > "$TMP" || true + if [ -s "$TMP" ]; then + chmod 0644 "$TMP" + mv "$TMP" "$PRELOAD" + else + # An empty ld.so.preload is legal but pointless — drop the file entirely. + rm -f "$TMP" "$PRELOAD" + fi + ;; +esac + +exit 0 diff --git a/packages/naros-identity/stage.sh b/packages/naros-identity/stage.sh new file mode 100644 index 0000000..09cdd03 --- /dev/null +++ b/packages/naros-identity/stage.sh @@ -0,0 +1,25 @@ +# Stage the prebuilt narOS kernel identity shim (os/src/naros-identity, built per-arch +# into dist/bin by that dir's build.sh / the CI binaries job). +# +# /etc/ld.so.preload is written by the postinst rather than shipped in files/: dpkg +# unpacks a package's files in no guaranteed order, so a shipped preload file could land +# before the .so it names and make every binary exec'd for the rest of that transaction — +# including dpkg's own maintainer scripts — emit a loader warning. The postinst runs +# after the whole package is on disk, so the reference is never dangling. +# The /lib/modules alias unit rides along with a static enable symlink (rather than a +# `systemctl enable` in the postinst) so it takes effect inside the mmdebstrap chroot, +# where no systemd is running — the same pattern nucleic-linux-agent uses. +stage() { + local dest="$1" arch="$2" + local lib="$OS_DIR/dist/bin/libnaros-uname-$arch.so" + local unit="$OS_DIR/src/naros-identity/naros-identity-modules.service" + if [ ! -f "$lib" ]; then + echo "prebuilt shim missing: dist/bin/libnaros-uname-$arch.so" > "$dest/.skip-reason" + return 1 + fi + install -D -m 0644 "$lib" "$dest/usr/lib/naros/libnaros-uname.so" + install -D -m 0644 "$unit" "$dest/usr/lib/systemd/system/naros-identity-modules.service" + install -d "$dest/etc/systemd/system/sysinit.target.wants" + ln -sf /usr/lib/systemd/system/naros-identity-modules.service \ + "$dest/etc/systemd/system/sysinit.target.wants/naros-identity-modules.service" +} diff --git a/packages/naros-tier-base/control b/packages/naros-tier-base/control index d832a42..9137b04 100644 --- a/packages/naros-tier-base/control +++ b/packages/naros-tier-base/control @@ -4,7 +4,7 @@ Architecture: all Maintainer: Nucleic Section: metapackages Priority: optional -Depends: nash, nash-default-shell, naros-init, naros, ca-certificates, curl, git, openssh-client, iproute2 +Depends: nash, nash-default-shell, naros-init, naros, naros-identity, ca-certificates, curl, git, openssh-client, iproute2 Recommends: naros-keyring Description: narOS base tier (NAROS.md §4) The minimal narOS surface: nash forced as the default shell, naros-init, the diff --git a/src/naros-identity/build.sh b/src/naros-identity/build.sh new file mode 100755 index 0000000..ebc4dd6 --- /dev/null +++ b/src/naros-identity/build.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Build the narOS kernel identity shim into os/dist/bin/libnaros-uname-.so +# (NAROS.md §2.3), the artifact os/packages/naros-identity/stage.sh packages. +# +# build.sh [arch] arch: arm64|amd64 (default: this host's) +# +# glibc, dynamically linked, on purpose: the shim only ever loads into glibc processes +# via /etc/ld.so.preload, and a static or musl build could not interpose them. CI builds +# arm64 with the gcc-aarch64-linux-gnu cross toolchain rather than zig (which the musl +# static binaries in this tree use) because a glibc shared object is exactly what the +# stock cross-gcc is for. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OS_DIR="$(cd "$here/../.." && pwd)" +VERSION="$(cat "$OS_DIR/VERSION")" + +case "${1:-$(dpkg --print-architecture 2>/dev/null || uname -m)}" in + arm64 | aarch64) arch=arm64 ;; + amd64 | x86_64) arch=amd64 ;; + *) echo "unsupported arch: ${1:-}" >&2; exit 2 ;; +esac + +# Cross only when the target differs from the host; a native build wants plain cc. +host="$(uname -m)" +cc=cc +if [ "$arch" = arm64 ] && [ "$host" != aarch64 ]; then cc=aarch64-linux-gnu-gcc; fi +if [ "$arch" = amd64 ] && [ "$host" != x86_64 ]; then cc=x86_64-linux-gnu-gcc; fi +command -v "$cc" > /dev/null || { echo "compiler not found: $cc" >&2; exit 2; } + +out="$OS_DIR/dist/bin/libnaros-uname-$arch.so" +mkdir -p "$(dirname "$out")" +"$cc" -shared -fPIC -O2 -Wall -Wextra \ + -DNAROS_KERNEL_TAG="\"-naros$VERSION\"" \ + -o "$out" "$here/uname.c" +# Match strip to the compiler: the host's strip cannot touch a cross-built object. +strip_bin=strip +[ "$cc" = cc ] || strip_bin="${cc%gcc}strip" +"$strip_bin" "$out" 2> /dev/null || true + +echo "built $out (tag -naros$VERSION, $cc)" diff --git a/src/naros-identity/naros-identity-modules.service b/src/naros-identity/naros-identity-modules.service new file mode 100644 index 0000000..af7d7ed --- /dev/null +++ b/src/naros-identity/naros-identity-modules.service @@ -0,0 +1,31 @@ +[Unit] +Description=narOS: alias /lib/modules for the identity-tagged kernel release +Documentation=https://github.com/abkslm/nucleic/blob/main/docs/NAROS.md +# /lib/modules/`uname -r` is a real path: kmod, depmod and udev all resolve modules +# through it. Since naros-identity tags utsname.release (NAROS.md §2.3), the tagged name +# has no directory and module autoloading would fail on the tiers that load modules at all +# — the VM tiers, which boot an external kernel whose modules the payload bakes in under +# the UNTAGGED release. Symlinking the tagged name onto the real directory fixes every +# consumer at once, which env-var plumbing into each caller could not. +# +# Inert on the container tiers: naros-init is PID 1 there, so no unit ever runs, and +# containers never load modules anyway. +DefaultDependencies=no +Before=systemd-modules-load.service sysinit.target +After=systemd-remount-fs.service + +[Service] +Type=oneshot +RemainAfterExit=yes +# Only $(...) substitutions, never bare $var: systemd expands $NAME in ExecStart itself, +# so shell variables here would arrive empty. +# +# Both guards matter. The first requires the real module directory to exist, so a kernel +# that ships no modules is left alone. The second requires the tagged path to be ABSENT, +# which is what makes this safe when the tag is not in effect (shim not installed, or +# passthrough): there tagged == real, the path exists, and we must not replace a real +# module directory with a symlink to itself. +ExecStart=/bin/sh -c '[ -d "/lib/modules/$(NAROS_UNAME_PASSTHROUGH=1 uname -r)" ] && [ ! -e "/lib/modules/$(uname -r)" ] && ln -sfnT "/lib/modules/$(NAROS_UNAME_PASSTHROUGH=1 uname -r)" "/lib/modules/$(uname -r)"; true' + +[Install] +WantedBy=sysinit.target diff --git a/src/naros-identity/uname.c b/src/naros-identity/uname.c new file mode 100644 index 0000000..43a07ef --- /dev/null +++ b/src/naros-identity/uname.c @@ -0,0 +1,73 @@ +/* narOS kernel identity shim (NAROS.md §2.3). + * + * narOS does not build its own kernel: containers share the host's (on Cloudflare + * Containers that is a Firecracker microVM kernel, whose release string carries a + * `-cloudflare-firecracker` suffix) and the VM tiers boot an externally-fetched + * vmlinux (scripts/fetch-kernel.sh). So there is no CONFIG_LOCALVERSION to set, and + * every uname(2)-derived identity — including the `OS Version:` line agent harnesses + * put in their environment block, which is os.type() + os.release() — reports the + * host kernel with no narOS in it at all, no matter what /etc/os-release says. + * + * This interposer, preloaded via /etc/ld.so.preload, appends the narOS release tag to + * utsname.release the way a distro kernel package does (Debian's own kernels report + * `6.1.0-18-amd64`), so the identity is true at the syscall layer rather than only in + * files a caller has to know to read. + * + * Deliberately narrow: `sysname` stays "Linux". It is the single most-switched-on + * uname field in build tooling — autoconf's config.guess, CMAKE_SYSTEM_NAME, node-gyp, + * the Go and rustup installers all compare it against "Linux" and fall through to + * "unsupported platform" otherwise. `version` is likewise untouched. + * + * Truth comes from syscall(SYS_uname) rather than dlsym(RTLD_NEXT): a library in + * /etc/ld.so.preload is loaded into *every* dynamically-linked process on the system, + * including early boot and dpkg's own maintainer scripts, so it must not depend on + * libdl being resolvable or risk recursing through an interposed symbol. + * + * Escape hatch: NAROS_UNAME_PASSTHROUGH=1 returns the kernel's answer verbatim. This + * exists because /lib/modules/`uname -r` is a real path — kmod, depmod and udev resolve + * modules through it, so the VM tiers need the untagged release to find their external + * kernel's modules (see nucleic-modsetup.service, which also symlinks the tagged name + * onto the real one so autoloading works without the env var). + * + * Statically linked binaries, and Go programs that issue the raw syscall themselves, + * bypass this by construction — the tag is an identity marker, never a security or + * correctness boundary. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +/* Baked at build time from os/VERSION, e.g. "-naros26.07". */ +#ifndef NAROS_KERNEL_TAG +#define NAROS_KERNEL_TAG "-naros" +#endif + +static int naros_passthrough(void) +{ + const char *v = getenv("NAROS_UNAME_PASSTHROUGH"); + return v != NULL && *v != '\0' && strcmp(v, "0") != 0; +} + +int uname(struct utsname *buf) +{ + long rc = syscall(SYS_uname, buf); + if (rc != 0 || buf == NULL) + return (int)rc; + if (naros_passthrough()) + return 0; + /* Idempotent: a re-exec through another preloading process must not stack tags. */ + if (strstr(buf->release, NAROS_KERNEL_TAG) != NULL) + return 0; + + size_t have = strnlen(buf->release, sizeof(buf->release)); + size_t tag = sizeof(NAROS_KERNEL_TAG) - 1; + /* utsname.release is a fixed 65-byte field; leave it untagged rather than truncate + * the real kernel release, which callers parse for version comparisons. */ + if (have + tag + 1 > sizeof(buf->release)) + return 0; + memcpy(buf->release + have, NAROS_KERNEL_TAG, tag + 1); + return 0; +}