From 711ea80fb18709b3945d5a94aba0b38a1b800892 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 12 Nov 2026 17:17:40 -0500 Subject: [PATCH 01/12] feat: collect unique owner credentials in ISO installer --- core/archipelago/src/api/rpc/auth.rs | 20 +- core/archipelago/src/api/rpc/dispatcher.rs | 1 + core/archipelago/src/api/rpc/middleware.rs | 1 + core/archipelago/src/auth.rs | 70 ++++++- core/archipelago/src/host_capabilities.rs | 2 +- core/archipelago/src/host_login.rs | 49 ++++- core/archipelago/src/main.rs | 14 ++ .../_archived/build-auto-installer-iso.sh | 71 +++---- image-recipe/branding/grub-theme/theme.txt | 41 ++-- neode-ui/src/views/Login.vue | 17 +- neode-ui/src/views/OnboardingDone.vue | 14 +- scripts/lib/installer-owner.sh | 176 ++++++++++++++++++ scripts/tests/test_installer_owner.py | 113 +++++++++++ 13 files changed, 513 insertions(+), 76 deletions(-) create mode 100644 scripts/lib/installer-owner.sh create mode 100644 scripts/tests/test_installer_owner.py 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 52b878ee..66be9877 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -108,6 +108,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 dfa3e472..7afd5568 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -142,6 +142,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 52dc6de0..510e0dbd 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -902,7 +902,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/ @@ -948,6 +948,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 @@ -1180,16 +1181,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)" @@ -2381,6 +2373,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 "") @@ -2645,14 +2645,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 <${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 on first visit${NC}" +p "${WHITE} SSH key-only after explicit key setup${NC}" echo "" hrule echo "" @@ -3892,13 +3890,7 @@ 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 text-on-solid menu. if [ -f "$WORK_DIR/vesamenu.c32" ]; then cp "$WORK_DIR/vesamenu.c32" "$INSTALLER_ISO/isolinux/vesamenu.c32" fi @@ -3908,18 +3900,17 @@ UI vesamenu.c32 PROMPT 0 TIMEOUT 0 -MENU TITLE -MENU BACKGROUND splash.png +MENU TITLE ARCHIPELAGO / INSTALL MENU RESOLUTION 1024 768 -MENU VSHIFT 20 -MENU HSHIFT 6 -MENU WIDTH 68 +MENU VSHIFT 0 +MENU HSHIFT 14 +MENU WIDTH 50 MENU MARGIN 2 MENU ROWS 5 -MENU TABMSG press tab to edit | archipelago.sh -MENU COLOR screen 37;40 #00000000 #00000000 none +MENU TABMSG arrows to select | enter to boot +MENU COLOR screen 37;40 #ff0b0d12 #ff0b0d12 none MENU COLOR border 30;40 #00000000 #00000000 none -MENU COLOR title 1;37;40 #80888888 #00000000 none +MENU COLOR title 1;37;40 #fffb923c #ff0b0d12 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 diff --git a/image-recipe/branding/grub-theme/theme.txt b/image-recipe/branding/grub-theme/theme.txt index be5029d4..aff46bb6 100644 --- a/image-recipe/branding/grub-theme/theme.txt +++ b/image-recipe/branding/grub-theme/theme.txt @@ -1,52 +1,49 @@ -# Archipelago GRUB Theme -# Dark background with Bitcoin orange accents +# Archipelago installer boot menu: quiet solid background, clear 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% + left = 27% + top = 42% + width = 46% + height = 25% item_font = "DejaVu Sans Bold 16" - item_color = "#aaaaaa" + item_color = "#bbb5b0" selected_item_font = "DejaVu Sans Bold 16" - selected_item_color = "#f7931a" - item_height = 36 - item_spacing = 8 - item_padding = 16 + selected_item_color = "#fb923c" + item_height = 40 + item_spacing = 10 + item_padding = 18 scrollbar = false } + label { left = 10% - top = 18% + top = 23% width = 80% font = "DejaVu Sans Mono Bold 24" text = "a r c h i p e l a g o" - color = "#f7931a" + color = "#fb923c" align = "center" } + label { left = 10% - top = 26% + top = 31% width = 80% font = "DejaVu Sans Bold 14" - text = "bitcoin node os" - color = "#888888" + text = "YOUR NODE. YOUR KEYS. YOUR WAY." + color = "#b6b5b8" align = "center" } + label { left = 10% - top = 90% + top = 86% width = 80% font = "DejaVu Sans Bold 12" - text = "press tab to edit | use arrow keys to select | enter to boot" - color = "#555555" + text = "arrow keys to select ยท enter to boot" + color = "#777477" align = "center" } diff --git a/neode-ui/src/views/Login.vue b/neode-ui/src/views/Login.vue index 4e814b46..679f3956 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 + ? '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 ? 'Use your installation password' : "You'll create your node password next" }}

@@ -72,6 +74,7 @@ import { rpcClient } from '@/api/rpc-client' import { playNavSound } from '@/composables/useNavSounds' const router = useRouter() +const installerPasswordPending = ref(false) 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/scripts/lib/installer-owner.sh b/scripts/lib/installer-owner.sh new file mode 100644 index 00000000..d74f9f66 --- /dev/null +++ b/scripts/lib/installer-owner.sh @@ -0,0 +1,176 @@ +#!/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_center() { + local columns padding + columns=$(tput cols 2>/dev/null || printf '80') + padding=$(( (columns - ${#1}) / 2 )) + (( padding < 0 )) && padding=0 + printf '%*s%s\n' "$padding" '' "$1" +} + +installer_owner_header() { + local rows top + clear + rows=$(tput lines 2>/dev/null || printf '24') + top=$(( (rows - 24) / 2 )) + (( top < 0 )) && top=0 + (( top > 8 )) && top=8 + printf '%*s' "$top" '' | tr ' ' '\n' + printf '%s' "${ORANGE:-}" + installer_center 'โ–„โ–€โ–ˆ โ–ˆโ–€โ–„ โ–ˆโ–€โ–€ โ–ˆ โ–ˆ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆโ–€โ–€ โ–ˆ โ–„โ–€โ–ˆ โ–ˆโ–€โ–€ โ–ˆโ–€โ–ˆ' + installer_center 'โ–ˆโ–€โ–ˆ โ–ˆโ–€โ–„ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆ โ–ˆโ–€โ–€ โ–ˆโ–ˆโ–€ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆ โ–ˆ โ–ˆ โ–ˆ' + installer_center 'โ–€ โ–€ โ–€ โ–€ โ–€โ–€โ–€ โ–€ โ–€ โ–€ โ–€ โ–€โ–€โ–€ โ–€โ–€โ–€ โ–€ โ–€ โ–€โ–€โ–€ โ–€โ–€โ–€' + printf '%s' "${NC:-}" + printf '\n' + installer_center 'SET UP YOUR ARCHIPELAGO' + installer_center 'Your own account, your own node' + 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_collect_wifi() { + INSTALLER_WIFI_UUID='' + INSTALLER_WIFI_PROFILE='' + local answer interface ssid profile + 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; continue offline or repair the live environment.\n' >&2 + return 1 + fi + nmcli radio wifi on || return 1 + interface=$(nmcli -t -f DEVICE,TYPE device status | awk -F: '$2 == "wifi" { print $1; exit }') + if [[ -z "$interface" ]]; then + printf ' No supported Wi-Fi adapter was found.\n' >&2 + return 1 + fi + nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true + nmcli -f SSID,SIGNAL,SECURITY device wifi list ifname "$interface" + while :; do + read -r -p ' Wi-Fi network name (SSID): ' ssid || return 1 + [[ -n "$ssid" && "$ssid" != *$'\n'* && "$ssid" != *$'\r'* ]] && break + printf ' Enter a network name.\n' + done + profile="archipelago-install-$$" + printf ' Enter the Wi-Fi password at the hidden NetworkManager 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. Check the network and try the setup again.\n' >&2 + return 1 + fi + INSTALLER_WIFI_UUID=$(nmcli -g connection.uuid connection show "$profile") + [[ "$INSTALLER_WIFI_UUID" =~ ^[0-9a-fA-F-]{36}$ ]] || return 1 + INSTALLER_WIFI_PROFILE=$(grep -l -F -x "uuid=$INSTALLER_WIFI_UUID" \ + /etc/NetworkManager/system-connections/*.nmconnection 2>/dev/null | head -n 1) + if [[ -z "$INSTALLER_WIFI_PROFILE" || ! -f "$INSTALLER_WIFI_PROFILE" || + -L "$INSTALLER_WIFI_PROFILE" ]]; then + printf ' Wi-Fi profile was not saved; installation cannot carry it across reboot.\n' >&2 + return 1 + fi + printf ' Wi-Fi connected. This connection will be available after reboot.\n' +} + +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 + chown 1000:1000 "$target/var/lib/archipelago/data/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..a5159826 --- /dev/null +++ b/scripts/tests/test_installer_owner.py @@ -0,0 +1,113 @@ +#!/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_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" + mkdir -p "$TARGET/var/lib/archipelago/data" + : > "$TARGET/var/lib/archipelago/data/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.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() -- 2.54.0 From bb4115e0a7c4f069548bd301743b63625f7eb841 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 12 Nov 2026 17:23:36 -0500 Subject: [PATCH 02/12] fix: keep installer login clear during onboarding handoff --- image-recipe/_archived/build-auto-installer-iso.sh | 2 +- neode-ui/src/views/OnboardingDone.vue | 8 ++++---- scripts/lib/installer-owner.sh | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index 510e0dbd..07baac62 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -3740,7 +3740,7 @@ echo "" p "${ORANGE} http://${NC}" echo "" p "${WHITE} Console $INSTALLER_OWNER_USER (the password you chose)${NC}" -p "${WHITE} Dashboard complete onboarding on first visit${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 diff --git a/neode-ui/src/views/OnboardingDone.vue b/neode-ui/src/views/OnboardingDone.vue index 7a7896b6..3fca46e1 100644 --- a/neode-ui/src/views/OnboardingDone.vue +++ b/neode-ui/src/views/OnboardingDone.vue @@ -15,7 +15,7 @@

- {{ installerPasswordPending + {{ 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.' }}

@@ -54,13 +54,13 @@ -

{{ installerPasswordPending ? 'Use your installation password' : "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' }}

@@ -74,7 +74,7 @@ import { rpcClient } from '@/api/rpc-client' import { playNavSound } from '@/composables/useNavSounds' const router = useRouter() -const installerPasswordPending = ref(false) +const installerPasswordPending = ref(null) const setPasswordButton = ref(null) // FIPS auto-activates in a detached task after the seed is written, so it can diff --git a/scripts/lib/installer-owner.sh b/scripts/lib/installer-owner.sh index d74f9f66..291d4509 100644 --- a/scripts/lib/installer-owner.sh +++ b/scripts/lib/installer-owner.sh @@ -12,7 +12,7 @@ installer_valid_hostname() { } installer_center() { - local columns padding + local columns padding LC_ALL=C.UTF-8 columns=$(tput cols 2>/dev/null || printf '80') padding=$(( (columns - ${#1}) / 2 )) (( padding < 0 )) && padding=0 -- 2.54.0 From 98847b930f41a99473831ec258e82ae471615ece Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 13 Nov 2026 01:00:25 -0500 Subject: [PATCH 03/12] fix: provision ISO policy in isolated rootless image namespace --- .../_archived/build-auto-installer-iso.sh | 32 +++++++++++++++---- image-recipe/host-policy/provision.py | 29 +++++++++++++++-- image-recipe/host-policy/test_policy.py | 9 ++++++ 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index 07baac62..8b76fad2 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -828,12 +828,32 @@ 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" - 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" + 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" + test "$(stat -c %u "$root/etc")" = 0 + test "$(stat -c %u "$root/home/archipelago")" = 1000 + 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" 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') -- 2.54.0 From 459115dc86438e7b50dd83783b91e458280f2f78 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 13 Nov 2026 03:48:07 -0500 Subject: [PATCH 04/12] Fix installer Wi-Fi fallback, root permissions, and boot layout --- .../_archived/build-auto-installer-iso.sh | 17 ++- image-recipe/branding/grub-theme/theme.txt | 66 +++++++---- scripts/lib/installer-owner.sh | 111 +++++++++++------- scripts/tests/test_installer_owner.py | 47 ++++++++ 4 files changed, 175 insertions(+), 66 deletions(-) diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index 8b76fad2..f9b914e2 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -830,6 +830,10 @@ SYSTEMDSERVICE POLICY_ROOT=$(mktemp -d "$WORK_DIR/policy-root.XXXXXX") 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" + chroot "$POLICY_ROOT" 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" @@ -842,8 +846,11 @@ SYSTEMDSERVICE $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 + chroot "$root" 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" @@ -854,13 +861,18 @@ SYSTEMDSERVICE 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 -tvf "$ROOTFS_TAR" . | awk 'NR == 1 {print $1}')" != drwxr-xr-x ]; then + echo 'Root filesystem / must be traversable by the service user' >&2 + exit 1 +fi if [ -n "$UAT_KERNEL" ]; then python3 "$WORK_DIR/uat_kernel.py" cache "$WORK_DIR/uat-kernel.json" --archive "$ROOTFS_TAR" fi @@ -2516,6 +2528,9 @@ 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 # LUKS2 encryption for data partition step "Encrypting data partition" diff --git a/image-recipe/branding/grub-theme/theme.txt b/image-recipe/branding/grub-theme/theme.txt index aff46bb6..04f9470a 100644 --- a/image-recipe/branding/grub-theme/theme.txt +++ b/image-recipe/branding/grub-theme/theme.txt @@ -1,49 +1,69 @@ -# Archipelago installer boot menu: quiet solid background, clear choices. +# Match the installer console: left-aligned Archipelago mark and choices. title-text: "" desktop-color: "#0b0d12" + boot_menu { - left = 27% - top = 42% - width = 46% - height = 25% - item_font = "DejaVu Sans Bold 16" + left = 8% + top = 46% + width = 84% + height = 24% + item_font = "DejaVu Sans Mono Bold 16" item_color = "#bbb5b0" - selected_item_font = "DejaVu Sans Bold 16" + selected_item_font = "DejaVu Sans Mono Bold 16" selected_item_color = "#fb923c" item_height = 40 item_spacing = 10 - item_padding = 18 + item_padding = 0 scrollbar = false } + label { - left = 10% - top = 23% - 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" + text = "โ–„โ–€โ–ˆ โ–ˆโ–€โ–„ โ–ˆโ–€โ–€ โ–ˆ โ–ˆ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆโ–€โ–€ โ–ˆ โ–„โ–€โ–ˆ โ–ˆโ–€โ–€ โ–ˆโ–€โ–ˆ" color = "#fb923c" - align = "center" + align = "left" } + label { - left = 10% - top = 31% - width = 80% - font = "DejaVu Sans Bold 14" + left = 8% + top = 22% + width = 84% + font = "DejaVu Sans Mono Bold 24" + text = "โ–ˆโ–€โ–ˆ โ–ˆโ–€โ–„ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆ โ–ˆโ–€โ–€ โ–ˆโ–ˆโ–€ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆ โ–ˆ โ–ˆ โ–ˆ" + color = "#fb923c" + align = "left" +} + ++ label { + 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 = "center" + align = "left" } + label { - left = 10% - top = 86% - width = 80% - font = "DejaVu Sans Bold 12" + left = 8% + top = 84% + width = 84% + font = "DejaVu Sans Mono Bold 12" text = "arrow keys to select ยท enter to boot" color = "#777477" - align = "center" + align = "left" } diff --git a/scripts/lib/installer-owner.sh b/scripts/lib/installer-owner.sh index 291d4509..a328318d 100644 --- a/scripts/lib/installer-owner.sh +++ b/scripts/lib/installer-owner.sh @@ -11,30 +11,22 @@ installer_valid_hostname() { [[ "$1" != localhost && "$1" != archipelago ]] } -installer_center() { - local columns padding LC_ALL=C.UTF-8 - columns=$(tput cols 2>/dev/null || printf '80') - padding=$(( (columns - ${#1}) / 2 )) - (( padding < 0 )) && padding=0 - printf '%*s%s\n' "$padding" '' "$1" -} - installer_owner_header() { local rows top clear rows=$(tput lines 2>/dev/null || printf '24') - top=$(( (rows - 24) / 2 )) + top=$(( (rows - 20) / 2 )) (( top < 0 )) && top=0 (( top > 8 )) && top=8 printf '%*s' "$top" '' | tr ' ' '\n' printf '%s' "${ORANGE:-}" - installer_center 'โ–„โ–€โ–ˆ โ–ˆโ–€โ–„ โ–ˆโ–€โ–€ โ–ˆ โ–ˆ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆโ–€โ–€ โ–ˆ โ–„โ–€โ–ˆ โ–ˆโ–€โ–€ โ–ˆโ–€โ–ˆ' - installer_center 'โ–ˆโ–€โ–ˆ โ–ˆโ–€โ–„ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆ โ–ˆโ–€โ–€ โ–ˆโ–ˆโ–€ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆ โ–ˆ โ–ˆ โ–ˆ' - installer_center 'โ–€ โ–€ โ–€ โ–€ โ–€โ–€โ–€ โ–€ โ–€ โ–€ โ–€ โ–€โ–€โ–€ โ–€โ–€โ–€ โ–€ โ–€ โ–€โ–€โ–€ โ–€โ–€โ–€' + printf ' โ–„โ–€โ–ˆ โ–ˆโ–€โ–„ โ–ˆโ–€โ–€ โ–ˆ โ–ˆ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆโ–€โ–€ โ–ˆ โ–„โ–€โ–ˆ โ–ˆโ–€โ–€ โ–ˆโ–€โ–ˆ\n' + printf ' โ–ˆโ–€โ–ˆ โ–ˆโ–€โ–„ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆ โ–ˆโ–€โ–€ โ–ˆโ–ˆโ–€ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆ โ–ˆ โ–ˆ โ–ˆ\n' + printf ' โ–€ โ–€ โ–€ โ–€ โ–€โ–€โ–€ โ–€ โ–€ โ–€ โ–€ โ–€โ–€โ–€ โ–€โ–€โ–€ โ–€ โ–€ โ–€โ–€โ–€ โ–€โ–€โ–€\n' printf '%s' "${NC:-}" printf '\n' - installer_center 'SET UP YOUR ARCHIPELAGO' - installer_center 'Your own account, your own node' + printf ' SET UP YOUR ARCHIPELAGO\n' + printf ' Your own account, your own node\n' printf '\n' } @@ -48,10 +40,15 @@ installer_wired_link() { 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 + 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 @@ -62,39 +59,69 @@ installer_collect_wifi() { [[ "$answer" =~ ^[Nn]$ ]] && return 0 fi if ! command -v nmcli >/dev/null || ! systemctl start NetworkManager; then - printf ' Wi-Fi manager is unavailable; continue offline or repair the live environment.\n' >&2 - return 1 + printf ' Wi-Fi manager is unavailable.\n' >&2 + installer_wifi_skip + return 0 fi - nmcli radio wifi on || return 1 - interface=$(nmcli -t -f DEVICE,TYPE device status | awk -F: '$2 == "wifi" { print $1; exit }') + 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 - return 1 + installer_wifi_skip + return 0 fi - nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true - nmcli -f SSID,SIGNAL,SECURITY device wifi list ifname "$interface" while :; do - read -r -p ' Wi-Fi network name (SSID): ' ssid || return 1 - [[ -n "$ssid" && "$ssid" != *$'\n'* && "$ssid" != *$'\r'* ]] && break - printf ' Enter a network name.\n' + 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 - profile="archipelago-install-$$" - printf ' Enter the Wi-Fi password at the hidden NetworkManager 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. Check the network and try the setup again.\n' >&2 - return 1 - fi - INSTALLER_WIFI_UUID=$(nmcli -g connection.uuid connection show "$profile") - [[ "$INSTALLER_WIFI_UUID" =~ ^[0-9a-fA-F-]{36}$ ]] || return 1 - INSTALLER_WIFI_PROFILE=$(grep -l -F -x "uuid=$INSTALLER_WIFI_UUID" \ - /etc/NetworkManager/system-connections/*.nmconnection 2>/dev/null | head -n 1) - if [[ -z "$INSTALLER_WIFI_PROFILE" || ! -f "$INSTALLER_WIFI_PROFILE" || - -L "$INSTALLER_WIFI_PROFILE" ]]; then - printf ' Wi-Fi profile was not saved; installation cannot carry it across reboot.\n' >&2 - return 1 - fi - printf ' Wi-Fi connected. This connection will be available after reboot.\n' } installer_collect_owner() { diff --git a/scripts/tests/test_installer_owner.py b/scripts/tests/test_installer_owner.py index a5159826..ab17f5b6 100644 --- a/scripts/tests/test_installer_owner.py +++ b/scripts/tests/test_installer_owner.py @@ -22,6 +22,53 @@ def bash(script: str, *, input_text: str = "", env: dict | None = None): 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( """ -- 2.54.0 From d4396d7246df51c8191d2bd1127f674f573751c3 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 13 Nov 2026 04:31:14 -0500 Subject: [PATCH 05/12] Use absolute chroot paths in rootless ISO preflight --- image-recipe/_archived/build-auto-installer-iso.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index f9b914e2..741f39ca 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -833,7 +833,7 @@ SYSTEMDSERVICE # mktemp creates the extraction directory as 0700. Tar exports that # directory as /, so a nonroot service cannot execute /bin/bash. chmod 755 "$POLICY_ROOT" - chroot "$POLICY_ROOT" runuser -u archipelago -- /bin/bash -c true + /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" @@ -850,7 +850,7 @@ SYSTEMDSERVICE test "$(stat -c %u "$root/etc")" = 0 test "$(stat -c %u "$root/home/archipelago")" = 1000 test "$(stat -c %a "$root")" = 755 - chroot "$root" runuser -u archipelago -- /bin/bash -c true + /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" -- 2.54.0 From 3ae717f66e9f085f0121914ba930ec3fd1e979c9 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 13 Nov 2026 06:05:05 -0500 Subject: [PATCH 06/12] Render left aligned branded BIOS and UEFI installer menus --- .../_archived/build-auto-installer-iso.sh | 33 ++++++++++-------- image-recipe/branding/isolinux-ascii.txt | 3 ++ image-recipe/branding/isolinux-background.png | Bin 0 -> 13314 bytes image-recipe/branding/isolinux-tagline.txt | 1 + 4 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 image-recipe/branding/isolinux-ascii.txt create mode 100644 image-recipe/branding/isolinux-background.png create mode 100644 image-recipe/branding/isolinux-tagline.txt diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index 741f39ca..ac4daf7b 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -3885,8 +3885,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 @@ -3925,31 +3928,33 @@ GRUBCFG # Create ISOLINUX configuration (legacy BIOS boot) echo " Writing ISOLINUX config..." -# Copy vesamenu.c32 for the legacy BIOS text-on-solid menu. +# 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 PROMPT 0 TIMEOUT 0 -MENU TITLE ARCHIPELAGO / INSTALL +MENU TITLE MENU RESOLUTION 1024 768 -MENU VSHIFT 0 -MENU HSHIFT 14 -MENU WIDTH 50 -MENU MARGIN 2 +MENU BACKGROUND background.png +MENU VSHIFT 22 +MENU HSHIFT 8 +MENU WIDTH 62 +MENU MARGIN 0 MENU ROWS 5 MENU TABMSG arrows to select | enter to boot -MENU COLOR screen 37;40 #ff0b0d12 #ff0b0d12 none -MENU COLOR border 30;40 #00000000 #00000000 none -MENU COLOR title 1;37;40 #fffb923c #ff0b0d12 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 COLOR screen 37;40 #ffb6b5b8 #ff0b0d12 std +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/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 0000000000000000000000000000000000000000..9af89b2fbbe83beabbede7ad6f89fe95fea8786b GIT binary patch literal 13314 zcmeI3X;f2L*7q-)xnp;!$0|eFKfb1dLzJBwQoOeyFU&B zz<0o@AAh(Ilgk}LJPqyz-fAy2!F2@6VE~gfK@MzfnYQpZjS8C@D zsIS#OysF`4Qe>w~c!LSUJSRN*`zrUx7lOH|5ckKJvD*bVYxZq{5JqS@V>yy*)1-vY z+=gw$m$E?a(Dah*LPAFif{`ej5l3VBGYHqAIPr`&IM;I=(FSlHF~{`HRt{67@|2hC5Sq8raHmblEGvo-lpi>>N~ zbVZ zZl)dK4no#*K2|@AyvS3Y*4WHQ$m0$Xz50I5<2%FOSq^+4j5o!v%QU zOp1P{C5<2oE|-UI^6JeUFr=4o@FIsi#JMlc)Dk?c@#d9uCh9W{%9J#cM(f@kN57LP zZ$R2u@nVaV+C=4OZqz0QjI@;OAw)P569>9IRy5}!8IK@j5-rwO&|x=ZPmKG4902np z9ze9424%Z!?z4nf2I|khhipYSK9Abh~`-&XUbsT|IKZ z6JWF5?2|6%tAdoR<(;GIP=?}b-Ol=@F2zVX%P6FAFS-)XL@Nj-#3iZqwS{xw_~mKG zC2bDATf&>UTFYi|9+U)-Z=u6ve^vu3Ctmg3L8IblNuf5jZJ|e8RBdGhM3(%9u*In< zXYn%5a5jtlJj*(17rGJ}JCU^p6YFW)Hf&hskMzrg(&9;pmj$HTSS(?RQp2naJ2^-> zT;VATYvX$RMp5fjmE&Fe(sSgEXIlfPslqOIx8keja$K3-_c_kW-ib+~*w1Psd2}n> zty|vhA%4SqT0GS?7EdOHTD{wvBWIgZq0;dwdG9nmf+KeqBofs> zj)Qam^vrQJptc?GsXl%jQ>X3#tOh<4rvASj*vNYob{*Gr>e-u{(xRDnFiVQ6NYb%h zp_m>~5occIc7{h$e^D)d%qNZ5n8Pd4)v>)#dh0LN*iG^IdtifmJ&l_W=uqi@#upv0 zipD*alq|XvMe=>Cffr?AddU_tBQVLwf;QchPU;p0;|37)QhNjGWWF&H1<02Q&bf74 z9fenhjvbur=DScv)O`odo*#G}N)E8y3YhyJJb<7#y}cYpzS?f5mjNxr0Y$q2CH2{< z92sXZxh)z6OyRUB{s$`qJxRH{hbRUWVLuKkZ`A@zyeJ`}d=&+cRS9S&xaX?OU_u=0 zc7>`s&QH9mS%q5a>T-4x%<8ty<>o~FjIRwlrmObV3z-VmR^Y>#jh)c?6+n+??So@* zV>?MUxeS6*%(j~#24nX$Jp5ef>~y_pZinhl9YHOOR-}``i;6)StWkeakJy3kbN2@# z`A_OA9DT=fd|RUm>LE32J#w`MFm(>w#?98Mzt|3}NCJvnucdhc7n}efr65dirP|r= z05It<6jnHz0B?2zNypu5AN&OD`BoEes2B5efq@M`^UJ-XCw>4ng6Dq+3!b(FmhHe=@ch+DvlfE^D;=U%0AT@Zk>|bxR;q!QbBNLps#JerqYhYEBop_nPWlNv zAN)P-u#ehV12tgVvljo2D;$0Ri%#t<=97LIP?Qb;>s|g6)7@bg;-U|o&YL!Y*hSEz zjJIK{G6yMJq(in@tWqJ|HVbcyK1;UA;CTB+jHJqYFG1k6&sgug1pZ(k#)53!TybV(3O);m{F1!g=h2UzSnGHx3O#p8FtuQ-ulT|I3!29cwG;<0}b%8 zt#=CXi;@JC*y?RcElsEm@*vx&Zqg_0EHS-}>|?rMOu$9)@PogxMUHwv6A6xK9p#fD~>B zG9Eb6z2zx^j50GsyOKtMT4`>DB+uYTz-2JmW}V5uZ-EO-cUr>2epEig<&M}Qv`WE@ zO9UdnP0nodS)zF2pfxA{Hu;5z4pHJ)bnIGMHJCT|IcK8aA-Zp@^uQ_JvGUvaZ%Wc} z^%pSyrFL(-TRGo@9d`UJZP?J@Ij99XeA)k^1dO;t>**%&%DWlEB|$aZaJ!)6>AxG? zIzRUSjQKruar7}tX}GK6Sa5`CP|2pgmKo=W!Q533@AX#YxoD1WNx4x*Ah278nl9R! zhH9lkZ{u@!J0*tW_mPu}t7w)2QOnGXlkC`FfZI~vC6CnT<_&g_cs6=wm4!?Voc=2jv)NV0n}|1QVBxnBvVRT%}bx* zAhgAjkn+C(hK<53c`r7uqlmXINgF%z6hg*vFDl0epGQ>R8pl^NA2scQcZY`39~uEU z%eM(?g0cy}7}d)Y6nsuLVdbSdnKXOIKbtU>ZX0?rN-_dgz3PUnkHo0h4{yiW*9npb zyrEuH+HPTCYU$NVFF&h1l*V>pm!bqyvl~B(;1Wvorfye2N5lN5H!aQcQYZ$!HAHf9 z3vBMXX2jMP9qREI~LOtGsa3J z1WKVH7z|;`{JEaJpu1F4wy>EH$*7bcl+!2OA=&bFotiUWx)9f_K|#o^pL_$}`;pm? zc_;&hzf;RcAMOW(8hG$PH(Sp6w0DfCXLFF|!d6o6K}xLj5*5u;=#is#@OBkpv1J#c z9qJPwFUD%6wB(>B=ZcRDzklNKWe%j@L7|XqX}Ya1n0@T!eF(Dm7n*ssDX24*bv+{x zWM`SstzE`l`<>G=>%PosT(nm^J9gEW;(+ zC)2xg+#gerz|`%57WQF4DLW2H96JhyYsue9>UY80Nh8%U6c_k~aI-0XGAslu<`k^7 z&RbTQ9J0}c)_|=hK;M;zvAg0IKD4VEPAm(rhdR7hJtNL+3J<%m(Dk9ZdzShVdMPfV zMzc91RDZEQ^#j6DHfR80$QcV@bB_+_q}|ZDx6r+X%eDfP%n$JCzs&_~)pYn`*N8gN zd@HZI=*Zvq0^V^Ry|;F@KLYQ0^Q*V-i+W%Qm*L-#a1)=~TaL56I{wRPNBTvzuYI~2 zkc^+frkx8eee;ezqbFY0JF^FK8WR_%GOamBH(omVm72fv5KQz?)hw@DmvsEHe)ygh z4r14x`W#jmjt~3}MU>Q%Ee3N;{?SsC{;DzY93&x zb{lcu{-8m$1Uz98j%2InaDzI@Pdf!e@~JLHj_>8xWF3LVi%uusvAVO#WULncQg|0R z$Br0ne!&vNZOz)h>H~dFSL0;|F5^MlRH?AS`mU!-mk<0Jrmp#A`w4J!;NRIg`m-63 zK1a3OLeI+z{JbG$w{?*H{8yhx1oE;;22~NLEFVE>%0_Lsh{2 z&`xbrB{zQJJ|@KvrHjA@4vyxLYQ2;Pvq+>>MipL2P(>hKuEp5t2{LlRWmEgTTXTF< zg_Q~Q96zu!v|4|AOqd3jLc%_#te%|{NZ`C4Y}HGDI~w^Clql|6x!z@i#^r?~_h`KL z)4+BQ;URO37U|%0J27jjln}^$Byo3~E`@aOxalSXQ^?#39&r;GjT)%44B_0g4q>*t4QzKB6dKW4}R-*8m5 z85Otaks}LIym>DCmlgVEi3#bSym&-q&S7sSFI{*@xLJduIgP?jtG{CLqeRMM~5guTdJ62K=eRGtd*qZv|uIdcULA* zM{=r1ZB$Ph*zPstxy2G=e3v)-uN~A747W0$))z|^I4yU%9Lg(2>G#cw zr$o`0HR_w55zs!E%WqN_x)3kxA(k*u_rNKmR7cFywZ`ynP^j93o87i&!z^8kC!V%L z5qfDwimUWTVEG8MW{ebJ5xQ+>#8@yb|H&mR_c|L?7wE@&|4I2TcSd*yxDjem{}1!A zY%9LZ9R$tCvZVUEp;+#^7`vOG9s2T5CSkcp6WUS%K;Qo#K4sZ~M2enn037nP{^32A zn2q7DQ^{=-i!8=IbyYmNYrX7&$D^M5_r|2*snWSFJt9@c&pXre(oA@40XMRCK{ zo5L}rTbGs|q-9SVT|*B=o|q0$y$IxfF&sNWne*p4rRRxsfNI*U|?fm9jmag0xWW@8y1Sen@%R(nu z1+k+=(ve2E**8ab@-g;t%AaUQO56%oy0=}wWD~mC8x_X;nj7`k##tIw5csif(rGFq zy5s41j@sMk^TE{IJJy^A_QX7Up%dfimp~rQjS9hyaOz?;uywU)sS{aXzJGIW-AXWR z3pSLPTZ0_#CZvzXzuzEf8-5Hy!PBGv52!?B#q;jJbRkGt-`J9?h4;^NM8zO6TA*CN zGW_CBj@sGQsP9#NCpDW*wB9wL;2Ak`i1;V9DwMh5V*+`4Waexatq#+e7|qYo+VR~ZcW2l0btd70%z(LGl#@wdEB`KGY=V44!P zCB{$DcsIj9G;4y_6*oyf(T%Yd^$kI^IyT-HMS&1;E*h@LeDrITT_|m3FzG~n<2FV` z+d4ccXvm$isix2+p*h$$BrEdgqZRox^arHJ_xg(yYOb*%i#52|LCd~?2SibWR9frq z2K|gItr>rx7MNo@RB9SpcWZpHtjV8N$7-x8yXe8N51P)^v^?s!Z55s*nif(TpC;ZX z^8AqFqp=vfPJ1+J_KVtGs-RV`AWv~kQ-a}iL}6_bkHyN)DKwvKy)yGocM*0-B}iV} zSvS?UvXzTlnq2`%{<6y9&QUWtZ*cchAXyIlE z$Hexv!g9V*)WQp?`3wy>8B(kCWo0wp$wg8k?_w2tkofgY(EY9MpN`gtcfMcscD};c zCyHE%^Q^Wxa&H~f8qFNgY#uUpNl>IAAJC~s7fU8dSwe#cz~-Z0YO^oJ=d*%fX!z2a zot*;(8ZQ!W*?)byW9E~6LWU1gtl;9Q*jphq&;7a2UMfWw_Ojyox|$jkslPt%pi@Yf zJr&uvX``dFg(m#jxq&r+jY$Bo%fz!h#CD~1^w)=A_EhB_$<+Bt3SY{KhmZ7JF`f&)?NF zw7Idcl&H`wP!-gjh(#r6YzxcXn>dRLT~5+GDIfQ)U$gn)FdM0URKqlJtGk|~16CC=h-N(*Yo zRhYJY^e{4;;mL@Rb=V5T{#yWee8Op)B8G}Phn9O8PjB;IoXx;Ryc@2eWE;iCOooQ4 zKFftSH`Y+FQCGqziTnoK5%P=o$Eh2X161-!9dSPnw;urW>z2f{Ceye0Ph*oQ*o$aJ znLiR%Cm+zln})jSrDgH)7~lnAVpVhVwR;U;<<6{y#+dt&0|qiUn+1ub zlAoVr#Bc-|3PbeAHO85Q%7SSOi;J@d)@nl+0%B3m9#^S!s@5i1GUD;Mh;0XezK{9w zJW^et@3ZpEl36DIi=drci527w-A#kIn1nW5ITt(B$7h^p46xK5zMeX%({UB!b|lmd z^1H=Sg!Jk6a8~Dmtg(0R3GoTd)e=#nHkteMDXma^)Vg+FA-bm4q`N46 zOJsaFN>{Ci$g3NkdSikwM_09XTUK>>k`8@-8WBcdGzy_JFRK{hsw_M z<>GI+k`la9psscNzNw|wp1{@Mxq z{k1=gbY>c?LHj#BYCSb>DilfOAbIQ4WOC5uMO&(&*M7u8()&;n^__$8vF1~wc;P5> z@6g$%#cD7lx!W2RCQJ;JR;*nDrOa>-);?a05kIK$GCTknGW#zutYUh?%Lb2tXjs%q z7qxkxgeJy@&c`G89qQY=)jMN-pP6M-+bpVVq(xb<8@YCixdPuID8lazTH@qWTU+5= zkg1Sr?@x&o*YnwYqJ_CRZ2nPV`9_UkJ+gKZ!w5|XjQ!a-aQEVa{2zGG)X@5Tu5Bx~ zxP@;|7krd!6E{A%@%O!eA?W$h^9|3?vT6*|O7^_f9|G^HiO`!l1^ zOj5j3SIdm_PR^9-#j*S_z~{$n41h*)BmaO)U5C?1%d1uy^q8^TU{(zi#gtYDZH9;{ zBkrJhO*OUi)D_LaHRW@3c4r{X0&E!*SxBaAJKIvp+I6VA&ipyrW1c?{#N#DA1Y#ta zSw`{!yHJC$WLEAZEBjKgExB6ltV-}Gs9fP0eoYptZfIY+7tYW}awG0o z5NS}VPgoTF@DtzS#QUPaf=oyO<+O}9{Qh{BssE)>!5|&_1~(8@%Rh~Rt6;c@=43h= z!b#0RaP)Xoagks|(U-TirqYMKuthJ65)5u>uj3fi$BkFvZW<*hMg16L)syq}A=^f3 zV7H!596oB|6>okRQL*lU-db{FfU0IIdRD0|*oTHc-DGGp_gc!__nKU+x@2%^IaI$I z0*z3m zVdq9SVv~@^&#+8|;vcFWvdko8D z*kkseb>xe?h?7u>c_lhP{2}$rc&XlpGZ;adlxQzw z0+oWk2l%eWFYj`3uk3-iAs2(bBARMla}bI#i+Z6OEwTjk$aO^x;v|yVmzHcmca@QC zX*^*LJH#+!#++T6!+C|=XcPGdL)#h^310k}Lag=W?NPJ49g_R6v9jY)^T_qp!&7^i zXJjSuthh#j!t}kkACxoZq+5)9VP~YZ2DU}}WTpU5`6ec#jG z{GzLVTutnnp5GE=m_xVDNIKHMEdM+jNVS#k^;zjaGB?V+`LNa0#A^;+D7eGW`1<~H zhTHFP=vQK3G@h#To~~pjHeJtGdw5hf+G$EzknT7`7WIYSp)I=H75Lh|I;oL{{>2o!@=GHUZLzR4Xw-`66wo{Irnuc@ zXe@p}D~2cGZk6>_HZ5&E)2Mx@!VpBWfcq;2D!xi5S&p7A0lxTK8Wf{f{?49sPdd## zW0GNbU@6VgH}R_h9jWVHd0xuo(xW$t3tIbHOl>1a%2-KG>H1!zc!|kx>!E_GL{63B za(A?7EFD96ZJ_A6oACtJ6vU>MRr?O8oz({`P*4)V*uM3O+ivZUnA(f>E6>lTHK9X0 zos2WhSMP_OK=N-~>9Bl&qDm6qLhHTaCKqdb6npK|0EcWH09B>>4#ki_#FV(ux-wU7&Mve-{xaW$V`WY))@h?TWw`!SSmy+?q) z8fR*;CnQxvX^>P<<~B%LvQiM^{UdyC4)^HLjNlQ9bxbq9^g3GebCb;S^`6t!N4UQ; zkzgoLXgQTdf%1{F{L?C5hKH&YAz^T)TIz)BYco7c{rnk;Z)akx;lYRPg3(M}@B_Z& zHJul;>qUtzU7xR|L@gQ6d#?cdl_Dg)Y4tbW;7K3_L)>&{q3xAnt(ikv;w&5!76VGU zOmstOqIu=cD3qecc6eZ~f9(Cnp4qYwK+g`fw_YifiUAUB$<{fV^Fk_3InZhs6ZHuo`El&(pZcWul^y&SXN-07g z{51Le*(|egxER>8iPI8z;~6T`yfsl#WoiBl)qYSz(|E>+rDffxzKDj2it;oYzfNE$ z$}Mu9#}D=|KnhF=GxMe_?ej{>8-^y%vmo>wqe&n-%vEg+R{c3l|5IMZQP1)nELO$c zluAWpFU@vekCPxb`` z^Hvf1H@Aw(4x(Y=b@%W|HD)v}WIxXodbzU471(GAKC5Tr49pE&0bU2ri$h>hZ{c%& zQ+Zr=l@V(6s}#P;w~j-OT10mgV&H@PV%h$$!UF#uvRimFaFXyr>b0|SnbG>6FPl4` zoH?(Ouo~$Jga`?uDo*WqiJRI!EMb3s@%6gvy3~$c13rq>x0&6~v3t;@bI)Qvfm*03 zv6;n8U@JW7Qn;$bCuOLkh9s%O`vK5LpBf4MtQEuBrvPUqp%ZGG|+VGYu zg=l=f9O9iQYumKGq42)qyz=UlB4Hi|Yw+4%`zCR7@nhe_`uYsnZJ9FXdTYl(Yhx%$ zSRuC10)YBlVA#a&W0tTmnz1@&c4-ah6RSWihq{E4mDQl1d>_oQJQ_D5FY$4Sbq@4- zSTMBJaCE8V0jf_B81h?zJ%`t%$_=f?wiTX=wNCPUd>0gV-i#{HgwZ5>`(p05g8uFl zvpn&qVbRX*8AfpvpwtYdjmiZkp|9JWq@hw|{f#eg2TdSMYB@$dAqHi9b>cw-!d^iVl5R17hH-T3e|Xq-)%#iN6uJ0A_3 zeAH%nxKS|sJn;EzJFEyrlLezcJut6pkcay;ZfDO1h3x0wQcco36TFN-J!EH&{dBf` z>en*5rXl3D(zZJ4EwD%L>0#MKwN|QX_9G}_1{=j?ZqS?hXToa>%ATwrB9Ou_l_c-v z7ayZ-(6lw7W|cAxSe)2d(g{guGjY#d&|%AJed6b&}f|;E!h}|A@dJ5%?nl ze?;Jq2>kmI2zC|1z;6*eT>g&0ACKMt5rID<@Lv!Cfl95y-dT5MePAUh_kdF;Tz@P- IcKN6O0WwPV_y7O^ literal 0 HcmV?d00001 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. -- 2.54.0 From a3d5942aa28f1cba70574322751648fea38362bb Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 13 Nov 2026 06:06:26 -0500 Subject: [PATCH 07/12] Keep BIOS artwork visible behind boot choices --- image-recipe/_archived/build-auto-installer-iso.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index ac4daf7b..72858496 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -3948,7 +3948,6 @@ MENU WIDTH 62 MENU MARGIN 0 MENU ROWS 5 MENU TABMSG arrows to select | enter to boot -MENU COLOR screen 37;40 #ffb6b5b8 #ff0b0d12 std 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 -- 2.54.0 From 890cb94ae798917515b8652d289ac2e91046406d Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 13 Nov 2026 07:09:53 -0500 Subject: [PATCH 08/12] Keep installer failures visible and fix owner console and cipher fallback --- .../_archived/build-auto-installer-iso.sh | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index 72858496..523052a1 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -2265,7 +2265,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" @@ -2274,6 +2274,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) @@ -2548,13 +2571,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 @@ -2971,7 +2994,7 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then echo -e " ${O}โ–„โ–€โ–ˆ โ–ˆโ–€โ–„ โ–ˆโ–€โ–€ โ–ˆ โ–ˆ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆโ–€โ–€ โ–ˆ โ–„โ–€โ–ˆ โ–ˆโ–€โ–€ โ–ˆโ–€โ–ˆ${N}" echo -e " ${O}โ–ˆโ–€โ–ˆ โ–ˆโ–€โ–„ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆ โ–ˆโ–€โ–€ โ–ˆโ–ˆโ–€ โ–ˆ โ–ˆโ–€โ–ˆ โ–ˆ โ–ˆ โ–ˆ โ–ˆ${N}" echo -e " ${O}โ–€ โ–€ โ–€ โ–€ โ–€โ–€โ–€ โ–€ โ–€ โ–€ โ–€ โ–€โ–€โ–€ โ–€โ–€โ–€ โ–€ โ–€ โ–€โ–€โ–€ โ–€โ–€โ–€${N}" - echo -e " ${OD}bitcoin node os${N}" + echo -e " ${OD}bitcoin node os${N}" if [ -n "$IP" ]; then echo -e " ${W}web ui http://$IP${N}" # The mDNS name survives any DHCP change โ€” it is the address to @@ -2979,8 +3002,10 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then if systemctl is-active avahi-daemon >/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 @@ -2994,7 +3019,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 @@ -3614,13 +3639,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 -- 2.54.0 From 961c0fbdf54107ad7b013f9f48fb4fb17578934d Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 13 Nov 2026 07:17:49 -0500 Subject: [PATCH 09/12] Correct installer progress step count --- image-recipe/_archived/build-auto-installer-iso.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index 523052a1..b60b4bff 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -2344,7 +2344,7 @@ typewrite() { # Phase display STEP=0 -TOTAL_STEPS=8 +TOTAL_STEPS=9 step() { STEP=$((STEP + 1)) echo "" -- 2.54.0 From 32a92b29f7126005d8cd68aa13d30db82edf4e91 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 13 Nov 2026 07:34:03 -0500 Subject: [PATCH 10/12] Preserve kernel provenance across encrypted data mount --- image-recipe/_archived/build-auto-installer-iso.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index b60b4bff..f4a219c8 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -2554,6 +2554,11 @@ 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" @@ -2613,6 +2618,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)" -- 2.54.0 From 6b56347dcfd407f565445ee17a98ca17c6c60b01 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 13 Nov 2026 09:43:14 -0500 Subject: [PATCH 11/12] Stage installer password where dashboard auth reads it --- scripts/lib/installer-owner.sh | 5 +++-- scripts/tests/test_installer_owner.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/lib/installer-owner.sh b/scripts/lib/installer-owner.sh index a328318d..5d8c0bc1 100644 --- a/scripts/lib/installer-owner.sh +++ b/scripts/lib/installer-owner.sh @@ -189,11 +189,12 @@ HOSTS # Stage only an Argon2 verifier. The dashboard account is claimed by the # same password on the final login screen after seed onboarding. if ! printf '%s\n' "$INSTALLER_OWNER_PASSWORD" | - chroot "$target" /usr/local/bin/archipelago stage-installer-password-v1 /var/lib/archipelago/data >/dev/null; then + chroot "$target" /usr/local/bin/archipelago stage-installer-password-v1 /var/lib/archipelago >/dev/null; then printf ' Could not stage dashboard login; installation is incomplete.\n' >&2 return 1 fi - chown 1000:1000 "$target/var/lib/archipelago/data/installer-password.json" || return 1 + [[ -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 diff --git a/scripts/tests/test_installer_owner.py b/scripts/tests/test_installer_owner.py index ab17f5b6..548a2c9e 100644 --- a/scripts/tests/test_installer_owner.py +++ b/scripts/tests/test_installer_owner.py @@ -120,8 +120,9 @@ class InstallerOwnerTests(unittest.TestCase): if [ "$2" = /usr/local/bin/archipelago ]; then IFS= read -r line printf '%s' "$line" > "$DASHBOARD_INPUT" - mkdir -p "$TARGET/var/lib/archipelago/data" - : > "$TARGET/var/lib/archipelago/data/installer-password.json" + [ "$4" = /var/lib/archipelago ] || return 1 + mkdir -p "$TARGET/var/lib/archipelago" + : > "$TARGET/var/lib/archipelago/installer-password.json" return 0 fi return 1 @@ -150,6 +151,7 @@ class InstallerOwnerTests(unittest.TestCase): 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()) -- 2.54.0 From 4c3cf5a3240bf28af0579e1215da53a4639cb957 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 13 Nov 2026 10:25:03 -0500 Subject: [PATCH 12/12] Make seed confirmation reachable by keyboard on kiosk --- neode-ui/src/views/OnboardingSeedGenerate.vue | 14 +++++++++++--- .../views/__tests__/OnboardingScrollCue.test.ts | 13 +++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) 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 () => { -- 2.54.0