[WIP] First-install owner setup and keyboard-accessible seed confirmation #168
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█
|
||||
█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █
|
||||
▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
YOUR NODE. YOUR KEYS. YOUR WAY.
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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=''
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user