diff --git a/rootfs-overlay/usr/lib/nimux/nimux-early-mount.xsh b/rootfs-overlay/usr/lib/nimux/nimux-early-mount.xsh index 0a1bbea..8bb9708 100755 --- a/rootfs-overlay/usr/lib/nimux/nimux-early-mount.xsh +++ b/rootfs-overlay/usr/lib/nimux/nimux-early-mount.xsh @@ -1,23 +1,23 @@ #!/bin/xonsh --no-rc # Nimux Early Mount (xonsh) -#mount /mnt -print("mount /mnt") -/bin/mount /mnt +# #mount /mnt +# print("mount /mnt") +# /bin/mount /mnt -# mount root on future accesable place -print("re-mount rootfs") -/bin/mkdir -p /mnt/rootfs -/bin/mount --bind / /mnt/rootfs -/bin/mount --make-rprivate /mnt/rootfs/ +# # mount root on future accesable place +# print("re-mount rootfs") +# /bin/mkdir -p /mnt/rootfs +# /bin/mount --bind / /mnt/rootfs +# /bin/mount --make-rprivate /mnt/rootfs/ -# import zpool -print("import zpool") -/sbin/zpool import nimux-zfs +# # import zpool +# print("import zpool") +# /sbin/zpool import nimux-zfs -# overlay etc and home with a persistant zfs dataset -print("overlay mount etc") -/bin/unionfs -o cow,nonempty /mnt/rootfs.overlay/etc=RW:/mnt/rootfs/etc=RO /etc -print("overlay mount home") -/bin/unionfs -o cow,nonempty /mnt/rootfs.overlay/home=RW:/mnt/rootfs/home=RO /home +# # overlay etc and home with a persistant zfs dataset +# print("overlay mount etc") +# /bin/unionfs -o cow,nonempty /mnt/rootfs.overlay/etc=RW:/mnt/rootfs/etc=RO /etc +# print("overlay mount home") +# /bin/unionfs -o cow,nonempty /mnt/rootfs.overlay/home=RW:/mnt/rootfs/home=RO /home diff --git a/rootfs-overlay/usr/lib/nimux/nimux-firstlogin b/rootfs-overlay/usr/lib/nimux/nimux-firstlogin index e69de29..13f52fd 100644 --- a/rootfs-overlay/usr/lib/nimux/nimux-firstlogin +++ b/rootfs-overlay/usr/lib/nimux/nimux-firstlogin @@ -0,0 +1,383 @@ +# /etc/profile.d/nimux-first-login.sh +# Nimux first-login bootstrap (mirror) — numbered disk selection + safeguards + default proposal + +case $- in *i*) ;; *) return 0 ;; esac + +SENTINEL="/var/lib/nimux/firstboot.done" +[ -f "$SENTINEL" ] && return 0 + +run_bootstrap() { + set -euo pipefail + + need() { command -v "$1" >/dev/null 2>&1 || { echo "Missing: $1"; exit 1; }; } + need zpool; need zfs; need lsblk; command -v blkid >/dev/null 2>&1 || true; command -v blockdev >/dev/null 2>&1 || true + + CUR_HOST="$(hostname)" + POOL="${CUR_HOST}-zfs" + FSTAB="/etc/fstab" + OVERLAY_ROOT="/mnt/overlays/root" + ADMINUSER="" + NEW_LANG="${LANG:-en_US.UTF-8}" + DISK1=""; DISK2="" + DEFAULT_IDX1=""; DEFAULT_IDX2="" + + # -------------------- Helpers -------------------- + + canonical() { readlink -f "$1"; } + + has_partitions() { + # returns 0 (true) if the disk has child partitions + local d="$1" + lsblk -n "$d" -o TYPE | grep -q '^part$' + } + + fstype_of() { + # Try lsblk first, then blkid + local d="$1" fs + fs="$(lsblk -dn -o FSTYPE "$d" 2>/dev/null | tr -d ' ')" + if [ -z "$fs" ] && command -v blkid >/dev/null 2>&1; then + fs="$(blkid -o value -s TYPE "$d" 2>/dev/null | tr -d ' ')" + fi + echo "${fs:-}" + } + + bytes_of() { + # size in bytes (fallback to lsblk if blockdev not present) + local d="$1" + if command -v blockdev >/dev/null 2>&1; then + blockdev --getsize64 "$d" 2>/dev/null || echo 0 + else + lsblk -bdn -o SIZE "$d" 2>/dev/null || echo 0 + fi + } + + is_whole_disk() { + # TYPE=disk + local d="$1" + [ "$(lsblk -dn -o TYPE "$d" 2>/dev/null)" = "disk" ] + } + + is_zfs_member() { + local d="$1" + [ "$(fstype_of "$d")" = "zfs_member" ] + } + + print_disks_table() { + echo "Available whole disks:" + printf " %-5s %-20s %-12s %-12s %s\n" "No." "PATH" "SIZE" "FSTYPE" "MODEL" + local i=1 + while read -r name size type fstype model; do + [ "$type" = "disk" ] || continue + local path="/dev/$name" + local sz="$size" + printf " [%d] %-20s %-12s %-12s %s\n" "$i" "$path" "$sz" "${fstype:--}" "${model:-"-"}" + i=$((i+1)) + done < <(lsblk -dn -o NAME,SIZE,TYPE,FSTYPE,MODEL) + } + + build_disk_arrays() { + # Build arrays for selection UI + mapfile -t DISK_LINES < <(lsblk -dn -o NAME,SIZE,TYPE,FSTYPE,MODEL | awk '$3=="disk"{print}') + mapfile -t DISK_PATHS < <(printf "%s\n" "${DISK_LINES[@]}" | awk '{print "/dev/"$1}') + [ "${#DISK_PATHS[@]}" -ge 1 ] || { echo "No whole disks found."; exit 1; } + } + + propose_two_largest_empty() { + # Choose two largest disks that are: TYPE=disk, no partitions, no FSTYPE, not zfs_member + local candidates=() + local sizes=() + + for p in "${DISK_PATHS[@]}"; do + is_whole_disk "$p" || continue + if has_partitions "$p"; then continue; fi + local fs; fs="$(fstype_of "$p")" + [ -n "$fs" ] && continue + is_zfs_member "$p" && continue + local b; b="$(bytes_of "$p")" + candidates+=("$p"); sizes+=("$b") + done + + if [ "${#candidates[@]}" -lt 2 ]; then + # Not enough clean disks to propose + DEFAULT_IDX1=""; DEFAULT_IDX2="" + return 0 + fi + + # Sort candidates by size desc (simple bubble for small lists) + local idxs=() + local i; for ((i=0;i<${#candidates[@]};i++)); do idxs+=("$i"); done + local swapped=1 j tmp + while [ $swapped -eq 1 ]; do + swapped=0 + for ((j=0;j<${#idxs[@]}-1;j++)); do + if [ "${sizes[${idxs[$j]}]}" -lt "${sizes[${idxs[$j+1]}]}" ]; then + tmp="${idxs[$j]}"; idxs[$j]="${idxs[$j+1]}"; idxs[$j+1]="$tmp"; swapped=1 + fi + done + done + + local top1="${candidates[${idxs[0]}]}" + local top2="${candidates[${idxs[1]}]}" + + # Map to numbered list indices shown to user + local n i=1 + for n in "${DISK_PATHS[@]}"; do + if [ "$(canonical "$n")" = "$(canonical "$top1")" ]; then DEFAULT_IDX1="$i"; fi + if [ "$(canonical "$n")" = "$(canonical "$top2")" ]; then DEFAULT_IDX2="$i"; fi + i=$((i+1)) + done + } + + warn_if_risky() { + # Return 0 if OK; non-zero if user declines risk + local d="$1" label="$2" + local risk=0 msg="" + if has_partitions "$d"; then risk=1; msg+="\n - $label has existing partitions"; fi + local fs; fs="$(fstype_of "$d")" + if [ -n "$fs" ]; then risk=1; msg+="\n - $label has existing filesystem: $fs"; fi + if [ "$risk" -eq 0 ]; then return 0; fi + echo -e "WARNING:$msg" + read -r -p "Type 'force' to override and continue with $label: " force + [ "${force:-}" = "force" ] || { echo "Refusing risky selection for $label."; return 1; } + return 0 + } + + pick_two_disks() { + echo + print_disks_table + build_disk_arrays + propose_two_largest_empty + + local default_hint="" + if [ -n "$DEFAULT_IDX1" ] && [ -n "$DEFAULT_IDX2" ]; then + default_hint=" [${DEFAULT_IDX1} ${DEFAULT_IDX2}]" + echo + echo "Proposed (largest clean disks): ${DEFAULT_IDX1} and ${DEFAULT_IDX2}" + echo "Press Enter to accept, or type two numbers to override." + fi + + while :; do + echo + read -r -p "Select TWO disks by number (e.g. '1 3')${default_hint}: " CHOICE + if [ -z "${CHOICE// }" ] && [ -n "$DEFAULT_IDX1" ] && [ -n "$DEFAULT_IDX2" ]; then + idx1="$DEFAULT_IDX1"; idx2="$DEFAULT_IDX2" + else + CHOICE="$(echo "$CHOICE" | tr ',' ' ' | xargs)" + set +e; set -- $CHOICE; set -e + if [ $# -ne 2 ]; then echo "Please provide exactly two numbers."; continue; fi + idx1="$1"; idx2="$2" + fi + + case "$idx1" in (*[!0-9]*|'') echo "Invalid first selection."; continue;; esac + case "$idx2" in (*[!0-9]*|'') echo "Invalid second selection."; continue;; esac + [ "$idx1" -ge 1 ] && [ "$idx1" -le "${#DISK_PATHS[@]}" ] || { echo "First number out of range."; continue; } + [ "$idx2" -ge 1 ] && [ "$idx2" -le "${#DISK_PATHS[@]}" ] || { echo "Second number out of range."; continue; } + [ "$idx1" -ne "$idx2" ] || { echo "Disks must be different."; continue; } + + local cand1="${DISK_PATHS[$((idx1-1))]}" + local cand2="${DISK_PATHS[$((idx2-1))]}" + + # Risk checks + warn_if_risky "$cand1" "Disk 1" || continue + warn_if_risky "$cand2" "Disk 2" || continue + + DISK1="$cand1"; DISK2="$cand2" + echo + echo "Selected:" + echo " Disk 1: $DISK1" + echo " Disk 2: $DISK2" + read -r -p "Proceed to create MIRRORED pool on these disks? [y/N]: " yn + case "${yn:-N}" in y|Y) break;; *) echo "Re-selecting…";; esac + done + } + + create_pool_and_datasets() { + echo + echo "== Creating mirrored ZFS pool and datasets ==" + if zpool list -H -o name 2>/dev/null | grep -qx "$POOL"; then + echo "Pool '$POOL' already exists; skipping creation." + else + ASHIFT="12" + zpool create -f \ + -o ashift="$ASHIFT" \ + -O compression=zstd -O atime=off -O xattr=sa -O acltype=posixacl \ + "$POOL" mirror "$DISK1" "$DISK2" + fi + + zfs set mountpoint=none "$POOL" || true + + create_ds() { # $1=dataset $2=mountpoint|"none" + local ds="$1" mp="$2" + if ! zfs list -H -o name | grep -qx "$ds"; then + if [ "$mp" = "none" ]; then + zfs create -o mountpoint=none "$ds" + else + zfs create -o mountpoint="$mp" "$ds" + fi + else + [ "$mp" = "none" ] && zfs set mountpoint=none "$ds" || zfs set mountpoint="$mp" "$ds" + fi + } + + create_ds "${POOL}" "none" + create_ds "${POOL}/data" "/mnt/data" + create_ds "${POOL}/docker" "/var/lib/docker" + create_ds "${POOL}/overlay-layers" "none" + create_ds "${POOL}/overlay-layers/root" "$OVERLAY_ROOT" + + mkdir -p /mnt/data /var/lib/docker "$OVERLAY_ROOT" + zfs mount -a + } + + setup_overlays() { + echo + echo "== Setting up overlays for /etc and /home ==" + mkdir -p "${OVERLAY_ROOT}/etc" "${OVERLAY_ROOT}/etc.work" \ + "${OVERLAY_ROOT}/home" "${OVERLAY_ROOT}/home.work" + chmod 700 "${OVERLAY_ROOT}/etc.work" "${OVERLAY_ROOT}/home.work" + touch "$FSTAB" + + add_fstab_line() { local line="$1" pattern="$2"; grep -qsE "$pattern" "$FSTAB" || echo "$line" >> "$FSTAB"; } + + add_fstab_line \ + "ZFS=${POOL}/overlay-layers/root $OVERLAY_ROOT zfs defaults,nofail 0 0" \ + "^ZFS=${POOL}/overlay-layers/root[[:space:]]+$OVERLAY_ROOT[[:space:]]+zfs" + + add_fstab_line \ + "overlay /etc overlay nofail,x-systemd.requires-mounts-for=$OVERLAY_ROOT,lowerdir=/etc,upperdir=${OVERLAY_ROOT}/etc,workdir=${OVERLAY_ROOT}/etc.work 0 0" \ + "^overlay[[:space:]]+/etc[[:space:]]+overlay.*upperdir=${OVERLAY_ROOT}/etc,workdir=${OVERLAY_ROOT}/etc.work" + + add_fstab_line \ + "overlay /home overlay nofail,x-systemd.requires-mounts-for=$OVERLAY_ROOT,lowerdir=/home,upperdir=${OVERLAY_ROOT}/home,workdir=${OVERLAY_ROOT}/home.work 0 0" \ + "^overlay[[:space:]]+/home[[:space:]]+overlay.*upperdir=${OVERLAY_ROOT}/home,workdir=${OVERLAY_ROOT}/home.work" + } + + ask_hostname_and_maybe_rename_pool() { + echo + echo "== Hostname ==" + read -r -p "New hostname (leave blank to keep '$CUR_HOST'): " NEW_HOSTNAME + NEW_HOSTNAME="${NEW_HOSTNAME:-$CUR_HOST}" + + if command -v hostnamectl >/dev/null 2>&1; then + hostnamectl set-hostname "$NEW_HOSTNAME" + else + echo "$NEW_HOSTNAME" > /etc/hostname + fi + + local NEWPOOL="${NEW_HOSTNAME}-zfs" + if [ "$NEWPOOL" != "$POOL" ]; then + echo "Renaming pool '$POOL' -> '$NEWPOOL' via export/import…" + zfs unmount -a || true + sed -i "s/ZFS=${POOL}\//ZFS=${NEWPOOL}\//g" "$FSTAB" + zpool export "$POOL" + zpool import "$POOL" "$NEWPOOL" + zfs set mountpoint=none "$NEWPOOL" || true + zfs set mountpoint=/mnt/data "$NEWPOOL/data" || true + zfs set mountpoint=/var/lib/docker "$NEWPOOL/docker" || true + zfs set mountpoint=none "$NEWPOOL/overlay-layers" || true + zfs set mountpoint="$OVERLAY_ROOT" "$NEWPOOL/overlay-layers/root" || true + zfs mount -a + POOL="$NEWPOOL" + fi + } + + set_locale_step() { + echo + echo "== Locale ==" + read -r -p "Locale (LANG) [${NEW_LANG}]: " LANG_INPUT + NEW_LANG="${LANG_INPUT:-$NEW_LANG}" + printf 'LANG=%s\n' "$NEW_LANG" > /etc/locale.conf + if command -v localectl >/dev/null 2>&1; then + localectl set-locale "LANG=$NEW_LANG" || true + fi + } + + create_admin_user() { + echo + echo "== Create admin user with sudo rights ==" + while :; do + read -r -p "Admin username (e.g., nimuxadmin): " ADMINUSER + [ -n "${ADMINUSER:-}" ] && break + echo "Username cannot be empty." + done + + if id "$ADMINUSER" >/dev/null 2>&1; then + echo "User '$ADMINUSER' already exists." + else + if [ -x /bin/bash ]; then SHELLPATH=/bin/bash + elif [ -x /bin/sh ]; then SHELLPATH=/bin/sh + else SHELLPATH=/bin/sh; fi + useradd -m -s "$SHELLPATH" "$ADMINUSER" + echo "Set password for $ADMINUSER:" + passwd "$ADMINUSER" + fi + + if command -v sudo >/dev/null 2>&1; then + getent group sudo >/dev/null 2>&1 || groupadd sudo + usermod -aG sudo "$ADMINUSER" || true + mkdir -p /etc/sudoers.d + SUDO_FILE="/etc/sudoers.d/90-${ADMINUSER}" + [ -f "$SUDO_FILE" ] || { echo "${ADMINUSER} ALL=(ALL) ALL" > "$SUDO_FILE"; chmod 440 "$SUDO_FILE"; } + else + echo "NOTE: 'sudo' not installed; grant temporary root via 'su' until sudo is available." + fi + } + + disable_root_login() { + echo + echo "== Disable root logins ==" + passwd -l root || true + if [ -f /etc/ssh/sshd_config ]; then + if grep -qE '^\s*PermitRootLogin\s+' /etc/ssh/sshd_config; then + sed -i 's/^\s*PermitRootLogin\s\+.*/PermitRootLogin no/' /etc/ssh/sshd_config + else + printf '\nPermitRootLogin no\n' >> /etc/ssh/sshd_config + fi + if grep -qE '^\s*PermitEmptyPasswords\s+' /etc/ssh/sshd_config'; then + sed -i 's/^\s*PermitEmptyPasswords\s\+.*/PermitEmptyPasswords no/' /etc/ssh/sshd_config + else + printf 'PermitEmptyPasswords no\n' >> /etc/ssh/sshd_config + fi + (command -v systemctl >/dev/null 2>&1 && systemctl reload sshd) || \ + (command -v rc-service >/dev/null 2>&1 && rc-service sshd reload) || true + fi + } + + mark_completion() { + mkdir -p "$(dirname "$SENTINEL")" + echo "ok" > "$SENTINEL" + echo + echo "Bootstrap complete." + echo " - Pool: ${POOL}" + echo " - Overlays for /etc and /home will take effect on next reboot." + echo " - Locale: ${NEW_LANG}" + echo " - Admin user: ${ADMINUSER}" + echo " - Root login disabled" + } + + # -------------------- Ordered flow -------------------- + echo + echo "=== Nimux First-Login ===" + build_disk_arrays + pick_two_disks + create_pool_and_datasets + setup_overlays + ask_hostname_and_maybe_rename_pool + set_locale_step + create_admin_user + disable_root_login + mark_completion +} + +if [ "$EUID" -ne 0 ]; then + echo + echo "[Nimux] First-boot setup needs elevated privileges. Re-running with sudo…" + echo + sudo bash -c "$(declare -f run_bootstrap); run_bootstrap" && \ + echo "Setup completed (or already done)." || \ + echo "Setup aborted or failed." +else + run_bootstrap +fi + +[ -f "$SENTINEL" ] && chmod -x /etc/profile.d/nimux-first-login.sh 2>/dev/null || true