diff --git a/core/archipelago/src/api/rpc/auth.rs b/core/archipelago/src/api/rpc/auth.rs index 0f05be96..d34d9702 100644 --- a/core/archipelago/src/api/rpc/auth.rs +++ b/core/archipelago/src/api/rpc/auth.rs @@ -33,7 +33,21 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing password"))?; - let is_setup = self.auth_manager.is_setup().await?; + let mut is_setup = self.auth_manager.is_setup().await?; + if !is_setup && self.auth_manager.installer_password_pending() { + anyhow::ensure!( + crate::identity::fips_key_exists(&self.config.data_dir.join("identity")), + "Not supported: complete identity onboarding before login" + ); + self.auth_manager.claim_installer_password(password).await?; + if let Err(error) = + super::seed_rpc::save_pending_seed_encrypted(&self.config.data_dir, password).await + { + tracing::warn!(%error, "installer seed backup could not be saved"); + } + tracing::info!("installer dashboard account claimed"); + is_setup = true; + } if !is_setup { // Dev BUILDS only: allow the default password so the UI can log // in without running setup. cfg-gated so no release binary can @@ -227,6 +241,10 @@ impl RpcHandler { &self, params: Option, ) -> Result { + anyhow::ensure!( + !self.auth_manager.installer_password_pending(), + "Not supported: use the password chosen in the ISO installer to log in" + ); // Prevent re-setup if already set up. Kept ahead of the gate so the // existing, more specific message survives for this common case. let is_setup = self.auth_manager.is_setup().await?; diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 542fad58..e055a3ff 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -112,6 +112,7 @@ impl RpcHandler { .await }, "auth.isSetup" => self.handle_auth_is_setup().await, + "auth.isInstallerPasswordPending" => Ok(serde_json::json!(self.auth_manager.installer_password_pending())), "auth.setup" => self.handle_auth_setup(params).await, "auth.onboardingComplete" => self.handle_auth_onboarding_complete().await, "auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await, diff --git a/core/archipelago/src/api/rpc/middleware.rs b/core/archipelago/src/api/rpc/middleware.rs index fd0157e5..4174f60f 100644 --- a/core/archipelago/src/api/rpc/middleware.rs +++ b/core/archipelago/src/api/rpc/middleware.rs @@ -19,6 +19,7 @@ pub(crate) const UNAUTHENTICATED_METHODS: &[&str] = &[ "auth.login.signer.finish", "auth.isOnboardingComplete", "auth.isSetup", + "auth.isInstallerPasswordPending", "auth.setup", "auth.onboardingComplete", "health", diff --git a/core/archipelago/src/auth.rs b/core/archipelago/src/auth.rs index 93bf7003..d8898eb0 100644 --- a/core/archipelago/src/auth.rs +++ b/core/archipelago/src/auth.rs @@ -126,6 +126,47 @@ impl AuthManager { Self { data_dir } } + /// Installer handoff: a verifier exists before the web account is claimed. + /// It never contains the password or grants a session on its own. + pub fn installer_password_pending(&self) -> bool { + self.data_dir.join("installer-password.json").is_file() + } + + pub fn stage_installer_password(&self, password: &str) -> Result<()> { + anyhow::ensure!( + password.len() >= 12, + "Password must be at least 12 characters" + ); + anyhow::ensure!(!self.data_dir.join("user.json").exists(), "Already set up"); + let path = self.data_dir.join("installer-password.json"); + let hash = argon2id_hash(password)?; + use std::io::Write; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(hash.as_bytes())?; + file.sync_all()?; + Ok(()) + } + + pub async fn claim_installer_password(&self, password: &str) -> Result<()> { + anyhow::ensure!( + self.installer_password_pending(), + "Not supported: no installer password is pending" + ); + let path = self.data_dir.join("installer-password.json"); + let hash = fs::read_to_string(&path).await?; + anyhow::ensure!(argon2id_verify(password, &hash), "Password is incorrect"); + self.setup_user(password).await?; + fs::remove_file(path).await?; + Ok(()) + } + pub async fn is_setup(&self) -> Result { let user_file = self.data_dir.join("user.json"); Ok(user_file.exists()) @@ -561,8 +602,10 @@ pub(crate) async fn change_ssh_password(new_password: &str) -> Result<()> { !new_password.bytes().any(|b| matches!(b, 0 | b'\n' | b'\r')), "SSH passwords cannot contain line breaks or NUL" ); - let ssh_user = - std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string()); + let ssh_user = match std::env::var("ARCHIPELAGO_SSH_USER") { + Ok(user) => user, + Err(_) => crate::host_access::login::selected_login_account()?, + }; // Generate crypt hash via openssl (SHA-512, compatible with /etc/shadow) // Use /usr/bin/openssl - systemd services often have minimal PATH @@ -653,6 +696,29 @@ fn argon2id_verify(password: &str, hash: &str) -> bool { mod tests { use super::*; + #[tokio::test] + async fn installer_password_claim_requires_the_chosen_password_once() { + let dir = tempfile::tempdir().unwrap(); + let auth = AuthManager::new(dir.path().into()); + auth.stage_installer_password("Unique-Owner-123!").unwrap(); + assert!(auth.installer_password_pending()); + assert!(!auth.is_setup().await.unwrap()); + assert!(auth + .claim_installer_password("wrong-password") + .await + .is_err()); + assert!(!auth.is_setup().await.unwrap()); + auth.claim_installer_password("Unique-Owner-123!") + .await + .unwrap(); + assert!(!auth.installer_password_pending()); + assert!(auth.verify_password("Unique-Owner-123!").await.unwrap()); + assert!(auth + .claim_installer_password("Unique-Owner-123!") + .await + .is_err()); + } + /// An already-open descriptor must keep the complete old account after /// replacement. A truncate/write update would modify these same bytes, /// reproducing what concurrent password/TOTP readers could observe. diff --git a/core/archipelago/src/host_capabilities.rs b/core/archipelago/src/host_capabilities.rs index 5df43bc2..e1d5b383 100644 --- a/core/archipelago/src/host_capabilities.rs +++ b/core/archipelago/src/host_capabilities.rs @@ -553,7 +553,7 @@ pub(super) fn set_login_password( end: Duration, ) -> Result> { ensure!(now()? < end, "login password update expired"); - let input = hash.input(); + let input = hash.input()?; run_with_input( executable(Program::Chpasswd)?, vec!["--encrypted".into()], diff --git a/core/archipelago/src/host_login.rs b/core/archipelago/src/host_login.rs index 18ecd3c0..036969ea 100644 --- a/core/archipelago/src/host_login.rs +++ b/core/archipelago/src/host_login.rs @@ -1,4 +1,4 @@ -//! The host owner may update only the fixed interactive account. A password +//! The host owner may update only the image's recorded interactive account. A password //! hash is data sent over stdin, never an account name, argument or shell text. use anyhow::{ensure, Result}; use serde::{Deserialize, Serialize}; @@ -45,11 +45,49 @@ impl Drop for PasswordHash { } impl PasswordHash { - pub(super) fn input(&self) -> Zeroizing { - Zeroizing::new(format!("archipelago:{}\n", self.0)) + pub(super) fn input(&self) -> Result> { + Ok(Zeroizing::new(format!( + "{}:{}\n", + selected_login_account()?, + self.0 + ))) } } +pub(crate) fn selected_login_account() -> Result { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let path = std::path::Path::new("/etc/archipelago/owner-account"); + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok("archipelago".into()) + } + Err(error) => return Err(error.into()), + }; + ensure!( + metadata.is_file() + && !metadata.file_type().is_symlink() + && metadata.uid() == 0 + && metadata.permissions().mode() & 0o022 == 0 + && metadata.len() <= 33, + "invalid installed owner account record" + ); + let name = std::fs::read_to_string(path)?; + let name = name.trim_end_matches('\n'); + ensure!( + (2..=31).contains(&name.len()) + && name.starts_with(|c: char| c.is_ascii_lowercase()) + && name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') + && name != "root" + && name != "archipelago" + && name != "nobody", + "invalid installed owner account name" + ); + Ok(name.into()) +} + #[cfg(test)] mod tests { use super::*; @@ -58,7 +96,10 @@ mod tests { fn login_password_hash_cannot_select_an_account_or_inject_records() { let hash = format!("$6$randomsalt${}", "a".repeat(86)); let parsed = PasswordHash::try_from(hash.clone()).unwrap(); - assert_eq!(parsed.input().as_str(), format!("archipelago:{hash}\n")); + assert_eq!( + parsed.input().unwrap().as_str(), + format!("archipelago:{hash}\n") + ); for invalid in [ "".into(), "!".into(), diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index f51866c3..91393fca 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -146,6 +146,20 @@ use container::{ use server::Server; fn main() -> Result<()> { + if std::env::args().nth(1).as_deref() == Some("stage-installer-password-v1") { + anyhow::ensure!( + std::env::args().count() == 3, + "expected target data directory" + ); + use std::io::Read; + use zeroize::Zeroizing; + let mut password = Zeroizing::new(String::new()); + std::io::stdin().read_to_string(&mut password)?; + anyhow::ensure!(password.ends_with('\n'), "password input incomplete"); + password.pop(); + return auth::AuthManager::new(std::env::args().nth(2).unwrap().into()) + .stage_installer_password(&password); + } if std::env::args().nth(1).as_deref() == Some("migrate-login-v1") { return c01_supervisor::migrate_default_login(); } diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index ad78ca37..14fc0981 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -829,21 +829,47 @@ SYSTEMDSERVICE $CONTAINER_CMD rm archipelago-rootfs-tmp # Apply offline policy after all package/service build operations. POLICY_ROOT=$(mktemp -d "$WORK_DIR/policy-root.XXXXXX") - tar --numeric-owner -xf "$ROOTFS_TAR" -C "$POLICY_ROOT" - mkdir -p "$POLICY_ROOT/usr/lib/archipelago/host-policy" - cp -a "$INPUT_STAGE/source/image-recipe/host-policy/." "$POLICY_ROOT/usr/lib/archipelago/host-policy/" - python3 "$INPUT_STAGE/source/image-recipe/host-policy/provision.py" --root "$POLICY_ROOT" - chmod 755 "$POLICY_ROOT" - /usr/sbin/chroot "$POLICY_ROOT" /usr/sbin/runuser -u archipelago -- /bin/bash -c true - tar --numeric-owner -cf "$ROOTFS_TAR" -C "$POLICY_ROOT" . - rm -rf "$POLICY_ROOT" - + if [ "$EUID" -eq 0 ]; then + tar --numeric-owner -xf "$ROOTFS_TAR" -C "$POLICY_ROOT" + # mktemp creates the extraction directory as 0700. Tar exports that + # directory as /, so a nonroot service cannot execute /bin/bash. + chmod 755 "$POLICY_ROOT" + /usr/sbin/chroot "$POLICY_ROOT" /usr/sbin/runuser -u archipelago -- /bin/bash -c true + mkdir -p "$POLICY_ROOT/usr/lib/archipelago/host-policy" + cp -a "$INPUT_STAGE/source/image-recipe/host-policy/." "$POLICY_ROOT/usr/lib/archipelago/host-policy/" + python3 "$INPUT_STAGE/source/image-recipe/host-policy/provision.py" --root "$POLICY_ROOT" + tar --numeric-owner -cf "$ROOTFS_TAR" -C "$POLICY_ROOT" . + rm -rf "$POLICY_ROOT" + elif [ "$CONTAINER_CMD" = podman ]; then + # Extract and repack inside the rootless user namespace so tar retains + # root and service UIDs. Running only provision.py there would make the + # installed image's files belong to the build host's user instead. + $CONTAINER_CMD unshare bash -euo pipefail -c ' + root=$1; archive=$2; policy=$3 + tar --numeric-owner --same-owner -xf "$archive" -C "$root" + chmod 755 "$root" + test "$(stat -c %u "$root/etc")" = 0 + test "$(stat -c %u "$root/home/archipelago")" = 1000 + test "$(stat -c %a "$root")" = 755 + /usr/sbin/chroot "$root" /usr/sbin/runuser -u archipelago -- /bin/bash -c true + mkdir -p "$root/usr/lib/archipelago/host-policy" + cp -a "$policy/." "$root/usr/lib/archipelago/host-policy/" + python3 "$policy/provision.py" --root "$root" + tar --numeric-owner -cf "$archive" -C "$root" . + rm -rf "$root" + ' _ "$POLICY_ROOT" "$ROOTFS_TAR" "$INPUT_STAGE/source/image-recipe/host-policy" + else + echo 'Offline image policy needs root or isolated rootless Podman' >&2 + exit 1 + fi sha256sum "$ROOTFS_TAR" | cut -d' ' -f1 > "$ROOTFS_DIGEST" echo "$RECIPE_HASH" > "$ROOTFS_STAMP" echo "✅ Root filesystem created: $(du -h "$ROOTFS_TAR" | cut -f1)" else echo "✅ Using cached root filesystem: $(du -h "$ROOTFS_TAR" | cut -f1)" fi +# A service-user execution failure here would otherwise surface only after +# the operator has partitioned and formatted the install target. if [ "$(tar --occurrence=1 -tvf "$ROOTFS_TAR" . | awk 'NR == 1 {print $1}')" != drwxr-xr-x ]; then echo 'Root filesystem archive root must be mode 0755' >&2 exit 1 @@ -909,7 +935,7 @@ kmod,procps,iproute2,ca-certificates,gdisk,python3,\ cryptsetup,cryptsetup-initramfs,parted,dosfstools,e2fsprogs,\ ${KERNEL_INCLUDE}grub-efi-${DEB_ARCH},grub-efi-${DEB_ARCH}-signed,shim-signed,shim-helpers-${DEB_ARCH}-signed,sbsigntool,grub-pc-bin,\ ifupdown,isc-dhcp-client,\ -wpasupplicant,wireless-regdb,iw,rfkill,\ +network-manager,wpasupplicant,wireless-regdb,iw,rfkill,\ pciutils,usbutils,less,nano \ trixie /installer http://snapshot.debian.org/archive/debian/20261109T224401Z/ @@ -955,6 +981,7 @@ echo " [container] Configuring installer environment..." # Set hostname echo "archipelago-installer" > /installer/etc/hostname +chroot /installer systemctl enable NetworkManager.service # The installer console uses physical autologin, never a shared root password. chroot /installer passwd --lock root @@ -1187,16 +1214,7 @@ if [ -d "$WORK_DIR/grub-fonts" ]; then # Also copy unicode font for GRUB to load cp "$WORK_DIR/grub-fonts/dejavu_16.pf2" "$INSTALLER_ISO/boot/grub/font.pf2" fi -# Copy GRUB background image (static asset or generate if missing) -GRUB_BG="$SCRIPT_DIR/branding/grub-theme/background.png" -if [ -f "$GRUB_BG" ]; then - cp "$GRUB_BG" "$THEME_DST/background.png" - echo " Installed GRUB background" -elif [ -f "$SCRIPT_DIR/branding/generate-grub-background.py" ]; then - echo " Generating GRUB background..." - python3 "$SCRIPT_DIR/branding/generate-grub-background.py" "$THEME_DST/background.png" 2>/dev/null || \ - echo " WARNING: Could not generate GRUB background" -fi +# No bitmap backdrop: the theme uses its solid desktop color on all screens. echo " Installer squashfs: $(du -h "$INSTALLER_ISO/live/filesystem.squashfs" | cut -f1)" echo " Kernel: $(du -h "$INSTALLER_ISO/live/vmlinuz" | cut -f1)" @@ -2248,7 +2266,7 @@ cat > "$ARCH_DIR/auto-install.sh" <<'INSTALLER_SCRIPT' # Automatically installs to internal disk (StartOS-like experience) # -set -e +set -eE # Log file — verbose command output goes here, TUI stays on console INSTALL_LOG="/tmp/archipelago-install.log" @@ -2257,6 +2275,29 @@ INSTALL_LOG="/tmp/archipelago-install.log" run() { "$@" >> "$INSTALL_LOG" 2>&1; } runq() { "$@" >>"$INSTALL_LOG" 2>&1 || true; } +# The live login shell resumes when this script exits. Keep an installation +# failure on a clear recovery screen and retain the command output on disk. +installer_abort() { + local status="$1" line="$2" command="$3" + trap - ERR + set +e + printf '\n Installation stopped (exit %s, line %s).\n' "$status" "$line" + printf ' Command: %s\n\n' "$command" + if [ -s "$INSTALL_LOG" ]; then + tail -n 30 "$INSTALL_LOG" + fi + if mountpoint -q /mnt/target && [ -d /mnt/target/var/log ] && [ -f "$INSTALL_LOG" ]; then + install -m 600 "$INSTALL_LOG" /mnt/target/var/log/archipelago-install.log + printf '\n Full log saved on the installed disk.\n' + fi + printf '\n Leave the installer USB inserted. Press Enter to restart it.\n' + read -r _ < /dev/tty + /sbin/reboot -f + printf ' Restart failed; hold the power button to turn off this machine.\n' + while :; do sleep 3600; done +} +trap 'installer_abort "$?" "$LINENO" "$BASH_COMMAND"' ERR + # Detect architecture at install time case "$(uname -m)" in x86_64|amd64) @@ -2304,7 +2345,7 @@ typewrite() { # Phase display STEP=0 -TOTAL_STEPS=8 +TOTAL_STEPS=9 step() { STEP=$((STEP + 1)) echo "" @@ -2388,6 +2429,14 @@ if [ ! -f "$ROOTFS_TAR" ]; then exit 1 fi +OWNER_HELPER="$BOOT_MEDIA/archipelago/scripts/lib/installer-owner.sh" +if [ ! -f "$OWNER_HELPER" ]; then + fail "Owner setup is missing from the installer media" + exit 1 +fi +source "$OWNER_HELPER" +installer_collect_owner || { fail "Owner setup was not completed"; exit 1; } + # Find the boot USB device to exclude it BOOT_DEV=$(findmnt -n -o SOURCE "$BOOT_MEDIA" 2>/dev/null | sed 's/[0-9]*$//' | sed 's/p[0-9]*$//') BOOT_DEV_NAME=$(basename "$BOOT_DEV" 2>/dev/null || echo "") @@ -2503,7 +2552,14 @@ mount "$EFI_PART" /mnt/target/boot/efi step "Installing system" run tar -xf "$ROOTFS_TAR" -C /mnt/target +# Tar may restore the archive's top-level mode onto the mounted root. Keep / +# traversable for the fixed service account before any runuser handoff. run chmod 755 /mnt/target +# The encrypted data mount covers /var/lib/archipelago. Preserve the signed +# kernel package receipt from the rootfs before that mount hides it; the +# installer and first boot both verify this exact receipt against the kernel. +KERNEL_RECEIPT_STAGE=$(mktemp -d /tmp/archipelago-kernel-receipts.XXXXXX) +run cp -a /mnt/target/var/lib/archipelago/kernel-security/. "$KERNEL_RECEIPT_STAGE/" # LUKS2 encryption for data partition step "Encrypting data partition" @@ -2521,13 +2577,13 @@ mount --bind /dev /mnt/target/dev mount --bind /proc /mnt/target/proc mount --bind /sys /mnt/target/sys -# Detect AES-NI support for cipher selection +# AES-XTS is available with or without AES-NI. The old non-AES fallback +# selected Adiantum, which this kernel does not support on some machines. +LUKS_CIPHER="aes-xts-plain64" if grep -q aes /proc/cpuinfo 2>/dev/null; then - LUKS_CIPHER="aes-xts-plain64" echo " AES-NI detected — using AES-256-XTS" else - LUKS_CIPHER="xchacha20,aes-adiantum-plain64" - echo " No AES-NI — using ChaCha20-Adiantum" + echo " No AES-NI — using AES-256-XTS in software" fi # Format LUKS2 partition with key file @@ -2563,6 +2619,11 @@ if [ -f /mnt/target/etc/archipelago/nostr-relay-config.toml ]; then cp /mnt/target/etc/archipelago/nostr-relay-config.toml /mnt/target/var/lib/archipelago/nostr-relay/config.toml fi chown -R 1000:1000 /mnt/target/var/lib/archipelago +run install -d -m 0700 -o root -g root /mnt/target/var/lib/archipelago/kernel-security +run cp -a "$KERNEL_RECEIPT_STAGE/." /mnt/target/var/lib/archipelago/kernel-security/ +run chown -R root:root /mnt/target/var/lib/archipelago/kernel-security +run chmod 0700 /mnt/target/var/lib/archipelago/kernel-security +run rm -r "$KERNEL_RECEIPT_STAGE" echo " ✅ Data partition encrypted with LUKS2 ($LUKS_CIPHER)" @@ -2653,14 +2714,7 @@ UUID=$(blkid -s UUID -o value "$EFI_PART") /boot/efi vfat umask=00 /var/lib/archipelago/swapfile none swap sw,nofail 0 0 EOF -# Configure hostname -echo "archipelago" > /mnt/target/etc/hostname -cat > /mnt/target/etc/hosts </dev/null 2>&1; then echo -e " ${W} http://$(hostname).local${N}" fi - echo -e " ${W}ssh archipelago@$IP${N}" - echo -e " ${OD}create your password in the web UI before using password-based SSH${N}" + OWNER=$(cat /etc/archipelago/owner-account 2>/dev/null || true) + if [ -n "$OWNER" ]; then + echo -e " ${OD}ssh ${OWNER}@$IP (key only, after adding your key)${N}" + fi else echo -e " ${OD}Waiting for network...${N}" fi @@ -2974,7 +3030,7 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then if systemctl is-active archipelago-kiosk.service >/dev/null 2>&1; then echo -e " ${OD}display Kiosk active (Ctrl+Alt+F2 for terminal)${N}" else - echo -e " ${OD}display Console (Ctrl+Alt+F1 for kiosk)${N}" + echo -e " ${OD}display Console (kiosk is not running)${N}" fi echo "" fi @@ -3271,12 +3327,17 @@ mount --bind /proc /mnt/target/proc mount --bind /sys /mnt/target/sys mount --bind /run /mnt/target/run -# Retain the image's locked accounts. auth.setup assigns the owner's chosen -# password; installation and later upgrades must never restore a shared one. +installer_install_owner /mnt/target || { fail "Owner account setup failed"; exit 1; } + +# Retain the image's locked service/root accounts. The installer creates a +# separate named human owner; no reusable image credential is unlocked here. if ! awk -F: '($1 == "archipelago" || $1 == "root") { seen++; if ($2 !~ /^[!*]/) bad=1 } END { exit (seen != 2 || bad) }' /mnt/target/etc/shadow; then fail "Fresh image contains an unexpected login credential; refusing installation" fi -echo " Password logins remain locked until owner setup" +if ! awk -F: -v owner="$INSTALLER_OWNER_USER" '$1 == owner { seen++; if ($2 == "" || $2 ~ /^[!*]/) bad=1 } END { exit (seen != 1 || bad) }' /mnt/target/etc/shadow; then + fail "Owner login password was not installed; refusing installation" +fi +echo " Service and root logins remain locked; the chosen owner account is ready" # Stage the authenticated signed UEFI chain after update-grub below. Never # purge shim or replace the firmware entry with a locally generated unsigned @@ -3589,13 +3650,9 @@ cat > /mnt/target/etc/systemd/system/tmp.mount.d/override.conf <<'TMPSIZE' Options=mode=1777,strictatime,nosuid,nodev,size=75%,nr_inodes=1m TMPSIZE -# Auto-login on tty1 — no password prompt on console -mkdir -p /mnt/target/etc/systemd/system/getty@tty1.service.d -cat > /mnt/target/etc/systemd/system/getty@tty1.service.d/autologin.conf <<'AUTOLOGIN' -[Service] -ExecStart= -ExecStart=-/sbin/agetty --autologin archipelago --noclear %I $TERM -AUTOLOGIN +# A human console must ask for the owner account. The fixed service account +# has no password and must never be selected by an agetty auto-login override. +rm -f /mnt/target/etc/systemd/system/getty@tty1.service.d/autologin.conf chroot /mnt/target systemctl enable archipelago-diagnostics.service 2>/dev/null || true # Post-install smoke test — runs Phase 1 (install verification) only on first boot @@ -3749,9 +3806,9 @@ p "${ORANGE_DIM} After reboot, access from any device:${NC}" echo "" p "${ORANGE} http://${NC}" echo "" -p "${WHITE} SSH ssh archipelago@${NC}" -p "${WHITE} Web Login create your password on first visit${NC}" -p "${WHITE} SSH use your chosen password after setup${NC}" +p "${WHITE} Console $INSTALLER_OWNER_USER (the password you chose)${NC}" +p "${WHITE} Dashboard complete onboarding, then log in with the same password${NC}" +p "${WHITE} SSH key-only after explicit key setup${NC}" echo "" hrule echo "" @@ -3860,8 +3917,11 @@ if loadfont ($root)/boot/grub/font.pf2; then set gfxmode=auto set gfxpayload=keep insmod gfxterm + insmod gfxmenu insmod png - terminal_output gfxterm serial + # A serial output alongside gfxterm selects GRUB's plain text menu. + # Keep serial input for headless diagnostics, but render only to gfxterm. + terminal_output gfxterm else terminal_output console serial fi @@ -3900,16 +3960,11 @@ GRUBCFG # Create ISOLINUX configuration (legacy BIOS boot) echo " Writing ISOLINUX config..." -# Copy background image for ISOLINUX graphical menu -ISOLINUX_BG="$SCRIPT_DIR/branding/grub-theme/background.png" -if [ -f "$ISOLINUX_BG" ]; then - cp "$ISOLINUX_BG" "$INSTALLER_ISO/isolinux/splash.png" -fi - -# Copy vesamenu.c32 for graphical menu (with background support) +# Copy vesamenu.c32 for the legacy BIOS menu. if [ -f "$WORK_DIR/vesamenu.c32" ]; then cp "$WORK_DIR/vesamenu.c32" "$INSTALLER_ISO/isolinux/vesamenu.c32" fi +cp "$SCRIPT_DIR/branding/isolinux-background.png" "$INSTALLER_ISO/isolinux/background.png" cat > "$INSTALLER_ISO/isolinux/isolinux.cfg" <<'ISOCFG' UI vesamenu.c32 @@ -3917,21 +3972,20 @@ PROMPT 0 TIMEOUT 0 MENU TITLE -MENU BACKGROUND splash.png MENU RESOLUTION 1024 768 -MENU VSHIFT 20 -MENU HSHIFT 6 -MENU WIDTH 68 -MENU MARGIN 2 +MENU BACKGROUND background.png +MENU VSHIFT 22 +MENU HSHIFT 8 +MENU WIDTH 62 +MENU MARGIN 0 MENU ROWS 5 -MENU TABMSG press tab to edit | archipelago.sh -MENU COLOR screen 37;40 #00000000 #00000000 none -MENU COLOR border 30;40 #00000000 #00000000 none -MENU COLOR title 1;37;40 #80888888 #00000000 none -MENU COLOR sel 7;37;40 #ffffffff #c0181818 std -MENU COLOR unsel 37;40 #ffaaaaaa #00000000 none -MENU COLOR hotkey 1;37;40 #fffb923c #00000000 none -MENU COLOR hotsel 1;37;40 #fffb923c #c0181818 std +MENU TABMSG arrows to select | enter to boot +MENU COLOR border 37;40 #00000000 #00000000 none +MENU COLOR title 1;37;40 #00000000 #00000000 none +MENU COLOR sel 1;37;40 #fffb923c #ff202027 std +MENU COLOR unsel 37;40 #ffb6b5b8 #00000000 std +MENU COLOR hotkey 1;37;40 #fffb923c #00000000 std +MENU COLOR hotsel 1;37;40 #fffb923c #ff202027 std MENU COLOR timeout_msg 37;40 #ff555555 #00000000 none MENU COLOR timeout 1;37;40 #fffb923c #00000000 none MENU COLOR tabmsg 37;40 #ff444444 #00000000 none diff --git a/image-recipe/branding/grub-theme/theme.txt b/image-recipe/branding/grub-theme/theme.txt index be5029d4..04f9470a 100644 --- a/image-recipe/branding/grub-theme/theme.txt +++ b/image-recipe/branding/grub-theme/theme.txt @@ -1,52 +1,69 @@ -# Archipelago GRUB Theme -# Dark background with Bitcoin orange accents +# Match the installer console: left-aligned Archipelago mark and choices. title-text: "" -desktop-color: "#0a0a0a" -desktop-image: "background.png" -desktop-image-scale-method: "stretch" +desktop-color: "#0b0d12" + boot_menu { - left = 10% - top = 35% - width = 80% - height = 40% - item_font = "DejaVu Sans Bold 16" - item_color = "#aaaaaa" - selected_item_font = "DejaVu Sans Bold 16" - selected_item_color = "#f7931a" - item_height = 36 - item_spacing = 8 - item_padding = 16 + left = 8% + top = 46% + width = 84% + height = 24% + item_font = "DejaVu Sans Mono Bold 16" + item_color = "#bbb5b0" + selected_item_font = "DejaVu Sans Mono Bold 16" + selected_item_color = "#fb923c" + item_height = 40 + item_spacing = 10 + item_padding = 0 scrollbar = false } + label { - left = 10% - top = 18% - width = 80% + left = 8% + top = 17% + width = 84% font = "DejaVu Sans Mono Bold 24" - text = "a r c h i p e l a g o" - color = "#f7931a" - align = "center" + text = "▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█" + color = "#fb923c" + align = "left" } + label { - left = 10% - top = 26% - width = 80% - font = "DejaVu Sans Bold 14" - text = "bitcoin node os" - color = "#888888" - align = "center" + left = 8% + top = 22% + width = 84% + font = "DejaVu Sans Mono Bold 24" + text = "█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █" + color = "#fb923c" + align = "left" } + label { - left = 10% - top = 90% - width = 80% - font = "DejaVu Sans Bold 12" - text = "press tab to edit | use arrow keys to select | enter to boot" - color = "#555555" - align = "center" + left = 8% + top = 27% + width = 84% + font = "DejaVu Sans Mono Bold 24" + text = "▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀" + color = "#fb923c" + align = "left" +} + ++ label { + left = 8% + top = 35% + width = 84% + font = "DejaVu Sans Bold 16" + text = "YOUR NODE. YOUR KEYS. YOUR WAY." + color = "#b6b5b8" + align = "left" +} + ++ label { + left = 8% + top = 84% + width = 84% + font = "DejaVu Sans Mono Bold 12" + text = "arrow keys to select · enter to boot" + color = "#777477" + align = "left" } diff --git a/image-recipe/branding/isolinux-ascii.txt b/image-recipe/branding/isolinux-ascii.txt new file mode 100644 index 00000000..3fe201cd --- /dev/null +++ b/image-recipe/branding/isolinux-ascii.txt @@ -0,0 +1,3 @@ +▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█ +█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █ +▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀ diff --git a/image-recipe/branding/isolinux-background.png b/image-recipe/branding/isolinux-background.png new file mode 100644 index 00000000..9af89b2f Binary files /dev/null and b/image-recipe/branding/isolinux-background.png differ diff --git a/image-recipe/branding/isolinux-tagline.txt b/image-recipe/branding/isolinux-tagline.txt new file mode 100644 index 00000000..1e8efa80 --- /dev/null +++ b/image-recipe/branding/isolinux-tagline.txt @@ -0,0 +1 @@ +YOUR NODE. YOUR KEYS. YOUR WAY. diff --git a/image-recipe/host-policy/provision.py b/image-recipe/host-policy/provision.py index f937c36e..04d740aa 100644 --- a/image-recipe/host-policy/provision.py +++ b/image-recipe/host-policy/provision.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """Provision only an offline Debian image root; never invoke APT/nft/systemctl.""" import argparse +import hashlib import json import os from pathlib import Path import stat import sys +import tempfile sys.path.insert(0, str(Path(__file__).resolve().parent)) import firewall @@ -19,6 +21,10 @@ DEPENDENTS = ('NetworkManager.service', 'systemd-networkd.service', 'networking. 'ssh.socket', 'sshd.socket', 'podman.socket', 'docker.socket', 'nostr-relay.service', 'archipelago-first-boot-containers.service') BUILD_MARKER = b'# Archipelago image build sources\n' +# Exact /etc/fips/fips.nft shipped by the reviewed fips 0.4.1-1 .deb. +# The image policy takes ownership of this path; every other existing byte +# sequence remains a foreign asset and is refused before any writes. +FIPS_PACKAGE_POLICY_SHA256 = '36c4a24dd633a583d57c17fd236ccfc053366968ade01e9e89702c9bc48cad4b' def checked_path(root, relative): @@ -94,14 +100,21 @@ Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg links['etc/systemd/system/multi-user.target.wants/archipelago-host-firewall.service'] = '../archipelago-host-firewall.service' links['etc/systemd/system/multi-user.target.wants/archipelago-app-profiles.service'] = '../archipelago-app-profiles.service' # Validate every destination before writing anything; no foreign overwrite. + replace_package_policy = False for name, contents in files.items(): path = checked_path(root, name) if path.exists() or path.is_symlink(): metadata = path.lstat() if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 - or metadata.st_uid != os.geteuid() or metadata.st_mode & 0o022 - or path.read_bytes() != contents): + or metadata.st_uid != os.geteuid() or metadata.st_mode & 0o022): raise ValueError('different existing image asset preserved: ' + name) + existing = path.read_bytes() + if existing != contents: + if (name == 'etc/fips/fips.nft' and + hashlib.sha256(existing).hexdigest() == FIPS_PACKAGE_POLICY_SHA256): + replace_package_policy = True + else: + raise ValueError('different existing image asset preserved: ' + name) for name, target in links.items(): path = checked_path(root, name) if (path.exists() or path.is_symlink()) and (not path.is_symlink() or os.readlink(path) != target): @@ -134,7 +147,17 @@ Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg for name, contents in files.items(): path = root / name path.parent.mkdir(parents=True, exist_ok=True, mode=0o755) - if not path.exists(): + if name == 'etc/fips/fips.nft' and replace_package_policy: + if hashlib.sha256(path.read_bytes()).hexdigest() != FIPS_PACKAGE_POLICY_SHA256: + raise ValueError('FIPS package policy changed during provisioning') + with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as staged: + staged.write(contents) + staged.flush() + os.fsync(staged.fileno()) + staged_path = Path(staged.name) + staged_path.chmod(0o644) + os.replace(staged_path, path) + elif not path.exists(): with path.open('xb') as target: target.write(contents) path.chmod(0o755 if name.startswith('usr/libexec/') else 0o644) diff --git a/image-recipe/host-policy/test_policy.py b/image-recipe/host-policy/test_policy.py index bcc77641..4e4c8eb3 100644 --- a/image-recipe/host-policy/test_policy.py +++ b/image-recipe/host-policy/test_policy.py @@ -61,6 +61,15 @@ class ProvisionTests(unittest.TestCase): self.assertEqual(os.readlink(mask), '/operator/unit') self.assertFalse((self.root / 'etc/archipelago').exists()) + def test_foreign_fips_policy_preserved(self): + policy = self.root / 'etc/fips/fips.nft' + policy.parent.mkdir(parents=True) + policy.write_text('operator policy\n') + with self.assertRaisesRegex(ValueError, 'different existing image asset preserved'): + p.provision(self.root, HERE) + self.assertEqual(policy.read_text(), 'operator policy\n') + self.assertFalse((self.root / 'etc/archipelago').exists()) + def test_keyring_escape_refused(self): keyring = self.root / 'usr/share/keyrings/debian-archive-keyring.gpg' keyring.unlink(); keyring.symlink_to('/etc/passwd') diff --git a/neode-ui/src/views/Login.vue b/neode-ui/src/views/Login.vue index 5a459067..a0108ade 100644 --- a/neode-ui/src/views/Login.vue +++ b/neode-ui/src/views/Login.vue @@ -184,6 +184,7 @@
🎮 Demo mode — Password: {{ DEMO_PASSWORD }}
+

Log in with the password you chose during installation. You can add a signer next.

- Your sovereign identity is ready. You can now log in and start your journey as a noderunner. + {{ installerPasswordPending === true + ? 'Your sovereign identity is ready. Log in with the password you chose during installation, then add a signer if you wish.' + : 'Your sovereign identity is ready. You can now log in and start your journey as a noderunner.' }}

@@ -51,14 +53,14 @@ {{ fipsLabel }} - -

You'll create your node password next

+ +

{{ installerPasswordPending === true ? 'Use your installation password' : installerPasswordPending === false ? "You'll create your node password next" : 'Continue to your node' }}

@@ -72,6 +74,7 @@ import { rpcClient } from '@/api/rpc-client' import { playNavSound } from '@/composables/useNavSounds' const router = useRouter() +const installerPasswordPending = ref(null) const setPasswordButton = ref(null) // FIPS auto-activates in a detached task after the seed is written, so it can @@ -108,6 +111,9 @@ async function pollFips() { } onMounted(() => { + rpcClient.call({ method: 'auth.isInstallerPasswordPending' }) + .then((pending) => { installerPasswordPending.value = Boolean(pending) }) + .catch(() => {}) setTimeout(() => { setPasswordButton.value?.focus({ preventScroll: true }) }, 500) diff --git a/neode-ui/src/views/OnboardingSeedGenerate.vue b/neode-ui/src/views/OnboardingSeedGenerate.vue index 3dd5fca0..b98ac065 100644 --- a/neode-ui/src/views/OnboardingSeedGenerate.vue +++ b/neode-ui/src/views/OnboardingSeedGenerate.vue @@ -97,9 +97,11 @@ @@ -157,6 +159,7 @@ const words = ref([]) const scrollContainer = ref(null) const contentWrapper = ref(null) const confirmLabel = ref(null) +const confirmCheckbox = ref(null) const showScrollCue = ref(false) let cueResizeObserver: ResizeObserver | null = null @@ -182,9 +185,14 @@ function updateScrollCue() { showScrollCue.value = labelBottom > containerBottom } -// Wayfinding only — this must never set `confirmed`, never focus/enable the -// Continue button, and never call proceed(). +// The cue is a keyboard route to the checkbox when it is below the fold. +// Focus alone never confirms the words or enables Continue. function revealConfirm() { + scrollConfirmIntoView() + confirmCheckbox.value?.focus({ preventScroll: true }) +} + +function scrollConfirmIntoView() { confirmLabel.value?.scrollIntoView({ behavior: 'smooth', block: 'center' }) } diff --git a/neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts b/neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts index 803215a3..58f44429 100644 --- a/neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts +++ b/neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts @@ -45,9 +45,9 @@ describe('OnboardingSeedGenerate scroll cue (UIFIX-03)', () => { Element.prototype.scrollIntoView = vi.fn() }) - async function mountWithWords() { + async function mountWithWords(attachTo?: Element) { vi.mocked(rpcClient.call).mockResolvedValue({ words: WORDS }) - const wrapper = mount(OnboardingSeedGenerate) + const wrapper = mount(OnboardingSeedGenerate, { attachTo }) await flushPromises() await wrapper.vm.$nextTick() return wrapper @@ -121,8 +121,10 @@ describe('OnboardingSeedGenerate scroll cue (UIFIX-03)', () => { expect(wrapper.text()).not.toContain('One more step below') }) - it('activating the cue scrolls the tickbox into view and never touches confirmed', async () => { - const wrapper = await mountWithWords() + it('activating the cue focuses the tickbox without confirming the seed', async () => { + const host = document.createElement('div') + document.body.appendChild(host) + const wrapper = await mountWithWords(host) const container = wrapper.get('.overflow-y-auto').element as HTMLElement const label = wrapper.get('label').element as HTMLElement @@ -143,7 +145,10 @@ describe('OnboardingSeedGenerate scroll cue (UIFIX-03)', () => { expect(label.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }) const checkbox = wrapper.get('input[type="checkbox"]').element as HTMLInputElement + expect(document.activeElement).toBe(checkbox) expect(checkbox.checked).toBe(false) + wrapper.unmount() + host.remove() }) it('never shows the cue while loading, regardless of overflow', async () => { diff --git a/scripts/lib/installer-owner.sh b/scripts/lib/installer-owner.sh new file mode 100644 index 00000000..5d8c0bc1 --- /dev/null +++ b/scripts/lib/installer-owner.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Sourced by the live ISO installer. Keep owner secrets in shell memory only. + +installer_valid_user() { + [[ "$1" =~ ^[a-z][a-z0-9_-]{1,30}$ ]] && + [[ "$1" != archipelago && "$1" != root && "$1" != nobody ]] +} + +installer_valid_hostname() { + [[ ${#1} -le 63 && "$1" =~ ^[a-z][a-z0-9-]*[a-z0-9]$ ]] && + [[ "$1" != localhost && "$1" != archipelago ]] +} + +installer_owner_header() { + local rows top + clear + rows=$(tput lines 2>/dev/null || printf '24') + top=$(( (rows - 20) / 2 )) + (( top < 0 )) && top=0 + (( top > 8 )) && top=8 + printf '%*s' "$top" '' | tr ' ' '\n' + printf '%s' "${ORANGE:-}" + printf ' ▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█\n' + printf ' █▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █\n' + printf ' ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀\n' + printf '%s' "${NC:-}" + printf '\n' + printf ' SET UP YOUR ARCHIPELAGO\n' + printf ' Your own account, your own node\n' + printf '\n' +} + +installer_wired_link() { + local path name + for path in /sys/class/net/*; do + name=${path##*/} + [[ "$name" == lo || -d "$path/wireless" ]] && continue + [[ -r "$path/carrier" && $(cat "$path/carrier" 2>/dev/null) == 1 ]] && return 0 + done + return 1 +} + +installer_wifi_skip() { + printf ' Continuing without Wi-Fi. You can set it up later in the web UI: Server > WiFi.\n' +} + +installer_collect_wifi() { + INSTALLER_WIFI_UUID='' + INSTALLER_WIFI_PROFILE='' + local answer interface ssid profile choice index + local -a networks + if installer_wired_link; then + printf ' Wired link detected.\n' + read -r -p ' Set up Wi-Fi as well? [y/N] ' answer || return 1 + [[ ! "$answer" =~ ^[Yy]$ ]] && return 0 + else + printf ' No wired link detected. An offline install is still possible.\n' + read -r -p ' Set up Wi-Fi now? [Y/n] ' answer || return 1 + [[ "$answer" =~ ^[Nn]$ ]] && return 0 + fi + if ! command -v nmcli >/dev/null || ! systemctl start NetworkManager; then + printf ' Wi-Fi manager is unavailable.\n' >&2 + installer_wifi_skip + return 0 + fi + if ! nmcli radio wifi on; then + printf ' Wi-Fi could not be enabled.\n' >&2 + installer_wifi_skip + return 0 + fi + interface=$(nmcli -t -f DEVICE,TYPE device status | awk -F: '$2 == "wifi" { print $1; exit }') || interface='' + if [[ -z "$interface" ]]; then + printf ' No supported Wi-Fi adapter was found.\n' >&2 + installer_wifi_skip + return 0 + fi + while :; do + nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true + printf '\n Available Wi-Fi networks:\n' + mapfile -t networks < <(nmcli -t --escape no -f SSID device wifi list ifname "$interface" | + awk 'NF && !seen[$0]++') + for index in "${!networks[@]}"; do + printf ' %2d. %s\n' "$((index + 1))" "${networks[index]}" + done + printf ' Choose a number, M for a hidden network, R to rescan, or S to skip.\n' + read -r -p ' Wi-Fi choice: ' choice || return 1 + case "$choice" in + [Ss]) installer_wifi_skip; return 0 ;; + [Rr]) continue ;; + [Mm]) + read -r -p ' Wi-Fi network name (SSID): ' ssid || return 1 + [[ -n "$ssid" && "$ssid" != *$'\n'* && "$ssid" != *$'\r'* ]] || { + printf ' Enter a network name.\n'; continue; + } + ;; + *) + [[ "$choice" =~ ^[0-9]+$ ]] || { printf ' Choose a listed number.\n'; continue; } + index=$((10#$choice - 1)) + (( index >= 0 && index < ${#networks[@]} )) || { + printf ' Choose a listed number.\n'; continue; + } + ssid=${networks[index]} + ;; + esac + profile="archipelago-install-$$" + printf ' Enter the Wi-Fi password at the hidden prompt if asked.\n' + if ! nmcli --ask device wifi connect "$ssid" ifname "$interface" name "$profile"; then + nmcli connection delete "$profile" >/dev/null 2>&1 || true + printf ' Wi-Fi connection failed. Choose another network or skip.\n' >&2 + continue + fi + INSTALLER_WIFI_UUID=$(nmcli -g connection.uuid connection show "$profile") || INSTALLER_WIFI_UUID='' + INSTALLER_WIFI_PROFILE=$(grep -l -F -x "uuid=$INSTALLER_WIFI_UUID" \ + /etc/NetworkManager/system-connections/*.nmconnection 2>/dev/null | head -n 1) + if [[ ! "$INSTALLER_WIFI_UUID" =~ ^[0-9a-fA-F-]{36}$ || + -z "$INSTALLER_WIFI_PROFILE" || ! -f "$INSTALLER_WIFI_PROFILE" || + -L "$INSTALLER_WIFI_PROFILE" ]]; then + printf ' Wi-Fi connected, but its profile was not saved for reboot. Choose another network or skip.\n' >&2 + INSTALLER_WIFI_UUID=''; INSTALLER_WIFI_PROFILE='' + continue + fi + printf ' Wi-Fi connected. This connection will be available after reboot.\n' + return 0 + done +} + +installer_collect_owner() { + installer_owner_header + printf ' Create the account used at the machine console.\n\n' + while :; do + read -r -p ' Username: ' INSTALLER_OWNER_USER || return 1 + if installer_valid_user "$INSTALLER_OWNER_USER" && + ! getent passwd "$INSTALLER_OWNER_USER" >/dev/null; then break; fi + printf ' Use 2–31 lowercase letters, digits, _ or -; choose a personal name.\n' + done + local proposed pass_again + proposed="archipelago-$(od -An -N3 -tx1 /dev/urandom | tr -d ' \n')" + while :; do + read -r -p " Hostname [$proposed]: " INSTALLER_OWNER_HOSTNAME || return 1 + INSTALLER_OWNER_HOSTNAME=${INSTALLER_OWNER_HOSTNAME:-$proposed} + installer_valid_hostname "$INSTALLER_OWNER_HOSTNAME" && break + printf ' Use a hostname of 2–63 lowercase letters, digits or hyphens.\n' + done + while :; do + read -r -s -p ' Password (12+ chars; upper, lower, number, symbol): ' INSTALLER_OWNER_PASSWORD || return 1; printf '\n' + read -r -s -p ' Confirm password: ' pass_again || return 1; printf '\n' + if [[ ${#INSTALLER_OWNER_PASSWORD} -ge 12 && "$INSTALLER_OWNER_PASSWORD" != archipelago && + "$INSTALLER_OWNER_PASSWORD" =~ [A-Z] && "$INSTALLER_OWNER_PASSWORD" =~ [a-z] && + "$INSTALLER_OWNER_PASSWORD" =~ [0-9] && "$INSTALLER_OWNER_PASSWORD" =~ [^[:alnum:]] && + "$INSTALLER_OWNER_PASSWORD" != [[:space:]]* && + "$INSTALLER_OWNER_PASSWORD" != *[[:space:]] && + "$INSTALLER_OWNER_PASSWORD" == "$pass_again" ]]; then + break + fi + INSTALLER_OWNER_PASSWORD=''; pass_again='' + printf ' Passwords must match and meet all strength requirements.\n' + done + pass_again='' + printf '\n' + installer_collect_wifi || return 1 + printf '\n Account: %s | Hostname: %s\n' "$INSTALLER_OWNER_USER" "$INSTALLER_OWNER_HOSTNAME" + read -r -p ' Continue to disk selection? [Y/n] ' answer || return 1 + [[ ! "$answer" =~ ^[Nn]$ ]] +} + +installer_install_owner() { + local target=${1:?target root required} + [[ -d "$target/etc" && -n "$INSTALLER_OWNER_USER" && -n "$INSTALLER_OWNER_HOSTNAME" && + -n "$INSTALLER_OWNER_PASSWORD" ]] || return 1 + chroot "$target" useradd -m -s /bin/bash -G sudo -- "$INSTALLER_OWNER_USER" || return 1 + if ! printf '%s:%s\n' "$INSTALLER_OWNER_USER" "$INSTALLER_OWNER_PASSWORD" | + chroot "$target" chpasswd >/dev/null 2>&1; then + printf ' Could not set the owner password; installation is incomplete.\n' >&2 + return 1 + fi + printf '%s\n' "$INSTALLER_OWNER_HOSTNAME" > "$target/etc/hostname" || return 1 + cat > "$target/etc/hosts" </dev/null; then + printf ' Could not stage dashboard login; installation is incomplete.\n' >&2 + return 1 + fi + [[ -f "$target/var/lib/archipelago/installer-password.json" ]] || return 1 + chown 1000:1000 "$target/var/lib/archipelago/installer-password.json" || return 1 + install -d -m 755 "$target/etc/archipelago" || return 1 + printf '%s\n' "$INSTALLER_OWNER_USER" > "$target/etc/archipelago/owner-account" || return 1 + chmod 644 "$target/etc/archipelago/owner-account" || return 1 + # The fixed archipelago UID owns services and data, never the human login. + # SSH remains key-only; this account uses its chosen password at the console. + INSTALLER_OWNER_PASSWORD='' +} diff --git a/scripts/tests/test_installer_owner.py b/scripts/tests/test_installer_owner.py new file mode 100644 index 00000000..548a2c9e --- /dev/null +++ b/scripts/tests/test_installer_owner.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Non-destructive checks for the live ISO owner form and target handoff.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +HELPER = Path(__file__).resolve().parents[1] / "lib" / "installer-owner.sh" + + +def bash(script: str, *, input_text: str = "", env: dict | None = None): + return subprocess.run( + ["bash", "-c", f'source "{HELPER}"\n{script}'], + input=input_text, + text=True, + capture_output=True, + env={**os.environ, **(env or {})}, + check=False, + ) + + +class InstallerOwnerTests(unittest.TestCase): + def test_no_wifi_card_can_be_skipped_after_yes(self): + result = bash( + """ + installer_wired_link() { return 1; } + systemctl() { return 0; } + nmcli() { + case "$*" in + 'radio wifi on') return 0 ;; + '-t -f DEVICE,TYPE device status') printf 'eth0:ethernet\\n' ;; + esac + } + installer_collect_wifi && printf 'CONTINUED\\n' + """, + input_text="y\n", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("No supported Wi-Fi adapter", result.stderr) + self.assertIn("CONTINUED", result.stdout) + + def test_wifi_scan_selection_and_failed_connection_skip(self): + result = bash( + """ + installer_wired_link() { return 1; } + systemctl() { return 0; } + nmcli() { + case "$*" in + 'radio wifi on'|'device wifi rescan ifname wlan0') return 0 ;; + '-t -f DEVICE,TYPE device status') printf 'wlan0:wifi\\n' ;; + '-t --escape no -f SSID device wifi list ifname wlan0') + printf 'Home Network\\nGuest\\n' ;; + '--ask device wifi connect Home Network ifname wlan0 name archipelago-install-'*) + printf 'SELECTED=Home Network\\n' >&2; return 1 ;; + 'connection delete '*) return 0 ;; + *) return 1 ;; + esac + } + installer_collect_wifi && printf 'CONTINUED\\n' + """, + input_text="y\n1\ns\n", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("1. Home Network", result.stdout) + self.assertIn("SELECTED=Home Network", result.stderr) + self.assertIn("Wi-Fi connection failed", result.stderr) + self.assertIn("Continuing without Wi-Fi", result.stdout) + self.assertIn("CONTINUED", result.stdout) + + def test_account_and_hostname_reject_defaults_and_unsafe_names(self): + result = bash( + """ + for name in alice owner-2; do installer_valid_user "$name" || exit 1; done + for name in root archipelago nobody a 'A-laptop' 'a;touch /tmp/no' ''; do + installer_valid_user "$name" && exit 2 + done + for name in alice-node archipelago-a1b2c3; do + installer_valid_hostname "$name" || exit 3 + done + for name in archipelago localhost '-bad' 'bad-' 'bad.name' ''; do + installer_valid_hostname "$name" && exit 4 + done + true + """ + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_form_keeps_password_off_screen_and_fails_closed_on_eof(self): + script = """ + clear() { :; } + installer_collect_wifi() { :; } + installer_collect_owner || exit 1 + printf 'OWNER=%s HOST=%s\\n' "$INSTALLER_OWNER_USER" "$INSTALLER_OWNER_HOSTNAME" + """ + secret = "synthetic-Secret-123!" + result = bash(script, input_text=f"alice\nalice-node\n{secret}\n{secret}\ny\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("OWNER=alice HOST=alice-node", result.stdout) + self.assertNotIn(secret, result.stdout + result.stderr) + result = bash(script, input_text="alice\n") + self.assertNotEqual(result.returncode, 0) + result = bash(script, input_text=f"alice\nalice-node\nweakpassword\nweakpassword\n{secret}\n{secret}\ny\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("strength requirements", result.stdout) + + def test_target_gets_owner_hostname_and_private_wifi_profile(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / "target" + (target / "etc").mkdir(parents=True) + profile = root / "wifi.nmconnection" + profile.write_text("[connection]\nid=test\nuuid=12345678-1234-1234-1234-123456789abc\n") + result = bash( + """ + chroot() { + if [ "$2" = useradd ]; then printf '%s\\n' "$*" >> "$CAPTURE"; return 0; fi + if [ "$2" = chpasswd ]; then IFS= read -r line; printf '%s' "$line" > "$SECRET_CAPTURE"; return 0; fi + if [ "$2" = /usr/local/bin/archipelago ]; then + IFS= read -r line + printf '%s' "$line" > "$DASHBOARD_INPUT" + [ "$4" = /var/lib/archipelago ] || return 1 + mkdir -p "$TARGET/var/lib/archipelago" + : > "$TARGET/var/lib/archipelago/installer-password.json" + return 0 + fi + return 1 + } + chown() { :; } + INSTALLER_OWNER_USER=alice + INSTALLER_OWNER_HOSTNAME=alice-node + INSTALLER_OWNER_PASSWORD=synthetic-Secret-123! + INSTALLER_WIFI_UUID=12345678-1234-1234-1234-123456789abc + INSTALLER_WIFI_PROFILE="$PROFILE" + installer_install_owner "$TARGET" + [ -z "$INSTALLER_OWNER_PASSWORD" ] + """, + env={ + "TARGET": str(target), + "PROFILE": str(profile), + "CAPTURE": str(root / "calls"), + "SECRET_CAPTURE": str(root / "secret-input"), + "DASHBOARD_INPUT": str(root / "dashboard-input"), + }, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((target / "etc/hostname").read_text(), "alice-node\n") + self.assertIn("127.0.1.1 alice-node", (target / "etc/hosts").read_text()) + self.assertIn("useradd -m -s /bin/bash -G sudo -- alice", (root / "calls").read_text()) + self.assertEqual((root / "secret-input").read_text(), "alice:synthetic-Secret-123!") + self.assertEqual((root / "dashboard-input").read_text(), "synthetic-Secret-123!") + self.assertEqual((target / "etc/archipelago/owner-account").read_text(), "alice\n") + self.assertTrue((target / "var/lib/archipelago/installer-password.json").is_file()) + self.assertNotIn("synthetic-Secret-123!", result.stdout + result.stderr) + installed = target / "etc/NetworkManager/system-connections/archipelago-wifi.nmconnection" + self.assertEqual(installed.read_text(), profile.read_text()) + self.assertEqual(installed.stat().st_mode & 0o777, 0o600) + + +if __name__ == "__main__": + unittest.main()