#!/bin/sh # Build the apk-managed root: Alpine's minirootfs, with SchOS installed into # it as an apk package. # # tools/posix/mkaroot.sh SYSROOT OUTDIR APK_STATIC MINIROOTFS KEYDIR VERSION \ # LOCK ALPINEDIR # # SYSROOT the staged SchOS userland (sysroot/), what the ramdisk root is # OUTDIR gets root/ (the tree for mkschfs) and repo/ (the SchOS repository) # APK_STATIC Alpine's static apk, from ports/alpine # MINIROOTFS Alpine's minirootfs tarball, from ports/alpine # KEYDIR where the repository signing key lives; made on first use # VERSION schos-base's apk version, e.g. 0.1_git20261024-r0 # LOCK ports/alpine/world.lock: the Alpine packages to install # ALPINEDIR where those are, already fetched and checked (dist/alpine) # # What comes out is a root apk can manage: `apk add` installs anything from # Alpine, and `apk upgrade` updates SchOS from its own repository, which is # copied into the root at /var/lib/schos/repo and listed after Alpine's. # # schos-base is the sysroot less what Alpine's own packages own. Those are: # # /bin/sh busybox-binsh. Alpine's package scripts are written for # ash; mksh stays, as /bin/mksh, and schinit's recovery # shell looks for it first because it is static. # /lib/ld-musl-* musl. Every SchOS program is static and does not use it. # /etc/passwd, /etc/group # alpine-baselayout-data. Root's shell becomes zsh below, # the one edit SchOS makes to them. # /etc/profile alpine-baselayout-data. SchOS's goes to profile.d. # # /etc/os-release and /etc/motd are SchOS's and taken over with `replaces`. # Alpine's programs replace SchOS's where both have one: # # zsh Alpine's, from world. SchOS keeps its zshrc and zshenv, # taken over with `replaces`. # pci.ids, usb.ids Alpine's hwdata-pci and hwdata-usb, from world; Xorg # depends on hwdata-pci and two owners of one file is an # error apk will not install past. # busybox's commands cat, sed, find and the rest: Alpine's package scripts # are written against busybox's, and toybox's same-named # links are left out. toybox keeps what busybox lacks. # # Runs as an ordinary user. apk's scripts need a chroot, so the install runs # with --no-scripts and this script makes the one change schos-base's # post-install would; on a running SchOS the script itself runs. set -eu [ $# -eq 8 ] || { echo "usage: $0 SYSROOT OUTDIR APK_STATIC MINIROOTFS KEYDIR VERSION LOCK ALPINEDIR" >&2; exit 2; } abs() { case "$1" in /*) echo "$1" ;; *) echo "$(pwd)/$1" ;; esac; } SYSROOT=$(abs "$1"); OUT=$(abs "$2"); APK=$(abs "$3") MINI=$(abs "$4"); KEYDIR=$(abs "$5"); VERSION=$6 LOCK=$(abs "$7"); ADIR=$(abs "$8") KEY=$KEYDIR/schos-local.rsa ROOT=$OUT/root REPO=$OUT/repo/x86_64 PKG=$OUT/pkg # --- the signing key ---------------------------------------------------------- # # One per build tree, never committed: whoever builds SchOS signs their own # packages, and the root they build trusts exactly that key besides Alpine's. if [ ! -f "$KEY" ]; then mkdir -p "$KEYDIR" openssl genrsa -out "$KEY" 2048 2>/dev/null openssl rsa -in "$KEY" -pubout -out "$KEY.pub" 2>/dev/null echo "mkaroot: made repository key $KEY" fi rm -rf "$OUT" mkdir -p "$ROOT" "$REPO" "$PKG/root" # First, because what busybox provides is read from it. tar -xzf "$MINI" -C "$ROOT" # --- schos-base --------------------------------------------------------------- cp -a "$SYSROOT/." "$PKG/root/" P=$PKG/root mv "$P/bin/sh" "$P/bin/mksh" rm -f "$P"/lib/ld-musl-*.so.1 "$P/etc/passwd" "$P/etc/group" rm -rf "$P/bin/zsh" "$P/bin/zsh-5.9" "$P/bin/zsh.old" "$P/usr/share/zsh" # pci.ids and usb.ids are Alpine's hwdata-pci and hwdata-usb, in world. rm -rf "$P/usr/share/hwdata" # Every busybox link in the minirootfs, out of the package. (cd "$ROOT" && find bin sbin usr/bin usr/sbin -type l) | while read -r l; do case "$(readlink "$ROOT/$l")" in */busybox) rm -f "$P/$l" ;; esac done # su and passwd are SchOS's own and run setuid root (cmd/su, cmd/passwd): the # busybox ones are not setuid here, and a setuid toybox is one binary under # every name. passwd goes where PATH finds it before busybox's link. mkdir -p "$P/usr/bin" cp "$SYSROOT/bin/su" "$P/bin/su" cp "$SYSROOT/bin/passwd" "$P/usr/bin/passwd" rm -f "$P/bin/passwd" chmod 4755 "$P/bin/su" "$P/usr/bin/passwd" # udev's device permissions for the kernel's own nodes. mkdir -p "$P/etc/udev/rules.d" mv "$P/etc/udev-schos.rules" "$P/etc/udev/rules.d/90-schos.rules" # SchOS's profile without the three lines Alpine's already sets, and sets # better: PATH with sbin, HOME from passwd, TERM from the console job. mkdir -p "$P/etc/profile.d" grep -v '^export \(PATH\|HOME\|TERM\)=' "$P/etc/profile" > "$P/etc/profile.d/schos.sh" rm -f "$P/etc/profile" # How startx starts the X server for root: Alpine's xserverrc. (It used to add # -dumbSched, Xorg's switch for reading input on the main loop, because when SchOS # had no threads the server crashed on its way out the first time a client # disconnected, in pthread_join on a thread that was never made. SchOS has threads # now; X was run without the switch under QEMU -- clients connecting and # disconnecting, an xterm starting and exiting -- and it stays up.) The file is # root's own rather than /etc/X11/xinit/xserverrc, which belongs to the xinit # package. mkdir -p "$P/root" cat > "$P/root/.xserverrc" <<'XEOF' #!/bin/sh # The VT X runs on is the one startx was started from. startx works that out # only when `uname -s` says Linux, and SchOS's says SchIFT. vt=$(tty | sed -n 's|^/dev/tty\([0-9][0-9]*\)$|vt\1|p') exec /usr/bin/X -nolisten tcp $vt "$@" XEOF # What startx runs inside it: Window Maker, which world installs. Without this # xinit falls back to twm and an xterm, neither of which is in the image. cat > "$P/root/.xinitrc" <<'XEOF' #!/bin/sh exec wmaker XEOF # X's input devices are found, not named: udevd (etc/schinit/udevd.job) turns # the kernel's uevents into /run/udev data, and Xorg asks libudev for every # device with ID_INPUT set. That was a hand-written list of event0 and event1 # while SchOS had no udev. The file is SchOS's, in xorg.conf.d, which no Alpine # package writes into; what is left here is the options the defaults do not # give, and the framebuffer driver. mkdir -p "$P/etc/X11/xorg.conf.d" cat > "$P/etc/X11/xorg.conf.d/10-schos.conf" <<'XEOF' Section "ServerFlags" # Ctrl+Alt+Backspace ends X. Option "DontZap" "false" EndSection Section "InputClass" Identifier "SchOS keyboards" MatchIsKeyboard "on" Option "XkbRules" "evdev" Option "XkbOptions" "terminate:ctrl_alt_bksp" EndSection Section "Device" Identifier "fb" Driver "fbdev" EndSection Section "Screen" Identifier "screen" Device "fb" EndSection Section "ServerLayout" Identifier "schos" Screen "screen" EndSection XEOF # Who may start X. The wrapper's default is "console users", decided by whether # a standard descriptor is a Linux VT, which xinit's server child does not # keep. SchOS has one seat, and /dev's group permissions (video, input, tty) # are what actually guard the hardware, so anyone who can open them may run X. cat > "$P/etc/X11/Xwrapper.config" <<'XEOF' allowed_users=anybody needs_root_rights=no XEOF # bwrap, without the sandbox. glycin -- which loads every image gdk-pixbuf is # asked for, so every GTK icon -- runs its loaders under bubblewrap and has no # other way to start them. Real bwrap needs user and mount namespaces, which # SchOS does not have. This takes the same arguments, keeps what they say # about the environment and the directory, and runs the program as it is: # no isolation at all, and it says so in its name. It replaces bubblewrap's own # /usr/bin/bwrap, which glycin depends on and which cannot run here. mkdir -p "$P/usr/bin" cat > "$P/usr/bin/bwrap" <<'BEOF' #!/bin/sh # SchOS: bubblewrap's command line, with no sandbox behind it. SchOS has no # namespaces; what the options say about the environment and the working # directory is honoured, and everything about isolation is ignored. clear= envs= while [ $# -gt 0 ]; do case "$1" in --clearenv) clear=1; shift ;; --setenv) envs="$envs $2=$3"; shift 3 ;; --unsetenv) unset "$2"; shift 2 ;; --chdir) cd "$2" || exit 1; shift 2 ;; --bind|--bind-try|--ro-bind|--ro-bind-try|--dev-bind|--dev-bind-try|\ --symlink|--bind-data|--ro-bind-data|--file|--chmod) shift 3 ;; --dev|--proc|--tmpfs|--dir|--mqueue|--remount-ro|--seccomp|--add-seccomp-fd|\ --uid|--gid|--hostname|--cap-add|--cap-drop|--lock-file|--sync-fd|\ --info-fd|--json-status-fd|--block-fd|--userns-block-fd|--perms|--size|\ --argv0|--exec-label|--file-label|--userns|--userns2|--pidns) shift 2 ;; --unshare-*|--share-net|--die-with-parent|--new-session|--as-pid-1|\ --disable-userns|--assert-userns-disabled) shift ;; --) shift; break ;; -*) echo "bwrap: $1: not understood (SchOS runs bwrap without a sandbox)" >&2 exit 1 ;; *) break ;; esac done [ $# -gt 0 ] || { echo "bwrap: no command" >&2; exit 1; } # shellcheck disable=SC2086 if [ -n "$clear" ]; then exec env -i PATH="$PATH" $envs "$@"; fi # shellcheck disable=SC2086 [ -n "$envs" ] && exec env $envs "$@" exec "$@" BEOF chmod 755 "$P/usr/bin/bwrap" # ALSA's default device, through the OSS plugin to /dev/dsp. The plug in front # converts whatever a program plays to what every SchOS audio driver accepts: # 16-bit stereo at 48 kHz (AC'97 without variable rate plays nothing else, and # the plugin refuses a rate the device rounds by more than 1%). mkdir -p "$P/etc" cat > "$P/etc/asound.conf" <<'AEOF' # SchOS: ALSA plays through OSS's /dev/dsp (kernel/dev/audio.c). pcm.!default { type plug slave { pcm "dsp" format S16_LE channels 2 rate 48000 } } pcm.dsp { type oss device /dev/dsp } ctl.!default { type hw card 0 } AEOF # Mount points and runtime directories are alpine-baselayout's; an empty # directory here only duplicates it. for d in dev proc sys run tmp srv; do rmdir "$P/$d" 2>/dev/null || true done cat > "$PKG/post-install" <<'EOF' #!/bin/sh # Root logs in to zsh, as on every SchOS; Alpine's passwd says /bin/sh. sed -i 's|^\(root:[^:]*:0:0:[^:]*:[^:]*:\)/bin/sh$|\1/bin/zsh|' /etc/passwd for s in /bin/zsh /bin/mksh; do grep -qx "$s" /etc/shells 2>/dev/null || echo "$s" >> /etc/shells done # The machine's own name, which Alpine's hosts leaves out. Without it xauth # cannot resolve "schos:0", and startx says "bad display name" four times. grep -qw schos /etc/hosts || printf '127.0.1.1\tschos\n' >> /etc/hosts exit 0 EOF cp "$PKG/post-install" "$PKG/post-upgrade" "$APK" mkpkg \ --info name:schos-base \ --info "version:$VERSION" \ --info arch:x86_64 \ --info "description:SchOS: init, console, tools and configuration" \ --info license:GPL-2.0-or-later \ --info url:https://github.com/Nexoniarz/SchOS \ --info origin:schos-base \ --info "depends:alpine-baselayout busybox musl apk-tools zsh" \ --info "replaces:alpine-release alpine-baselayout zsh bubblewrap busybox" \ --files "$P" \ --script "post-install:$PKG/post-install" \ --script "post-upgrade:$PKG/post-upgrade" \ --sign-key "$KEY" \ --output "$REPO/schos-base-$VERSION.apk" "$APK" --keys-dir "$KEYDIR" mkndx --sign-key "$KEY" \ --output "$REPO/APKINDEX.tar.gz" "$REPO"/*.apk # --- the root ----------------------------------------------------------------- cp "$KEY.pub" "$ROOT/etc/apk/keys/" # world.lock's packages, by file: no index and no network. apk still checks # each one's signature against Alpine's keys in the root. FILES=$(grep -v '^#' "$LOCK" | while read -r f sum repo; do echo "$ADIR/$f"; done) # shellcheck disable=SC2086 "$APK" --root "$ROOT" --repositories-file /dev/null --repository "$OUT/repo" \ --no-network --no-scripts --no-chown --no-cache add $FILES schos-base # What world asked for, by name rather than by file, so a later `apk upgrade` # on the running system follows Alpine rather than these exact versions. sed 's/#.*//' "$(dirname "$LOCK")/world" | tr -s ' \t' '\n' | grep . | while read -r w; do grep -qx "$w" "$ROOT/etc/apk/world" || echo "$w" >> "$ROOT/etc/apk/world"; done # Installing by file pins each one to its exact checksum ("zsh>/dev/null || echo "$s" >> "$ROOT_PI/etc/shells" done grep -qw schos "$ROOT_PI/etc/hosts" || printf "127.0.1.1\tschos\n" >> "$ROOT_PI/etc/hosts"' # The triggers --no-scripts skipped, and they are not optional: they build the # caches half the desktop reads. Without shared-mime-info's, GLib cannot tell a # PNG from anything else, so gdk-pixbuf -- which hands images to glycin by MIME # type -- refused every icon and NetSurf died on its first toolbar button. # Likewise gdk-pixbuf's loader list, GLib's compiled schemas and GIO modules, # GTK's input methods, the font caches and busybox's applet links. # # They run in the root as /, which is what bubblewrap is for: no chroot, so no # root needed. apk names each script by the hex form of the checksum the # triggers database keys in base64, and passes the paths it watches as # arguments, globs expanded. if command -v bwrap >/dev/null 2>&1; then TRIG=$(mktemp -d) tar -xzf "$ROOT/lib/apk/db/scripts.tar.gz" -C "$TRIG" --wildcards '*.trigger' 2>/dev/null || true mkdir -p "$ROOT/tmp" for t in "$TRIG"/*.trigger; do [ -e "$t" ] || continue hex=${t##*.X1}; hex=${hex%.trigger} q=Q1$(python3 -c 'import base64,sys; print(base64.b64encode(bytes.fromhex(sys.argv[1])).decode())' "$hex") paths=$(grep "^$q " "$ROOT/lib/apk/db/triggers" | cut -d' ' -f2-) cp "$t" "$ROOT/tmp/apk-trigger" # --clearenv: the host's environment is not the root's. A Nix shell # exports GDK_PIXBUF_MODULE_FILE, and the loader cache went there. bwrap --bind "$ROOT" / --dev /dev --proc /proc --clearenv \ --setenv PATH /usr/sbin:/usr/bin:/sbin:/bin --setenv HOME /root \ /bin/sh -c 'a=; for g in '"$paths"'; do for p in $g; do [ -e "$p" ] && a="$a $p"; done; done exec /bin/sh /tmp/apk-trigger $a' \ || echo "mkaroot: trigger $(basename "$t") failed" >&2 rm -f "$ROOT/tmp/apk-trigger" done rm -rf "$TRIG" else echo "mkaroot: no bwrap -- triggers not run; image and MIME caches are missing" >&2 fi # The shared scratch directories, world-writable and sticky as baselayout # means them. apk running as an ordinary user on the build host lands them as # 0755, and with the kernel reporting real modes that is what tmux and every # other program checking /tmp would see. chmod 1777 "$ROOT/tmp" "$ROOT/var/tmp" # --- accounts ------------------------------------------------------------------- # # Alpine's baselayout has root and the system accounts, with root's password # locked. A live image needs a way in, so: root with no password, and an # ordinary user, `schos`, who can become root with doas and su (they are in the # wheel group) and uses the desktop. Both have empty passwords -- login lets # them in without asking -- and `passwd` sets one. # # The numbers are Alpine's: wheel 10, tty 5, video 27, input 23, audio 18. add_member() { # add_member GROUP USER if grep -q "^$1:[^:]*:[^:]*:$" "$ROOT/etc/group"; then sed -i "s/^\($1:[^:]*:[^:]*:\)$/\1$2/" "$ROOT/etc/group" else sed -i "s/^\($1:[^:]*:[^:]*:.*\)$/\1,$2/" "$ROOT/etc/group" fi } sed -i 's/^root:[^:]*:/root::/' "$ROOT/etc/shadow" grep -q '^schos:' "$ROOT/etc/passwd" || echo 'schos:x:1000:1000:SchOS user:/home/schos:/bin/zsh' >> "$ROOT/etc/passwd" grep -q '^schos:' "$ROOT/etc/group" || echo 'schos:x:1000:' >> "$ROOT/etc/group" grep -q '^schos:' "$ROOT/etc/shadow" || echo 'schos::0:0:::::' >> "$ROOT/etc/shadow" for g in wheel tty video input audio cdrom; do grep -q "^$g:.*[:,]schos\(,\|\$\)" "$ROOT/etc/group" || add_member $g schos done chmod 640 "$ROOT/etc/shadow" # Who may become root with doas: the wheel group, without being asked again. # The file has to be root's and not writable by anyone else, or doas refuses it. cat > "$ROOT/etc/doas.conf" <<'DEOF' # doas: see doas.conf(5). Members of wheel run anything as anyone. permit nopass :wheel DEOF chmod 644 "$ROOT/etc/doas.conf" # The user's home, with what startx wants there. root's are in /root. mkdir -p "$ROOT/home/schos" cp "$ROOT/root/.xinitrc" "$ROOT/root/.xserverrc" "$ROOT/home/schos/" chmod 755 "$ROOT/home/schos/.xserverrc" # zsh offers to write one on first login; /etc/zsh is where the settings are. : > "$ROOT/home/schos/.zshrc" chmod 700 "$ROOT/home/schos" "$ROOT/root" # The setuid programs. Run as an ordinary user, apk may have dropped the bit, # and nothing here should depend on whether it did. for f in bin/su usr/bin/passwd usr/bin/doas; do [ -f "$ROOT/$f" ] && chmod 4755 "$ROOT/$f" done # Who owns what inside the image: everything is root's -- the build host's # user means nothing here -- except the user's home. cat > "$OUT/owners" <<'OEOF' /home/schos 1000 1000 0700 /root 0 0 0700 /tmp 0 0 1777 /var/tmp 0 0 1777 OEOF # The repository goes in too, so the installed system can reinstall and # upgrade schos-base, and Alpine's are listed for everything else. mkdir -p "$ROOT/var/lib/schos" cp -a "$OUT/repo" "$ROOT/var/lib/schos/repo" { cat "$ROOT/etc/apk/repositories" echo "/var/lib/schos/repo" } > "$ROOT/etc/apk/repositories.new" mv "$ROOT/etc/apk/repositories.new" "$ROOT/etc/apk/repositories" echo "mkaroot: $ROOT ($(du -sh "$ROOT" | cut -f1)), schos-base $VERSION"