[WIP] First-install owner setup and keyboard-accessible seed confirmation #168

Draft
chaum wants to merge 14 commits from pr/feat/first-install-owner-network-20261112 into main
20 changed files with 778 additions and 132 deletions
+19 -1
View File
@@ -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<serde_json::Value>,
) -> Result<serde_json::Value> {
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?;
@@ -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,
@@ -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",
+68 -2
View File
@@ -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<bool> {
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.
+1 -1
View File
@@ -553,7 +553,7 @@ pub(super) fn set_login_password(
end: Duration,
) -> Result<Vec<u8>> {
ensure!(now()? < end, "login password update expired");
let input = hash.input();
let input = hash.input()?;
run_with_input(
executable(Program::Chpasswd)?,
vec!["--encrypted".into()],
+45 -4
View File
@@ -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<String> {
Zeroizing::new(format!("archipelago:{}\n", self.0))
pub(super) fn input(&self) -> Result<Zeroizing<String>> {
Ok(Zeroizing::new(format!(
"{}:{}\n",
selected_login_account()?,
self.0
)))
}
}
pub(crate) fn selected_login_account() -> Result<String> {
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(),
+14
View File
@@ -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();
}
@@ -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 <<EOF
127.0.0.1 localhost
127.0.1.1 archipelago
::1 localhost ip6-localhost ip6-loopback
EOF
chmod 644 /mnt/target/etc/hosts
# Hostname and human owner account were configured from the live installer form.
# Pre-create container storage dirs (ReadWritePaths needs these to exist)
mkdir -p /mnt/target/home/archipelago/.local/share/containers
@@ -2951,7 +3005,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
@@ -2959,8 +3013,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
@@ -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://<this machine's IP>${NC}"
echo ""
p "${WHITE} SSH ssh archipelago@<IP>${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
+53 -36
View File
@@ -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"
}
+3
View File
@@ -0,0 +1,3 @@
▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█
█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █
▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1 @@
YOUR NODE. YOUR KEYS. YOUR WAY.
+26 -3
View File
@@ -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)
+9
View File
@@ -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')
+15 -2
View File
@@ -184,6 +184,7 @@
<div v-if="isDemo" class="mb-4 p-3 bg-orange-500/15 border border-orange-400/30 rounded-lg text-orange-100 text-sm text-center">
🎮 Demo mode — Password: <span class="font-mono font-semibold">{{ DEMO_PASSWORD }}</span>
</div>
<p v-if="installerPasswordPending" class="mb-4 text-sm text-white/70 text-center">Log in with the password you chose during installation. You can add a signer next.</p>
<!-- All auth inputs opt out of controller-nav's Enter→click-next-button
pattern (data-controller-no-submit): they submit via their own Enter
@@ -310,6 +311,7 @@ const confirmPassword = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const isSetup = ref(false)
const installerPasswordPending = ref(false)
const whooshAway = ref(false)
const requiresTotp = ref(false)
const totpCode = ref('')
@@ -458,7 +460,8 @@ onMounted(async () => {
try {
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
isSetup.value = Boolean(result)
isSetupMode.value = !isSetup.value
installerPasswordPending.value = !isSetup.value && Boolean(await rpcClient.call<boolean>({ method: 'auth.isInstallerPasswordPending', params: {}, timeout: 8000 }))
isSetupMode.value = !isSetup.value && !installerPasswordPending.value
} catch {
isSetup.value = true
isSetupMode.value = false
@@ -486,7 +489,8 @@ async function retrySetupProbe() {
try {
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
isSetup.value = Boolean(result)
isSetupMode.value = !isSetup.value
installerPasswordPending.value = !isSetup.value && Boolean(await rpcClient.call<boolean>({ method: 'auth.isInstallerPasswordPending', params: {}, timeout: 8000 }))
isSetupMode.value = !isSetup.value && !installerPasswordPending.value
if (isSetup.value) void probeSignerLogin()
return
} catch {
@@ -607,6 +611,15 @@ async function handleLogin() {
store.logout()
return
}
if (installerPasswordPending.value) {
localStorage.setItem('neode_onboarding_complete', '1')
if (signerPortOk) {
connectingSigner.value = true
return
}
await finishConnectSigner()
return
}
stopSynthwave()
whooshAway.value = true
playLoginSuccessWhoosh()
+10 -4
View File
@@ -15,7 +15,9 @@
<!-- Description -->
<p class="text-sm sm:text-base md:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6 sm:mb-8">
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.' }}
</p>
<!-- Features Grid -->
@@ -51,14 +53,14 @@
<span class="text-white/60">{{ fipsLabel }}</span>
</div>
<!-- Set Password Button -->
<p class="text-xs text-white/50 mb-3">You'll create your node password next</p>
<!-- Installer password is claimed by the login that follows. -->
<p class="text-xs text-white/50 mb-3">{{ installerPasswordPending === true ? 'Use your installation password' : installerPasswordPending === false ? "You'll create your node password next" : 'Continue to your node' }}</p>
<button
ref="setPasswordButton"
@click="goToLogin"
class="path-action-button path-action-button--continue mx-auto"
>
Set Password
{{ installerPasswordPending === true ? 'Log In' : installerPasswordPending === false ? 'Set Password' : 'Continue' }}
</button>
</div>
</div>
@@ -72,6 +74,7 @@ import { rpcClient } from '@/api/rpc-client'
import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const installerPasswordPending = ref<boolean | null>(null)
const setPasswordButton = ref<HTMLButtonElement | null>(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<boolean>({ method: 'auth.isInstallerPasswordPending' })
.then((pending) => { installerPasswordPending.value = Boolean(pending) })
.catch(() => {})
setTimeout(() => {
setPasswordButton.value?.focus({ preventScroll: true })
}, 500)
+11 -3
View File
@@ -97,9 +97,11 @@
<!-- Confirmation Checkbox -->
<label ref="confirmLabel" class="flex items-center justify-center gap-3 mt-3 cursor-pointer select-none">
<input
ref="confirmCheckbox"
v-model="confirmed"
type="checkbox"
class="w-5 h-5 rounded border-white/20 bg-black/40 accent-orange-400"
class="w-5 h-5 rounded border-white/20 bg-black/40 accent-orange-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400 focus-visible:ring-offset-2 focus-visible:ring-offset-black"
@focus="scrollConfirmIntoView"
/>
<span class="text-xs sm:text-sm text-white/80">I have written down these 24 words in a safe place</span>
</label>
@@ -157,6 +159,7 @@ const words = ref<string[]>([])
const scrollContainer = ref<HTMLElement | null>(null)
const contentWrapper = ref<HTMLElement | null>(null)
const confirmLabel = ref<HTMLElement | null>(null)
const confirmCheckbox = ref<HTMLInputElement | null>(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' })
}
@@ -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 () => {
+204
View File
@@ -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" <<HOSTS || return 1
127.0.0.1 localhost
127.0.1.1 $INSTALLER_OWNER_HOSTNAME
::1 localhost ip6-localhost ip6-loopback
HOSTS
chmod 644 "$target/etc/hosts" || return 1
if [[ -n "${INSTALLER_WIFI_UUID:-}" ]]; then
[[ -n "$INSTALLER_WIFI_PROFILE" && -f "$INSTALLER_WIFI_PROFILE" &&
! -L "$INSTALLER_WIFI_PROFILE" ]] || return 1
install -d -m 700 "$target/etc/NetworkManager/system-connections" || return 1
install -m 600 "$INSTALLER_WIFI_PROFILE" "$target/etc/NetworkManager/system-connections/archipelago-wifi.nmconnection" || return 1
fi
# 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 >/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=''
}
+162
View File
@@ -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()