384 lines
13 KiB
Plaintext
384 lines
13 KiB
Plaintext
# /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
|