| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
set -eu |
| 14 |
|
| 15 |
|
| 16 |
DRY_RUN=0 |
| 17 |
APKOVL_PATH="${APKOVL_PATH:-/media/cdrom/mountaineer.apkovl.tar.gz}" |
| 18 |
CONFIG="" |
| 19 |
TARGET=/mnt |
| 20 |
ROOT_DATASET=rpool/ROOT/mountaineer |
| 21 |
|
| 22 |
while [ $# -gt 0 ]; do |
| 23 |
case "$1" in |
| 24 |
--dry-run) DRY_RUN=1 ;; |
| 25 |
--apkovl) shift; APKOVL_PATH="$1" ;; |
| 26 |
--config) shift; CONFIG="$1" ;; |
| 27 |
*) echo "unknown flag: $1" >&2; exit 2 ;; |
| 28 |
esac |
| 29 |
shift |
| 30 |
done |
| 31 |
|
| 32 |
|
| 33 |
if [ -z "$CONFIG" ]; then |
| 34 |
for candidate in /media/cdrom/install.yaml /mnt/config/install.yaml /media/MTN-CFG/install.yaml; do |
| 35 |
if [ -f "$candidate" ]; then |
| 36 |
CONFIG="$candidate" |
| 37 |
break |
| 38 |
fi |
| 39 |
done |
| 40 |
fi |
| 41 |
|
| 42 |
|
| 43 |
|
| 44 |
|
| 45 |
cfg_get() { |
| 46 |
|
| 47 |
local key="$1" |
| 48 |
local default="${2:-}" |
| 49 |
if [ -z "$CONFIG" ] || [ ! -f "$CONFIG" ]; then |
| 50 |
echo "$default" |
| 51 |
return |
| 52 |
fi |
| 53 |
local val |
| 54 |
val=$(awk -F': *' -v k="$key" ' |
| 55 |
$0 ~ "^"k": *" { |
| 56 |
sub(/^[^:]+: */, "") |
| 57 |
sub(/ *#.*/, "") |
| 58 |
gsub(/^"|"$/, "") |
| 59 |
print |
| 60 |
exit |
| 61 |
} |
| 62 |
' "$CONFIG") |
| 63 |
if [ -z "$val" ]; then |
| 64 |
echo "$default" |
| 65 |
else |
| 66 |
echo "$val" |
| 67 |
fi |
| 68 |
} |
| 69 |
|
| 70 |
INTERACTIVE=1 |
| 71 |
if [ -n "$CONFIG" ] && [ -f "$CONFIG" ]; then |
| 72 |
echo "==> using install config: $CONFIG" |
| 73 |
INTERACTIVE=0 |
| 74 |
fi |
| 75 |
|
| 76 |
run() { |
| 77 |
if [ "$DRY_RUN" = 1 ]; then |
| 78 |
echo "[dry-run] $*" |
| 79 |
else |
| 80 |
|
| 81 |
$@ |
| 82 |
fi |
| 83 |
} |
| 84 |
|
| 85 |
die() { echo "error: $*" >&2; exit 1; } |
| 86 |
|
| 87 |
|
| 88 |
require_uefi() { |
| 89 |
if [ ! -d /sys/firmware/efi ]; then |
| 90 |
die "this system booted in legacy BIOS mode; Mountaineer is UEFI-only (see INSTALL.md). install aborted." |
| 91 |
fi |
| 92 |
} |
| 93 |
|
| 94 |
|
| 95 |
pick_disk() { |
| 96 |
if [ "$INTERACTIVE" = 0 ]; then |
| 97 |
DISK=$(cfg_get disk) |
| 98 |
[ -n "$DISK" ] || die "config has no 'disk' key" |
| 99 |
[ -b "$DISK" ] || die "config disk '$DISK' is not a block device" |
| 100 |
echo "==> using disk $DISK from config" |
| 101 |
return |
| 102 |
fi |
| 103 |
|
| 104 |
|
| 105 |
|
| 106 |
candidates=$(lsblk -dn -o NAME,TYPE,SIZE,MODEL | awk '$2 == "disk" {print $1, $3, substr($0, index($0, $4))}') |
| 107 |
if [ -z "$candidates" ]; then |
| 108 |
die "no candidate disks found via lsblk" |
| 109 |
fi |
| 110 |
|
| 111 |
menu_args="" |
| 112 |
while IFS= read -r line; do |
| 113 |
name=$(echo "$line" | awk '{print $1}') |
| 114 |
size=$(echo "$line" | awk '{print $2}') |
| 115 |
model=$(echo "$line" | cut -d' ' -f3-) |
| 116 |
menu_args="$menu_args /dev/$name ${size}_${model}" |
| 117 |
done <<EOF |
| 118 |
$candidates |
| 119 |
EOF |
| 120 |
|
| 121 |
|
| 122 |
dialog --no-tags --menu "Select install disk (WILL BE WIPED):" 20 70 10 $menu_args 2>/tmp/disk |
| 123 |
DISK=$(cat /tmp/disk) |
| 124 |
rm -f /tmp/disk |
| 125 |
[ -n "$DISK" ] || die "no disk selected" |
| 126 |
|
| 127 |
dialog --yesno "WIPE $DISK and install Mountaineer? This destroys all data on the disk." 8 60 || \ |
| 128 |
die "wipe declined" |
| 129 |
} |
| 130 |
|
| 131 |
|
| 132 |
ask_encryption() { |
| 133 |
if [ "$INTERACTIVE" = 0 ]; then |
| 134 |
case "$(cfg_get encrypt false)" in |
| 135 |
true|yes|1) ENCRYPT=1; PASSPHRASE=$(cfg_get passphrase) |
| 136 |
[ -n "$PASSPHRASE" ] || die "encrypt=true requires a passphrase in config" ;; |
| 137 |
*) ENCRYPT=0; PASSPHRASE="" ;; |
| 138 |
esac |
| 139 |
echo "==> encryption: $([ $ENCRYPT = 1 ] && echo on || echo off)" |
| 140 |
return |
| 141 |
fi |
| 142 |
|
| 143 |
if dialog --yesno "Encrypt the root pool with a passphrase? (Recommended for laptops; often inappropriate for racked servers.)" 9 60; then |
| 144 |
ENCRYPT=1 |
| 145 |
|
| 146 |
while :; do |
| 147 |
dialog --insecure --passwordbox "Root pool passphrase:" 8 60 2>/tmp/p1 |
| 148 |
dialog --insecure --passwordbox "Confirm passphrase:" 8 60 2>/tmp/p2 |
| 149 |
if [ "$(cat /tmp/p1)" = "$(cat /tmp/p2)" ] && [ -s /tmp/p1 ]; then |
| 150 |
PASSPHRASE=$(cat /tmp/p1) |
| 151 |
break |
| 152 |
fi |
| 153 |
dialog --msgbox "Passphrases did not match or were empty. Try again." 7 50 |
| 154 |
done |
| 155 |
rm -f /tmp/p1 /tmp/p2 |
| 156 |
else |
| 157 |
ENCRYPT=0 |
| 158 |
PASSPHRASE="" |
| 159 |
fi |
| 160 |
} |
| 161 |
|
| 162 |
|
| 163 |
partition() { |
| 164 |
run sgdisk --zap-all "$DISK" |
| 165 |
run sgdisk -n1:1M:+512M -t1:EF00 -c1:MTN-ESP "$DISK" |
| 166 |
run sgdisk -n2:0:0 -t2:BF00 -c2:MTN-ROOT "$DISK" |
| 167 |
run partprobe "$DISK" || true |
| 168 |
sleep 1 |
| 169 |
|
| 170 |
|
| 171 |
case "$DISK" in |
| 172 |
*nvme*|*mmcblk*) ESP="${DISK}p1"; ZPART="${DISK}p2" ;; |
| 173 |
*) ESP="${DISK}1"; ZPART="${DISK}2" ;; |
| 174 |
esac |
| 175 |
} |
| 176 |
|
| 177 |
|
| 178 |
create_pool() { |
| 179 |
pool_opts="-f \ |
| 180 |
-o ashift=12 \ |
| 181 |
-o autotrim=on \ |
| 182 |
-O compression=lz4 \ |
| 183 |
-O atime=off \ |
| 184 |
-O xattr=sa \ |
| 185 |
-O acltype=posixacl \ |
| 186 |
-O dnodesize=auto \ |
| 187 |
-O normalization=formD \ |
| 188 |
-O mountpoint=none \ |
| 189 |
-O canmount=off" |
| 190 |
|
| 191 |
if [ "$ENCRYPT" = 1 ]; then |
| 192 |
|
| 193 |
if [ "$DRY_RUN" = 1 ]; then |
| 194 |
echo "[dry-run] zpool create $pool_opts -O encryption=on -O keyformat=passphrase -O keylocation=prompt rpool $ZPART" |
| 195 |
else |
| 196 |
|
| 197 |
printf '%s' "$PASSPHRASE" | zpool create $pool_opts \ |
| 198 |
-O encryption=on -O keyformat=passphrase -O keylocation=prompt \ |
| 199 |
rpool "$ZPART" |
| 200 |
fi |
| 201 |
else |
| 202 |
|
| 203 |
run zpool create $pool_opts rpool "$ZPART" |
| 204 |
fi |
| 205 |
} |
| 206 |
|
| 207 |
|
| 208 |
create_datasets() { |
| 209 |
run zfs create -o canmount=off -o mountpoint=none rpool/ROOT |
| 210 |
run zfs create -o mountpoint=/ -o canmount=noauto "$ROOT_DATASET" |
| 211 |
run zfs mount "$ROOT_DATASET" |
| 212 |
run zfs set reservation=1G "$ROOT_DATASET" |
| 213 |
|
| 214 |
run zfs create -o mountpoint=/home rpool/home |
| 215 |
run zfs create -o canmount=off -o mountpoint=/var rpool/var |
| 216 |
run zfs create rpool/var/log |
| 217 |
run zfs create rpool/var/lib |
| 218 |
run zfs create -o recordsize=128k rpool/var/lib/containers |
| 219 |
run zfs create -o sync=disabled rpool/var/cache |
| 220 |
|
| 221 |
|
| 222 |
run zpool export rpool |
| 223 |
if [ "$ENCRYPT" = 1 ]; then |
| 224 |
if [ "$DRY_RUN" = 0 ]; then |
| 225 |
printf '%s' "$PASSPHRASE" | zpool import -N -R "$TARGET" rpool |
| 226 |
zfs load-key rpool |
| 227 |
fi |
| 228 |
else |
| 229 |
run zpool import -N -R "$TARGET" rpool |
| 230 |
fi |
| 231 |
run zfs mount -a |
| 232 |
} |
| 233 |
|
| 234 |
|
| 235 |
format_esp() { |
| 236 |
run mkfs.vfat -F32 -n MTN-ESP "$ESP" |
| 237 |
run mkdir -p "$TARGET/boot/efi" |
| 238 |
|
| 239 |
|
| 240 |
run mount -t vfat "$ESP" "$TARGET/boot/efi" |
| 241 |
} |
| 242 |
|
| 243 |
|
| 244 |
compute_arc_max() { |
| 245 |
|
| 246 |
total_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo) |
| 247 |
total_gib=$(( total_kb / 1024 / 1024 )) |
| 248 |
|
| 249 |
if [ "$total_gib" -le 2 ]; then arc=$((256 * 1024 * 1024)) |
| 250 |
elif [ "$total_gib" -le 4 ]; then arc=$((512 * 1024 * 1024)) |
| 251 |
elif [ "$total_gib" -le 8 ]; then arc=$((1 * 1024 * 1024 * 1024)) |
| 252 |
elif [ "$total_gib" -le 16 ]; then arc=$((2 * 1024 * 1024 * 1024)) |
| 253 |
elif [ "$total_gib" -le 32 ]; then arc=$((4 * 1024 * 1024 * 1024)) |
| 254 |
else |
| 255 |
|
| 256 |
arc=$(( total_kb * 1024 / 8 )) |
| 257 |
cap=$(( 16 * 1024 * 1024 * 1024 )) |
| 258 |
[ "$arc" -gt "$cap" ] && arc="$cap" |
| 259 |
fi |
| 260 |
|
| 261 |
ARC_MAX="$arc" |
| 262 |
echo "==> RAM: ${total_gib} GiB; zfs_arc_max: ${ARC_MAX} bytes" |
| 263 |
if [ "$INTERACTIVE" = 1 ]; then |
| 264 |
dialog --msgbox "Detected ${total_gib} GiB RAM. Setting zfs_arc_max=${ARC_MAX} bytes.\n\nOverride post-install by editing /etc/modprobe.d/zfs.conf." 10 60 |
| 265 |
fi |
| 266 |
} |
| 267 |
|
| 268 |
|
| 269 |
install_base() { |
| 270 |
|
| 271 |
|
| 272 |
tmpovl=$(mktemp -d) |
| 273 |
if [ "$DRY_RUN" = 0 ]; then |
| 274 |
tar -xzf "$APKOVL_PATH" -C "$tmpovl" |
| 275 |
fi |
| 276 |
WORLD="$tmpovl/etc/apk/world" |
| 277 |
REPOS="$tmpovl/etc/apk/repositories" |
| 278 |
|
| 279 |
run mkdir -p "$TARGET/etc/apk" |
| 280 |
if [ "$DRY_RUN" = 0 ]; then |
| 281 |
cp "$REPOS" "$TARGET/etc/apk/repositories" |
| 282 |
cp "$WORLD" "$TARGET/etc/apk/world" |
| 283 |
|
| 284 |
|
| 285 |
|
| 286 |
mkdir -p "$TARGET/etc/apk/keys" |
| 287 |
cp /etc/apk/keys/*.pub "$TARGET/etc/apk/keys/" 2>/dev/null || true |
| 288 |
fi |
| 289 |
|
| 290 |
|
| 291 |
|
| 292 |
|
| 293 |
|
| 294 |
pkgs=$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "$WORLD" | xargs) |
| 295 |
|
| 296 |
run apk --root "$TARGET" --initdb \ |
| 297 |
--repositories-file "$TARGET/etc/apk/repositories" \ |
| 298 |
add $pkgs |
| 299 |
|
| 300 |
rm -rf "$tmpovl" |
| 301 |
} |
| 302 |
|
| 303 |
|
| 304 |
unpack_apkovl() { |
| 305 |
run tar -xzf "$APKOVL_PATH" -C "$TARGET" |
| 306 |
} |
| 307 |
|
| 308 |
|
| 309 |
write_arc_conf() { |
| 310 |
if [ "$DRY_RUN" = 1 ]; then |
| 311 |
echo "[dry-run] write zfs_arc_max=$ARC_MAX to $TARGET/etc/modprobe.d/zfs.conf" |
| 312 |
else |
| 313 |
cat > "$TARGET/etc/modprobe.d/zfs.conf" <<EOF |
| 314 |
# Mountaineer: zfs_arc_max set at install time from RAM size. |
| 315 |
options zfs zfs_arc_max=$ARC_MAX |
| 316 |
EOF |
| 317 |
fi |
| 318 |
} |
| 319 |
|
| 320 |
|
| 321 |
|
| 322 |
|
| 323 |
|
| 324 |
|
| 325 |
ZBM_VERSION=v3.1.0 |
| 326 |
ZBM_KERNEL=linux6.18 |
| 327 |
ZBM_EFI_SHA256=0a2a192de90be4935c21a810f9e5281f0cbf625a76b2d6e9125c39c13becfe83 |
| 328 |
|
| 329 |
install_zbm() { |
| 330 |
esp_boot="$TARGET/boot/efi/EFI/BOOT" |
| 331 |
run mkdir -p "$esp_boot" |
| 332 |
|
| 333 |
asset="zfsbootmenu-release-x86_64-${ZBM_VERSION}-${ZBM_KERNEL}.EFI" |
| 334 |
url="https://github.com/zbm-dev/zfsbootmenu/releases/download/${ZBM_VERSION}/${asset}" |
| 335 |
|
| 336 |
if [ "$DRY_RUN" = 1 ]; then |
| 337 |
echo "[dry-run] curl -fL $url" |
| 338 |
echo "[dry-run] verify sha256 == $ZBM_EFI_SHA256" |
| 339 |
echo "[dry-run] install to $esp_boot/BOOTX64.EFI" |
| 340 |
echo "[dry-run] efibootmgr --create --disk $DISK --part 1 --label Mountaineer --loader '\\EFI\\BOOT\\BOOTX64.EFI'" |
| 341 |
return |
| 342 |
fi |
| 343 |
|
| 344 |
tmp=$(mktemp -d) |
| 345 |
echo "==> downloading zfsbootmenu $ZBM_VERSION ($ZBM_KERNEL)" |
| 346 |
if ! curl -fLsS -o "$tmp/zbm.EFI" "$url"; then |
| 347 |
rm -rf "$tmp" |
| 348 |
die "failed to download $url — check network and ZBM_VERSION" |
| 349 |
fi |
| 350 |
|
| 351 |
actual=$(sha256sum "$tmp/zbm.EFI" | awk '{print $1}') |
| 352 |
if [ "$actual" != "$ZBM_EFI_SHA256" ]; then |
| 353 |
rm -rf "$tmp" |
| 354 |
die "ZBM checksum mismatch: expected $ZBM_EFI_SHA256, got $actual" |
| 355 |
fi |
| 356 |
|
| 357 |
cp "$tmp/zbm.EFI" "$esp_boot/BOOTX64.EFI" |
| 358 |
rm -rf "$tmp" |
| 359 |
|
| 360 |
|
| 361 |
|
| 362 |
|
| 363 |
|
| 364 |
|
| 365 |
|
| 366 |
|
| 367 |
|
| 368 |
|
| 369 |
if [ "$DRY_RUN" = 1 ]; then |
| 370 |
echo "[dry-run] zfs set org.zfsbootmenu:commandline='ro quiet console=tty0 console=ttyS0,115200' $ROOT_DATASET" |
| 371 |
else |
| 372 |
zfs set org.zfsbootmenu:commandline='ro quiet console=tty0 console=ttyS0,115200' "$ROOT_DATASET" |
| 373 |
fi |
| 374 |
|
| 375 |
|
| 376 |
|
| 377 |
|
| 378 |
efibootmgr --create --disk "$DISK" --part 1 \ |
| 379 |
--label "Mountaineer" --loader '\EFI\BOOT\BOOTX64.EFI' || \ |
| 380 |
echo "warning: efibootmgr could not add boot entry — relying on \\EFI\\BOOT\\BOOTX64.EFI fallback path" >&2 |
| 381 |
} |
| 382 |
|
| 383 |
|
| 384 |
|
| 385 |
|
| 386 |
bind_target_dev() { |
| 387 |
for d in dev proc sys; do |
| 388 |
run mkdir -p "$TARGET/$d" |
| 389 |
mountpoint -q "$TARGET/$d" 2>/dev/null && continue |
| 390 |
run mount --bind "/$d" "$TARGET/$d" |
| 391 |
done |
| 392 |
} |
| 393 |
|
| 394 |
unbind_target_dev() { |
| 395 |
for d in sys proc dev; do |
| 396 |
mountpoint -q "$TARGET/$d" 2>/dev/null && run umount "$TARGET/$d" |
| 397 |
done |
| 398 |
return 0 |
| 399 |
} |
| 400 |
|
| 401 |
|
| 402 |
generate_identity() { |
| 403 |
bind_target_dev |
| 404 |
run chroot "$TARGET" ssh-keygen -A |
| 405 |
if [ "$DRY_RUN" = 0 ]; then |
| 406 |
dd if=/dev/urandom bs=16 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' > "$TARGET/etc/machine-id" |
| 407 |
echo >> "$TARGET/etc/machine-id" |
| 408 |
fi |
| 409 |
} |
| 410 |
|
| 411 |
|
| 412 |
first_boot_prompts() { |
| 413 |
if [ "$INTERACTIVE" = 0 ]; then |
| 414 |
HOSTNAME=$(cfg_get hostname mountaineer) |
| 415 |
PUBKEY=$(cfg_get ssh_pubkey) |
| 416 |
ROOTPW=$(cfg_get root_password) |
| 417 |
echo "==> hostname: $HOSTNAME" |
| 418 |
else |
| 419 |
dialog --inputbox "Hostname:" 8 50 "mountaineer" 2>/tmp/hn |
| 420 |
HOSTNAME=$(cat /tmp/hn); rm -f /tmp/hn |
| 421 |
[ -n "$HOSTNAME" ] || HOSTNAME=mountaineer |
| 422 |
|
| 423 |
dialog --inputbox "Operator SSH public key (paste, or leave blank to add later):" 10 70 "" 2>/tmp/pk |
| 424 |
PUBKEY=$(cat /tmp/pk); rm -f /tmp/pk |
| 425 |
|
| 426 |
dialog --insecure --passwordbox "Console root password (sshd does not allow password login; this is for console only):" 9 60 2>/tmp/rp |
| 427 |
ROOTPW=$(cat /tmp/rp); rm -f /tmp/rp |
| 428 |
fi |
| 429 |
|
| 430 |
if [ "$DRY_RUN" = 0 ]; then |
| 431 |
echo "$HOSTNAME" > "$TARGET/etc/hostname" |
| 432 |
fi |
| 433 |
|
| 434 |
if [ -n "$PUBKEY" ] && [ "$DRY_RUN" = 0 ]; then |
| 435 |
mkdir -p "$TARGET/root/.ssh" |
| 436 |
chmod 700 "$TARGET/root/.ssh" |
| 437 |
|
| 438 |
|
| 439 |
|
| 440 |
|
| 441 |
echo "$PUBKEY" > "$TARGET/root/.ssh/authorized_keys" |
| 442 |
chmod 600 "$TARGET/root/.ssh/authorized_keys" |
| 443 |
fi |
| 444 |
|
| 445 |
if [ -n "$ROOTPW" ] && [ "$DRY_RUN" = 0 ]; then |
| 446 |
echo "root:$ROOTPW" | chroot "$TARGET" chpasswd |
| 447 |
fi |
| 448 |
|
| 449 |
|
| 450 |
|
| 451 |
|
| 452 |
if [ "$DRY_RUN" = 0 ]; then |
| 453 |
cat > "$TARGET/etc/network/interfaces" <<'EOF' |
| 454 |
# Mountaineer: DHCP on every non-loopback interface by default. |
| 455 |
# Per-interface overrides go in /etc/network/interfaces.d/. |
| 456 |
|
| 457 |
auto lo |
| 458 |
iface lo inet loopback |
| 459 |
|
| 460 |
auto eth0 |
| 461 |
iface eth0 inet dhcp |
| 462 |
EOF |
| 463 |
fi |
| 464 |
} |
| 465 |
|
| 466 |
|
| 467 |
compile_s6rc() { |
| 468 |
run chroot "$TARGET" s6-rc-compile /etc/s6-rc/compiled /etc/s6-rc/source |
| 469 |
} |
| 470 |
|
| 471 |
|
| 472 |
summary() { |
| 473 |
cat <<EOF |
| 474 |
==> Install summary |
| 475 |
Disk: $DISK |
| 476 |
Encrypted: $([ "$ENCRYPT" = 1 ] && echo yes || echo no) |
| 477 |
Hostname: $HOSTNAME |
| 478 |
SSH key: $([ -n "$PUBKEY" ] && echo set || echo NOT SET) |
| 479 |
ARC max: $ARC_MAX bytes |
| 480 |
EOF |
| 481 |
if [ "$INTERACTIVE" = 1 ]; then |
| 482 |
dialog --yesno "Proceed with finalize + reboot?" 7 50 || die "install cancelled at confirm step" |
| 483 |
fi |
| 484 |
} |
| 485 |
|
| 486 |
|
| 487 |
finalize() { |
| 488 |
unbind_target_dev |
| 489 |
run umount "$TARGET/boot/efi" |
| 490 |
run zfs umount -a |
| 491 |
run zpool export rpool |
| 492 |
run reboot |
| 493 |
} |
| 494 |
|
| 495 |
|
| 496 |
require_uefi |
| 497 |
pick_disk |
| 498 |
ask_encryption |
| 499 |
partition |
| 500 |
create_pool |
| 501 |
create_datasets |
| 502 |
format_esp |
| 503 |
compute_arc_max |
| 504 |
install_base |
| 505 |
unpack_apkovl |
| 506 |
write_arc_conf |
| 507 |
install_zbm |
| 508 |
generate_identity |
| 509 |
first_boot_prompts |
| 510 |
compile_s6rc |
| 511 |
summary |
| 512 |
finalize |
| 513 |
|