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