main
  1#!/usr/bin/env bash
  2
  3# Aomi (Fedora CSB) Bootstrap Script
  4# Description: Installs Nix (Determinate) and deploys home-manager config
  5#
  6# Prerequisites: Fedora CSB installed, user has sudo access
  7# Usage: ./bootstrap.sh
  8#   Or remotely: ssh vdemeest@aomi.home 'bash -s' < imperative/aomi/bootstrap.sh
  9#
 10# Can also be sourced to run individual phases without poisoning the calling
 11# shell with errexit:
 12#   bash -c 'source imperative/aomi/bootstrap.sh && setup_crc'
 13
 14# Only enable strict mode when executed directly, not when sourced — otherwise
 15# a failing command would kill the interactive shell that sourced this file.
 16if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]]; then
 17	set -euo pipefail
 18fi
 19
 20readonly GREEN='\033[0;32m'
 21readonly YELLOW='\033[1;33m'
 22readonly RED='\033[0;31m'
 23readonly NC='\033[0m'
 24
 25REPO_URL="${REPO_URL:-https://git.sbr.pm/home.git}"
 26REPO_PATH="${REPO_PATH:-$HOME/src/home}"
 27SYSTEM_CONFIG="${SYSTEM_CONFIG:-aomi}"
 28
 29# CRC (OpenShift Local) — public mirror (no login required)
 30CRC_URL="${CRC_URL:-https://mirror.openshift.com/pub/openshift-v4/clients/crc/latest/crc-linux-amd64.tar.xz}"
 31CRC_PULL_SECRET="${CRC_PULL_SECRET:-$HOME/.crc/pull-secret.json}"
 32
 33log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
 34log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
 35log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
 36
 37check_root() {
 38	if [[ $EUID -eq 0 ]]; then
 39		log_error "Run as your regular user, not root (sudo is used when needed)"
 40		exit 1
 41	fi
 42}
 43
 44# --- Phase 1: Nix ---
 45
 46install_nix() {
 47	if command -v nix &>/dev/null; then
 48		log_info "Nix already installed: $(nix --version)"
 49		return 0
 50	fi
 51
 52	log_info "Installing Nix (Determinate Systems installer)..."
 53	curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
 54
 55	# Source nix for this session
 56	if [[ -f /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh ]]; then
 57		# shellcheck disable=SC1091
 58		. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
 59	fi
 60
 61	log_info "Nix installed: $(nix --version)"
 62}
 63
 64configure_nix() {
 65	local nix_conf="${XDG_CONFIG_HOME:-$HOME/.config}/nix/nix.conf"
 66	mkdir -p "$(dirname "$nix_conf")"
 67
 68	# Determinate installer enables flakes by default, but ensure our preferences
 69	if [[ ! -f "$nix_conf" ]] || ! grep -q 'use-xdg-base-directories' "$nix_conf" 2>/dev/null; then
 70		cat >"$nix_conf" <<-'EOF'
 71			experimental-features = nix-command flakes
 72			use-xdg-base-directories = true
 73			extra-substituters = http://okinawa.vpn:5000
 74			extra-trusted-public-keys = cache.okinawa.home:gp+IG0OaO4L/J0drL8OwmDtMPmdUq4kfLwg3mR8BkCs=
 75		EOF
 76		log_info "Nix config written to $nix_conf"
 77	else
 78		log_info "Nix config already exists"
 79	fi
 80}
 81
 82# --- Phase 2: Repository ---
 83
 84clone_repo() {
 85	if [[ -d "$REPO_PATH/.git" ]]; then
 86		log_info "Repository exists at $REPO_PATH, pulling..."
 87		git -C "$REPO_PATH" pull --ff-only || log_warn "Pull failed, continuing with existing"
 88		return 0
 89	fi
 90
 91	log_info "Cloning $REPO_URL$REPO_PATH"
 92	mkdir -p "$(dirname "$REPO_PATH")"
 93	git clone "$REPO_URL" "$REPO_PATH"
 94}
 95
 96# --- Phase 3: SELinux policy for Nix + systemd ---
 97
 98setup_selinux_policy() {
 99	if ! command -v getenforce &>/dev/null || [[ "$(getenforce)" == "Disabled" ]]; then
100		log_info "SELinux not enforcing, skipping policy"
101		return 0
102	fi
103
104	if sudo semodule -l 2>/dev/null | grep -q nix-systemd; then
105		log_info "SELinux nix-systemd policy already installed"
106		return 0
107	fi
108
109	log_info "Installing SELinux policy for Nix + systemd integration..."
110
111	# Ensure build tools are available
112	sudo dnf install -y checkpolicy policycoreutils-python-utils
113
114	local tmpdir
115	tmpdir=$(mktemp -d)
116
117	cat >"${tmpdir}/nix-systemd.te" <<'POLICY'
118module nix-systemd 1.0;
119
120require {
121    type init_t;
122    type default_t;
123    class file { read open getattr execute execute_no_trans map };
124    class dir { search getattr open read };
125    class lnk_file { read getattr };
126}
127
128# Allow systemd (init_t) to read unit files with default_t context
129# This occurs when system-manager symlinks units into /etc/systemd/system/
130# pointing to /nix/store paths which have default_t SELinux context
131allow init_t default_t:dir { search getattr open read };
132allow init_t default_t:file { read open getattr execute execute_no_trans map };
133allow init_t default_t:lnk_file { read getattr };
134POLICY
135
136	checkmodule -M -m -o "${tmpdir}/nix-systemd.mod" "${tmpdir}/nix-systemd.te"
137	semodule_package -o "${tmpdir}/nix-systemd.pp" -m "${tmpdir}/nix-systemd.mod"
138	sudo semodule -i "${tmpdir}/nix-systemd.pp"
139
140	rm -rf "${tmpdir}"
141	log_info "SELinux policy installed"
142}
143
144# --- Phase 4: System-manager ---
145
146# --- Phase 4: Native apps (dnf/flatpak) ---
147
148install_native_apps() {
149	log_info "Installing native apps..."
150
151	# Niri compositor + Wayland essentials + terminal
152	log_info "Installing niri, Wayland support, and kitty..."
153	sudo dnf install -y \
154		acpi \
155		brightnessctl \
156		podman podman-docker \
157		niri \
158		kitty \
159		xwayland-satellite \
160		xdg-desktop-portal-gnome \
161		xdg-desktop-portal-gtk \
162		libvirt
163
164	# Flatpak apps (sandboxed, auto-updating)
165	if ! command -v flatpak &>/dev/null; then
166		log_info "Installing flatpak..."
167		sudo dnf install -y flatpak
168		flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
169	fi
170
171	local flatpaks=(
172		com.slack.Slack
173		us.zoom.Zoom
174	)
175	for app in "${flatpaks[@]}"; do
176		if ! flatpak info "$app" &>/dev/null 2>&1; then
177			log_info "Installing $app..."
178			flatpak install -y flathub "$app"
179		else
180			log_info "$app already installed"
181		fi
182	done
183
184	# 1Password (dedicated RPM repo)
185	if ! rpm -q 1password &>/dev/null; then
186		log_info "Installing 1Password..."
187		sudo rpm --import https://downloads.1password.com/linux/keys/1password.asc
188		sudo tee /etc/yum.repos.d/1password.repo <<-'EOF'
189			[1password]
190			name=1Password Stable Channel
191			baseurl=https://downloads.1password.com/linux/rpm/stable/$basearch
192			enabled=1
193			gpgcheck=1
194			repo_gpgcheck=1
195			gpgkey=https://downloads.1password.com/linux/keys/1password.asc
196		EOF
197		sudo dnf install -y 1password 1password-cli
198	fi
199}
200
201# --- Phase 5: TPM 2.0 access ---
202
203setup_tpm() {
204	log_info "Configuring TPM 2.0 access..."
205
206	if [[ ! -e /dev/tpmrm0 && ! -e /dev/tpm0 ]]; then
207		log_warn "No TPM device found (/dev/tpm0, /dev/tpmrm0); skipping"
208		return 0
209	fi
210
211	# tpm2-tools gives tpm2_* utilities for inspection/testing
212	if ! command -v tpm2_pcrread &>/dev/null; then
213		sudo dnf install -y tpm2-tools
214	fi
215
216	# The tss group owns the TPM resource-manager device; membership is
217	# required for userspace access (age-plugin-tpm, ssh-tpm-agent, etc.)
218	if id -nG "$USER" | grep -qw tss; then
219		log_info "$USER already in tss group"
220	else
221		log_info "Adding $USER to tss group..."
222		sudo usermod -aG tss "$USER"
223		log_warn "Log out/in (or reboot) for tss group membership to take effect"
224	fi
225}
226
227# --- Phase 5: LUKS FIDO2 (YubiKey) ---
228
229setup_luks_fido2() {
230	local luks_dev
231	luks_dev=$(blkid -t TYPE=crypto_LUKS -o device 2>/dev/null | head -1)
232
233	if [[ -z "$luks_dev" ]]; then
234		log_warn "No LUKS device found; skipping FIDO2 enrollment"
235		return 0
236	fi
237
238	log_info "Found LUKS device: $luks_dev"
239
240	# Check if FIDO2 is already enrolled
241	if sudo systemd-cryptenroll "$luks_dev" --fido2-device=list 2>/dev/null | grep -q 'fido2'; then
242		log_info "FIDO2 already available"
243	else
244		log_warn "No FIDO2 device detected. Plug in YubiKey, then run:"
245		log_warn "  sudo systemd-cryptenroll $luks_dev --fido2-device=auto --fido2-with-client-pin=no"
246	fi
247
248	# Ensure dracut includes fido2 support in initramfs
249	if ! lsinitrd 2>/dev/null | grep -q fido2; then
250		log_info "Adding fido2 support to initramfs..."
251		sudo dracut --add fido2 -f
252		log_info "Initramfs rebuilt with fido2 support"
253	else
254		log_info "Initramfs already has fido2 support"
255	fi
256}
257
258# --- Phase 5b: lid-close behavior (systemd-logind) ---
259
260setup_lid() {
261	# Under niri there is no gnome-settings-daemon power plugin, so lid handling
262	# is governed entirely by systemd-logind (dconf/gsettings have no effect).
263	# Ignore lid close on AC and when docked (external monitor use), but still
264	# suspend on battery.
265	log_info "Configuring lid-close behavior (systemd-logind)..."
266
267	sudo install -d /etc/systemd/logind.conf.d
268	sudo tee /etc/systemd/logind.conf.d/10-lid.conf >/dev/null <<-'EOF'
269		[Login]
270		HandleLidSwitch=suspend
271		HandleLidSwitchExternalPower=ignore
272		HandleLidSwitchDocked=ignore
273	EOF
274
275	# logind reads this config on events; reload makes it pick up the new file
276	# without the session disruption a full restart can cause.
277	sudo systemctl reload systemd-logind
278	log_info "Lid behavior: suspend on battery, ignore on AC/docked"
279}
280
281# --- Phase 6: unscd for nix NSS resolution ---
282
283setup_unscd() {
284	# Nix glibc can't load system NSS modules (libnss_sss.so) so nix programs
285	# can't resolve LDAP/SSSD users. unscd creates /var/run/nscd/socket which
286	# nix glibc queries automatically. Must be built with system gcc/glibc
287	# so it can load system NSS modules.
288	if systemctl is-active --quiet unscd 2>/dev/null; then
289		log_info "unscd already running"
290		return 0
291	fi
292
293	log_info "Building and installing unscd for nix NSS resolution..."
294	sudo dnf install -y gcc make
295
296	local tmpdir
297	tmpdir=$(mktemp -d)
298	curl -sSL https://busybox.net/~vda/unscd/nscd-0.54.c -o "${tmpdir}/nscd.c"
299	# Newer glibc removed __nss_disable_nscd from headers; provide a stub
300	sed -i '/^void __nss_disable_nscd(/c\void __nss_disable_nscd(void (*hell)(size_t, struct traced_file*)) {}' "${tmpdir}/nscd.c"
301	PATH=/usr/bin:/usr/sbin /usr/bin/gcc -O2 -o "${tmpdir}/unscd" "${tmpdir}/nscd.c"
302	sudo install -m 755 "${tmpdir}/unscd" /usr/local/sbin/unscd
303	rm -rf "${tmpdir}"
304
305	# Create systemd service
306	sudo tee /etc/systemd/system/unscd.service >/dev/null <<-'EOF'
307		[Unit]
308		Description=Name Service Cache Daemon (unscd for nix)
309		After=sssd.service network.target
310
311		[Service]
312		Type=forking
313		ExecStart=/usr/local/sbin/unscd
314		Restart=on-failure
315
316		[Install]
317		WantedBy=multi-user.target
318	EOF
319
320	sudo systemctl daemon-reload
321	sudo systemctl enable --now unscd
322	log_info "unscd installed and running"
323}
324
325# --- Phase 6: Home-manager ---
326
327setup_home_manager() {
328	log_info "Setting up home-manager..."
329
330	# Ensure nix is in PATH
331	if ! command -v nix &>/dev/null; then
332		# shellcheck disable=SC1091
333		. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
334	fi
335
336	cd "$REPO_PATH"
337
338	# Build and activate home-manager
339	nix run home-manager -- switch --flake ".#vdemeest@${SYSTEM_CONFIG}"
340
341	log_info "Home-manager activated!"
342}
343
344# --- Phase 6: WireGuard key ---
345
346setup_power() {
347	log_info "Configuring power management..."
348
349	# Configure tuned-ppd mapping (PPD profiles → tuned profiles)
350	# All values must be unique per map (injective) for reversibility.
351	# AC:      power-saver→powersave, balanced→balanced, performance→latency-performance
352	# Battery: power-saver→powersave, balanced→balanced-battery, performance→balanced
353	if command -v tuned-adm &>/dev/null; then
354		log_info "Configuring tuned-ppd..."
355		sudo tee /etc/tuned/ppd.conf >/dev/null <<-'EOF'
356			[main]
357			default=performance
358			battery_detection=true
359			sysfs_acpi_monitor=true
360
361			[profiles]
362			power-saver=powersave
363			balanced=balanced
364			performance=latency-performance
365
366			[battery]
367			balanced=balanced-battery
368			performance=balanced
369		EOF
370		sudo systemctl restart tuned tuned-ppd
371		log_info "tuned-ppd configured"
372	fi
373}
374
375setup_wireguard() {
376	# Use Fedora-native wg-quick service (proper SELinux context)
377	# Config file is managed by fedoraConfigs in /etc/wireguard/wg0.conf
378	log_info "Setting up WireGuard (native service)..."
379	sudo dnf install -y wireguard-tools
380	sudo systemctl enable wg-quick@wg0
381
382	# Trust wg0 interface in firewall (allows SSH and all traffic over VPN)
383	if command -v firewall-cmd &>/dev/null; then
384		log_info "Adding wg0 to firewalld trusted zone..."
385		sudo firewall-cmd --zone=trusted --add-interface=wg0 --permanent
386		sudo firewall-cmd --reload
387	fi
388
389	if [[ -f /etc/wireguard/private.key ]]; then
390		log_info "WireGuard private key exists, starting service..."
391		sudo systemctl start wg-quick@wg0
392		return 0
393	fi
394
395	log_warn "WireGuard private key not found at /etc/wireguard/private.key"
396	log_warn ""
397	log_warn "To set up WireGuard, either:"
398	log_warn "  1. Copy existing key:  sudo cp /path/to/backup/private.key /etc/wireguard/"
399	log_warn "  2. Generate new key:   wg genkey | sudo tee /etc/wireguard/private.key"
400	log_warn "     Then update globals.nix with new pubkey: sudo cat /etc/wireguard/private.key | wg pubkey"
401	log_warn ""
402	log_warn "After placing the key:  sudo chmod 600 /etc/wireguard/private.key"
403	log_warn "Then re-run:  sudo systemctl restart wg-quick@wg0"
404}
405
406# --- Phase 7: YubiKey sudo (pam-u2f) ---
407
408setup_yubikey_sudo() {
409	if ! rpm -q pam-u2f &>/dev/null; then
410		log_info "Installing pam-u2f..."
411		sudo dnf install -y pam-u2f pamu2fcfg
412	else
413		log_info "pam-u2f already installed"
414	fi
415
416	local u2f_keys="${HOME}/.config/Yubico/u2f_keys"
417	if [[ ! -f "$u2f_keys" ]]; then
418		log_info "Registering YubiKey for PAM U2F — touch the key when it blinks..."
419		mkdir -p "$(dirname "$u2f_keys")"
420		pamu2fcfg >"$u2f_keys"
421		log_info "YubiKey registered at $u2f_keys"
422		log_warn "To add a backup key later: pamu2fcfg -n >> $u2f_keys"
423	else
424		log_info "YubiKey already registered at $u2f_keys"
425	fi
426
427	# Configure sudo to accept YubiKey touch (sufficient = no password needed)
428	local sudo_pam="/etc/pam.d/sudo"
429	if ! grep -q pam_u2f.so "$sudo_pam" 2>/dev/null; then
430		log_info "Adding pam_u2f to $sudo_pam (sufficient — falls back to password)..."
431		sudo sed -i '1a auth       sufficient   pam_u2f.so' "$sudo_pam"
432		log_info "YubiKey sudo configured"
433	else
434		log_info "pam_u2f already in $sudo_pam"
435	fi
436}
437
438# --- Phase 7: CRC (OpenShift Local) ---
439
440setup_crc() {
441	# CRC runs a single-node OpenShift cluster in a libvirt VM. It requires
442	# libvirt + NetworkManager (installed via install_native_apps) and a Red Hat
443	# pull secret (download from https://console.redhat.com/openshift/create/local).
444	log_info "Setting up CRC (OpenShift Local)..."
445
446	# CRC needs the user in the libvirt group to manage the VM
447	if ! id -nG "$USER" | grep -qw libvirt; then
448		log_info "Adding $USER to libvirt group..."
449		sudo usermod -aG libvirt "$USER"
450		log_warn "Log out/in (or reboot) for libvirt group membership to take effect"
451	fi
452	sudo systemctl enable --now libvirtd 2>/dev/null || true
453
454	# Install the crc binary into ~/bin if missing
455	if command -v crc &>/dev/null; then
456		log_info "crc already installed: $(crc version 2>/dev/null | head -1)"
457	else
458		log_info "Downloading crc from $CRC_URL ..."
459		local tmpdir
460		tmpdir=$(mktemp -d)
461		if curl -fSL "$CRC_URL" -o "${tmpdir}/crc-linux-amd64.tar.xz"; then
462			tar -xf "${tmpdir}/crc-linux-amd64.tar.xz" -C "${tmpdir}"
463			mkdir -p "$HOME/bin"
464			install -m 755 "${tmpdir}"/crc-linux-*-amd64/crc "$HOME/bin/crc"
465			log_info "crc installed to $HOME/bin/crc"
466		else
467			log_warn "Failed to download crc; skipping (set CRC_URL or install manually)"
468			rm -rf "${tmpdir}"
469			return 0
470		fi
471		rm -rf "${tmpdir}"
472	fi
473
474	export PATH="$PATH:$HOME/bin"
475
476	# Pull secret is required for `crc start`; warn rather than fail
477	if [[ ! -f "$CRC_PULL_SECRET" ]]; then
478		log_warn "CRC pull secret not found at $CRC_PULL_SECRET"
479		log_warn "  Download it from https://console.redhat.com/openshift/create/local"
480		log_warn "  and save it there, then run: crc setup && crc start --pull-secret-file $CRC_PULL_SECRET"
481		log_warn "Skipping 'crc setup' until pull secret is present"
482		return 0
483	fi
484
485	log_info "Running 'crc setup'..."
486	crc setup
487
488	log_info "CRC ready. Start the cluster with:"
489	log_info "  crc start --pull-secret-file $CRC_PULL_SECRET"
490}
491
492# --- Phase 7: summary ---
493
494print_summary() {
495	log_info ""
496	log_info "╔══════════════════════════════════════════╗"
497	log_info "║     Aomi (Fedora CSB) Bootstrap Done     ║"
498	log_info "╠══════════════════════════════════════════╣"
499	log_info "║  Nix:            ✓ installed             ║"
500	log_info "║  Home-manager:   ✓ activated             ║"
501	log_info "║  Native apps:    ✓ installed             ║"
502	log_info "║  CRC:            ✓ installed (needs pull) ║"
503	log_info "╠══════════════════════════════════════════╣"
504	log_info "║  Rebuild home:                           ║"
505	log_info "║    make host/aomi/switch                 ║"
506	log_info "╚══════════════════════════════════════════╝"
507}
508
509# --- Phase 8: Shell setup ---
510
511setup_shell() {
512	local zsh_path="$HOME/.local/state/nix/profile/bin/zsh"
513
514	if ! grep -q "$zsh_path" /etc/shells 2>/dev/null; then
515		log_info "Adding nix zsh to /etc/shells..."
516		echo "$zsh_path" | sudo tee -a /etc/shells
517	fi
518
519	# CSB uses LDAP/FreeIPA, chsh won't work — use bashrc exec instead
520	if ! grep -q 'exec.*zsh' ~/.bashrc 2>/dev/null; then
521		log_info "Configuring bash to exec into zsh..."
522		# shellcheck disable=SC2016
523		echo '[[ $- == *i* && -x "$HOME/.local/state/nix/profile/bin/zsh" ]] && exec "$HOME/.local/state/nix/profile/bin/zsh"' >>~/.bashrc
524	fi
525
526	log_info "Shell configured (zsh via bashrc exec)"
527}
528
529# --- Main ---
530
531main() {
532	log_info "Bootstrapping aomi (Fedora CSB)..."
533	log_info "  Repo:   $REPO_URL$REPO_PATH"
534	log_info "  Config: $SYSTEM_CONFIG"
535	echo
536
537	check_root
538	install_nix
539	configure_nix
540	clone_repo
541	setup_selinux_policy
542	install_native_apps
543	setup_tpm
544	setup_luks_fido2
545	setup_lid
546	setup_unscd
547	setup_power
548	setup_wireguard
549	setup_yubikey_sudo
550	setup_shell
551	setup_home_manager
552	setup_crc
553	print_summary
554}
555
556# Only run main when executed directly, not when sourced. This lets you run
557# individual phases, e.g. (use bash, the script targets bash):
558#   source imperative/aomi/bootstrap.sh
559#   setup_crc
560if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]]; then
561	main "$@"
562fi