commit 6e8d1c93923f3eb1648f6daa033aee2a1579bddc Author: Damien Coles Date: Thu Feb 5 18:05:06 2026 -0500 public-ready-init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7317af --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +.DS_Store +*.swp +*.swo +*~ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/gentoo-legion-python.iml b/.idea/gentoo-legion-python.iml new file mode 100644 index 0000000..7a6134d --- /dev/null +++ b/.idea/gentoo-legion-python.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..f90c586 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.zshrc b/.zshrc new file mode 100644 index 0000000..40ae784 --- /dev/null +++ b/.zshrc @@ -0,0 +1,130 @@ +# ~/.zshrc + +export TERM="xterm-256color" + +# --- Basic Zsh Configuration --- + +# Set default editor (for consistency) +export EDITOR="nano" # Or "vim", "nvim", etc. + +# History settings +HISTFILE=~/.zsh_history +SAVEHIST=10000 # Number of history entries to save +HISTSIZE=10000 # Number of history entries to keep in memory +setopt appendhistory # Append history to the history file +setopt sharehistory # Share history among all sessions +setopt hist_ignore_dups # Ignore duplicate commands +setopt hist_verify # Ask for confirmation before executing history expansion + +# Autocompletion +autoload -U compinit +compinit + +# Better completion styling +zstyle ':completion:*' menu select +zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' 'r:|[._-]=* r:|=*' 'l:|=* r:|=*' +zstyle ':completion:*' list-colors "${(s.:.)LS_COLORS}" +zstyle ':completion:*' group-name '' +zstyle ':completion:*:descriptions' format '%F{yellow}-- %d --%f' +zstyle ':completion:*:warnings' format '%F{red}-- no matches found --%f' + +# Zsh options +setopt autocd # Change directory just by typing directory name +setopt extendedglob # Enable extended globbing (e.g., removal of multiple files) +setopt no_beep # Disable the bell +setopt correct # Correct common typos +setopt complete_in_word # Complete from the middle of a word + +# Load any profile environment variables +if [[ -f /etc/zsh/zprofile ]]; then + source /etc/zsh/zprofile +fi +if [[ -f /etc/zsh/zshrc ]]; then + source /etc/zsh/zshrc +fi + +# --- fzf Integration --- + +# Load fzf key bindings and completions. +# These files are installed by app-shells/fzf +if [[ -f "/usr/share/fzf/key-bindings.zsh" ]]; then + source "/usr/share/fzf/key-bindings.zsh" +fi +if [[ -f "/usr/share/fzf/completion.zsh" ]]; then + source "/usr/share/fzf/completion.zsh" +fi + +# --- Aliases --- + +# General aliases +alias c='clear' +alias df='df -h' +alias du='du -sh' +alias ip='ip -c a' +alias ping='ping -c 5' +alias top='htop' # Assuming htop is installed (it's in your list) +alias nano='nano -c' # Show cursor position + +# Git aliases +alias g='git' +alias gs='git status' +alias ga='git add .' +alias gc='git commit -m' +alias gp='git push' +alias gl='git log --oneline --decorate --all --graph' + +# --- lsd Aliases --- +# Replace ls with lsd for better visuals +alias ls='lsd' +alias l='lsd -F' # List only files, no directories +alias ll='lsd -l' # Long format +alias la='lsd -a' # All files +alias lld='lsd -ld' # Long format, directories only +alias lla='lsd -la' # Long format, all files +alias lt='lsd --tree' # Tree view + +# Wireguard VPN control function +vpn() { + case "$1" in + up) + sudo wg-quick up nexus + ;; + down) + sudo wg-quick down nexus + ;; + status) + sudo wg show nexus 2>/dev/null || echo "VPN is down" + ;; + *) + echo "Usage: vpn {up|down|status}" + return 1 + ;; + esac +} + +# Neovide wrapper - automatically backgrounds the process and closes terminal +neovide() { + command neovide "$@" &>/dev/null & + disown + exit +} + +# Initialize starship prompt +eval "$(starship init zsh)" + +# Initialize zoxide (smarter cd) +eval "$(zoxide init zsh)" + +export PATH=$PATH:$HOME/.local/bin + +# Autosuggestions from history (fish-style) +source /usr/share/zsh/site-functions/zsh-autosuggestions.zsh +ZSH_AUTOSUGGEST_STRATEGY=(history completion) +ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=8' + +# Ctrl+→ accepts one word from autosuggestion +bindkey '^[[1;5C' forward-word + +# Syntax highlighting (must be at the end) +source /usr/share/zsh/site-functions/zsh-syntax-highlighting.zsh +[ -s "/home/damien/.jabba/jabba.sh" ] && source "/home/damien/.jabba/jabba.sh" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..edea9db --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Gentoo Legion Installer + +Automated Gentoo Linux installer for Lenovo Legion laptops with AMD + NVIDIA hybrid graphics. + +## Features + +- **Python-based installer** with idempotent operations (safe to re-run) +- **LUKS2 encryption** with Btrfs subvolumes +- **Hybrid GPU support** (AMD iGPU + NVIDIA dGPU) +- **OpenRC** init system +- **Hyprland** desktop environment (optional) +- **Fingerprint authentication** for Elan readers (optional) + +## Hardware + +Tested on Legion S7 15ACH6: +- AMD Ryzen 9 5900HX +- AMD Radeon Vega (iGPU) +- NVIDIA GeForce RTX 3050 Ti Mobile (dGPU) + +Should work on other Legion models with similar hardware. + +## Quick Start + +```bash +# Boot Gentoo live environment, then: +git clone https://github.com//gentoo-legion-python /root/gentoo +cd /root/gentoo +python setup.py --install +``` + +See [procedure.md](procedure.md) for complete installation guide. + +## Repository Structure + +``` +. +├── setup.py # Main installer entry point +├── procedure.md # Complete installation guide +├── install/ # Python installer modules +│ ├── disk.py # LUKS + Btrfs setup +│ ├── stage3.py # Stage3 download/extract +│ ├── sync.py # Portage sync, profile, locale +│ ├── services.py # OpenRC services setup +│ ├── users.py # User creation, shell config +│ ├── nvidia.py # NVIDIA driver setup +│ ├── bootloader.py # GRUB installation +│ └── fingerprint.py # Fingerprint auth (optional) +├── portage/ # Portage configuration +│ ├── make.conf +│ ├── package.use/ +│ ├── package.accept_keywords/ +│ └── sets/ # @hyprland package set +├── dracut.conf.d/ # Initramfs configs +├── conf.d/ # OpenRC service configs +├── iptables/ # Firewall rules +├── hypr/ # Hyprland configs +├── .zshrc # Shell configuration +└── starship.toml # Prompt theme +``` + +## Customization + +Before running, you may want to customize: + +1. **portage/make.conf** - CPU flags, mirrors, features +2. **install/sync.py** - Timezone, locale, hostname +3. **install/disk.py** - Swap size, partition layout +4. **iptables/*.rules** - Firewall rules for your network + +## License + +MIT License - See [LICENSE](LICENSE) diff --git a/conf.d/dmcrypt b/conf.d/dmcrypt new file mode 100644 index 0000000..ce5dfee --- /dev/null +++ b/conf.d/dmcrypt @@ -0,0 +1,6 @@ +# /etc/conf.d/dmcrypt - Encrypted swap configuration + +# Encrypted swap with random key (no hibernate support) +# Key is regenerated each boot from /dev/urandom +swap=cryptswap +source="/dev/nvme0n1p2" diff --git a/conf.d/ip6tables b/conf.d/ip6tables new file mode 100644 index 0000000..8cb2e8c --- /dev/null +++ b/conf.d/ip6tables @@ -0,0 +1,7 @@ +# /etc/conf.d/ip6tables + +# Don't save state on service stop (use static rules file) +SAVE_ON_STOP="no" + +# Rules file location +IP6TABLES_SAVE="/etc/iptables/ip6tables.rules" diff --git a/conf.d/iptables b/conf.d/iptables new file mode 100644 index 0000000..ac43374 --- /dev/null +++ b/conf.d/iptables @@ -0,0 +1,7 @@ +# /etc/conf.d/iptables + +# Don't save state on service stop (use static rules file) +SAVE_ON_STOP="no" + +# Rules file location +IPTABLES_SAVE="/etc/iptables/iptables.rules" diff --git a/conf.d/snapper b/conf.d/snapper new file mode 100644 index 0000000..1f9f7ac --- /dev/null +++ b/conf.d/snapper @@ -0,0 +1,4 @@ +# /etc/conf.d/snapper - Snapper configuration +# List of snapper configs to manage with hourly cron job + +SNAPPER_CONFIGS="root" diff --git a/dracut.conf.d/crypt.conf b/dracut.conf.d/crypt.conf new file mode 100644 index 0000000..abd9f26 --- /dev/null +++ b/dracut.conf.d/crypt.conf @@ -0,0 +1,2 @@ +# LUKS support for encrypted root +add_dracutmodules+=" crypt " diff --git a/dracut.conf.d/nvidia.conf b/dracut.conf.d/nvidia.conf new file mode 100644 index 0000000..ee91fc4 --- /dev/null +++ b/dracut.conf.d/nvidia.conf @@ -0,0 +1,4 @@ +# NVIDIA early KMS for Plymouth +# Loads NVIDIA modules in initramfs for seamless graphical boot +add_drivers+=" nvidia nvidia_modeset nvidia_uvm nvidia_drm " +force_drivers+=" nvidia nvidia_modeset nvidia_uvm nvidia_drm " diff --git a/hypr/ENVariables.conf b/hypr/ENVariables.conf new file mode 100644 index 0000000..d460a06 --- /dev/null +++ b/hypr/ENVariables.conf @@ -0,0 +1,55 @@ +# /* ---- 💫 https://github.com/JaKooLit 💫 ---- */ # +# Environment variables. See https://wiki.hyprland.org/Configuring/Environment-variables/ + +# Set your defaults editor through ENV in ~/.config/hypr/UserConfigs/01-UserDefaults.conf + +### QT Variables ### +# env = QT_AUTO_SCREEN_SCALE_FACTOR,1 +# env = QT_WAYLAND_DISABLE_WINDOWDECORATION,1 +# env = QT_QPA_PLATFORMTHEME,qt5ct +# env = QT_QPA_PLATFORMTHEME,qt6ct + +### xwayland apps scale fix (useful if you are use monitor scaling). ### +# Set same value if you use scaling in Monitors.conf +# 1 is 100% 1.5 is 150% +# see https://wiki.hyprland.org/Configuring/XWayland/ +# env = GDK_SCALE,1 +# env = QT_SCALE_FACTOR,1 + +### NVIDIA ### +# This is from Hyprland Wiki. Below will be activated nvidia gpu detected +# See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables + +#env = LIBVA_DRIVER_NAME,nvidia +#env = __GLX_VENDOR_LIBRARY_NAME,nvidia +#env = NVD_BACKEND,direct +#env = GSK_RENDERER,ngl + +### additional ENV's for nvidia. Caution, activate with care ### +#env = GBM_BACKEND,nvidia-drm +#env = __GL_GSYNC_ALLOWED,1 #adaptive Vsync +#env = __NV_PRIME_RENDER_OFFLOAD,1 +#env = __VK_LAYER_NV_optimus,NVIDIA_only +#env = WLR_DRM_NO_ATOMIC,1 + +### FOR VM and POSSIBLY NVIDIA ### +# LIBGL_ALWAYS_SOFTWARE software mesa rendering +#env = LIBGL_ALWAYS_SOFTWARE,1 # Warning. May cause hyprland to crash +#env = WLR_RENDERER_ALLOW_SOFTWARE,1 + +### nvidia firefox ### +# check this post https://github.com/elFarto/nvidia-vaapi-driver#configuration +#env = MOZ_DISABLE_RDD_SANDBOX,1 +#env = EGL_PLATFORM,wayland + +### Aquamarine Environment Variables (Hyprland > 0.45) ### +# https://wiki.hyprland.org/Configuring/Environment-variables/#aquamarine-environment-variables +# env = AQ_TRACE,1 # Enables more verbose logging. +env = AQ_DRM_DEVICES,/dev/dri/card1:/dev/dri/card0 # AMD primary, NVIDIA for external displays +env = AQ_MGPU_NO_EXPLICIT,1 # Disables explicit syncing on mgpu buffers (fixes pink screen) +# env = AQ_NO_MODIFIERS,1 # Disables modifiers for DRM buffers + +### Cursor ### +env = XCURSOR_THEME,Bibata-Modern-Ice +env = XCURSOR_SIZE,24 + diff --git a/hypr/monitors.conf b/hypr/monitors.conf new file mode 100644 index 0000000..cdf77d4 --- /dev/null +++ b/hypr/monitors.conf @@ -0,0 +1,57 @@ +# /* ---- 💫 https://github.com/JaKooLit 💫 ---- */ # +# default Monitor config + +# *********************************************************** # +# +# NOTE: This will be overwritten by NWG-Displays +# once you use and click apply. You can still find this +# default at ~/.config/hypr/Monitor_Profiles/default.conf +# +# *********************************************************** # + + +# Monitor Configuration +# See Hyprland wiki for more details +# https://wiki.hyprland.org/Configuring/Monitors/ +# Configure your Display resolution, offset, scale and Monitors here, use `hyprctl monitors` to get the info. + +# Triple monitor layout: DP-1 (left) | eDP-2 (center/laptop) | DP-2 (right) +monitor=DP-1, 1920x1080@60, 0x0, 1 +monitor=eDP-2, 1920x1080@60, 1920x0, 1 +monitor=DP-2, 1920x1080@60, 3840x0, 1 + +# NOTE: for laptop, kindly check notes in Laptops.conf regarding display +# Created this inorder for the monitor display to not wake up if not intended. +# See here: https://github.com/hyprwm/Hyprland/issues/4090 + +# Some examples to set your own monitor +#monitor = eDP-1, preferred, auto, 1 +#monitor = eDP-1, 2560x1440@165, 0x0, 1 #own screen +#monitor = DP-3, 1920x1080@240, auto, 1 +#monitor = DP-1, preferred, auto, 1 +#monitor = HDMI-A-1, preferred,auto,1 + +# QEMU-KVM, virtual box or vmware +#monitor = Virtual-1, 1920x1080@60,auto,1 + +# to disable a monitor +#monitor=name,disable + +# Mirror samples +#monitor=DP-3,1920x1080@60,0x0,1,mirror,DP-2 +#monitor=,preferred,auto,1,mirror,eDP-1 +#monitor=HDMI-A-1,2560x1440@144,0x0,1,mirror,eDP-1 + +# 10 bit monitor support - See wiki https://wiki.hyprland.org/Configuring/Monitors/#10-bit-support - See NOTES below +# NOTE: Colors registered in Hyprland (e.g. the border color) do not support 10 bit. +# NOTE: Some applications do not support screen capture with 10 bit enabled. (Screen captures like OBS may render black screen) +# monitor=,preferred,auto,1,bitdepth,10 + +#monitor=eDP-1,transform,0 +#monitor=eDP-1,addreserved,10,10,10,49 + +# workspaces - Monitor rules +# https://wiki.hyprland.org/Configuring/Workspace-Rules/ +# SUPER E - Workspace-Rules +# See ~/.config/hypr/UserConfigs/WorkspaceRules.conf + diff --git a/hypr/wlogout-layout b/hypr/wlogout-layout new file mode 100644 index 0000000..ac3097e --- /dev/null +++ b/hypr/wlogout-layout @@ -0,0 +1,30 @@ +{ + "label" : "lock", + "action" : "$HOME/.config/hypr/scripts/LockScreen.sh", + "text" : "Lock", + "keybind" : "l" +} +{ + "label" : "reboot", + "action" : "loginctl reboot", + "text" : "Reboot", + "keybind" : "r" +} +{ + "label" : "shutdown", + "action" : "loginctl poweroff", + "text" : "Shutdown", + "keybind" : "s" +} +{ + "label" : "logout", + "action" : "hyprctl dispatch exit 0", + "text" : "Logout", + "keybind" : "e" +} +{ + "label" : "suspend", + "action" : "loginctl suspend", + "text" : "Suspend", + "keybind" : "u" +} diff --git a/install/__init__.py b/install/__init__.py new file mode 100644 index 0000000..7b9fbb3 --- /dev/null +++ b/install/__init__.py @@ -0,0 +1,3 @@ +"""Gentoo installation automation for Legion S7 15ACH6.""" + +__version__ = "4.0.0" diff --git a/install/bootloader.py b/install/bootloader.py new file mode 100644 index 0000000..1878842 --- /dev/null +++ b/install/bootloader.py @@ -0,0 +1,227 @@ +"""GRUB bootloader installation and configuration.""" + +import re +import shutil +from pathlib import Path + +from .utils import info, success, error, warn, run, emerge + + +def emerge_grub() -> None: + """Install GRUB bootloader.""" + info("=== Installing GRUB ===") + + emerge("sys-boot/grub") + + success("GRUB installed.") + + +def install_grub_efi() -> None: + """Install GRUB to EFI system partition.""" + info("=== Installing GRUB to EFI ===") + + run( + "grub-install", + "--target=x86_64-efi", + "--efi-directory=/boot", + "--bootloader-id=Gentoo", + ) + + success("GRUB installed to EFI partition.") + + +def configure_grub() -> None: + """Configure GRUB for LUKS + NVIDIA.""" + info("=== Configuring GRUB ===") + + grub_default = Path("/etc/default/grub") + + if not grub_default.exists(): + error("/etc/default/grub not found") + return + + content = grub_default.read_text() + changes_made = False + + # Settings to apply (use regex for robust matching of commented/uncommented lines) + settings = { + "GRUB_CMDLINE_LINUX_DEFAULT": '"nvidia_drm.modeset=1 acpi_backlight=native"', + "GRUB_ENABLE_CRYPTODISK": "y", + "GRUB_GFXMODE": "1920x1080", + } + + for key, value in settings.items(): + full_setting = f'{key}={value}' + pattern = rf'^#?\s*{key}=.*$' + + if re.search(pattern, content, re.MULTILINE): + # Replace existing line (commented or not) + new_content = re.sub(pattern, full_setting, content, flags=re.MULTILINE) + if new_content != content: + content = new_content + changes_made = True + else: + # Append if not found at all + content += f"\n{full_setting}" + changes_made = True + + if changes_made: + grub_default.write_text(content) + success("GRUB configured for LUKS + NVIDIA + HiDPI") + print(" - GRUB_CMDLINE_LINUX_DEFAULT: nvidia_drm.modeset=1 acpi_backlight=native") + print(" - GRUB_ENABLE_CRYPTODISK: y") + print(" - GRUB_GFXMODE: 1920x1080") + else: + info("GRUB already configured") + + +def generate_grub_config() -> None: + """Generate GRUB configuration.""" + info("=== Generating GRUB Config ===") + + run("grub-mkconfig", "-o", "/boot/grub/grub.cfg") + + success("GRUB config generated.") + + +def copy_dracut_crypt_config(source_dir: Path) -> None: + """Copy LUKS dracut configuration.""" + info("=== Copying Dracut Crypt Config ===") + + dracut_src = source_dir / "dracut.conf.d" / "crypt.conf" + dracut_dst = Path("/etc/dracut.conf.d") + dracut_dst.mkdir(parents=True, exist_ok=True) + + if dracut_src.exists(): + shutil.copy2(dracut_src, dracut_dst / "crypt.conf") + success("Copied crypt.conf to /etc/dracut.conf.d/") + else: + # Create default if not in source + crypt_conf = dracut_dst / "crypt.conf" + crypt_conf.write_text( + "# LUKS support for encrypted root\n" + 'add_dracutmodules+=" crypt "\n' + ) + success("Created default /etc/dracut.conf.d/crypt.conf") + + +def verify_initramfs() -> None: + """Verify initramfs has required modules for boot.""" + info("=== Verifying Initramfs ===") + + # Find latest initramfs + boot = Path("/boot") + initramfs_files = sorted(boot.glob("initramfs-*.img"), reverse=True) + + if not initramfs_files: + error("No initramfs found in /boot") + return + + initramfs = initramfs_files[0] + info(f"Checking {initramfs.name}...") + + result = run("lsinitrd", str(initramfs), capture=True, check=False) + + if not result.ok or not result.stdout: + error("Could not inspect initramfs") + return + + output = result.stdout + + # Check for crypt/LUKS modules + print() + info("LUKS/Crypt modules:") + crypt_found = False + for keyword in ["crypt", "luks", "dm-crypt"]: + matches = [line for line in output.split("\n") if keyword in line.lower()] + if matches: + crypt_found = True + for match in matches[:5]: + print(f" {match}") + + if crypt_found: + success("LUKS support found in initramfs") + else: + error("WARNING: No LUKS/crypt modules found!") + print(" Boot from encrypted root may fail.") + + # Check for NVIDIA modules + print() + info("NVIDIA modules:") + nvidia_found = False + nvidia_modules = [line for line in output.split("\n") if "nvidia" in line.lower()] + + if nvidia_modules: + nvidia_found = True + for module in nvidia_modules[:5]: + print(f" {module}") + success("NVIDIA modules found in initramfs") + else: + warn("No NVIDIA modules in initramfs (may be OK if not using early KMS)") + + # Summary + print() + if crypt_found and nvidia_found: + success("Initramfs verification PASSED - LUKS and NVIDIA modules present") + elif crypt_found: + success("Initramfs verification PASSED - system should boot (NVIDIA optional)") + else: + error("Initramfs verification FAILED - fix before rebooting!") + + +def rebuild_initramfs() -> None: + """Rebuild initramfs with current configuration.""" + info("=== Rebuilding Initramfs ===") + + # Find installed kernel version + modules_dir = Path("/lib/modules") + if not modules_dir.exists(): + error("No kernel modules found. Install kernel first.") + return + + kernels = sorted(modules_dir.iterdir(), reverse=True) + if not kernels: + error("No kernel versions found in /lib/modules") + return + + kernel_version = kernels[0].name + info(f"Rebuilding initramfs for kernel {kernel_version}") + + run("dracut", "--force", f"/boot/initramfs-{kernel_version}.img", kernel_version) + + success("Initramfs rebuilt.") + + +def setup_bootloader(source_dir: Path | None = None) -> None: + """Full bootloader setup workflow.""" + if source_dir is None: + source_dir = Path("/root/gentoo") + + # Ensure dracut crypt config is in place + copy_dracut_crypt_config(source_dir) + print() + + emerge_grub() + print() + install_grub_efi() + print() + configure_grub() + print() + generate_grub_config() + print() + + # Verify initramfs + verify_initramfs() + + print() + success("=== Bootloader Setup Complete ===") + print() + info("Pre-reboot checklist:") + print(" 1. Verify /etc/fstab is correct") + print(" 2. Verify /etc/conf.d/dmcrypt has swap configured") + print(" 3. Ensure root password is set") + print(" 4. Ensure user account exists") + print() + info("Ready to reboot!") + print(" exit # Leave chroot") + print(" reboot # Restart system") diff --git a/install/chroot.py b/install/chroot.py new file mode 100644 index 0000000..0f15e24 --- /dev/null +++ b/install/chroot.py @@ -0,0 +1,81 @@ +"""Chroot environment preparation.""" + +import shutil +from pathlib import Path + +from .utils import info, success, warn, run, is_mounted + + +def prepare_chroot(mount_root: Path | None = None) -> None: + """Prepare chroot environment with bind mounts.""" + if mount_root is None: + mount_root = Path("/mnt/gentoo") + + info("=== Preparing Chroot Environment ===") + + # Check if already prepared (idempotency) + if is_mounted(mount_root / "proc"): + info("Chroot already prepared (skipping)") + return + + # Copy DNS configuration + info("Copying DNS configuration...") + resolv_src = Path("/etc/resolv.conf") + resolv_dst = mount_root / "etc/resolv.conf" + + if resolv_src.exists(): + # Use --dereference to copy the actual file if it's a symlink + shutil.copy2(resolv_src.resolve(), resolv_dst) + success("Copied resolv.conf") + + # Bind mount pseudo-filesystems + mounts = [ + ("proc", "/proc", ["--rbind"]), + ("sys", "/sys", ["--rbind"]), + ("dev", "/dev", ["--rbind"]), + ("run", "/run", ["--rbind"]), + ] + + for name, source, flags in mounts: + target = mount_root / name.lstrip("/") + target.mkdir(parents=True, exist_ok=True) + + run("mount", *flags, source, str(target)) + run("mount", "--make-rslave", str(target)) + success(f"Mounted {source}") + + print() + success("=== Chroot environment ready ===") + print() + info("To enter chroot:") + print(f" chroot {mount_root} /bin/bash") + print(" source /etc/profile") + print(" export PS1=\"(chroot) $PS1\"") + + +def enter_chroot(mount_root: Path | None = None) -> None: + """Enter the chroot environment.""" + if mount_root is None: + mount_root = Path("/mnt/gentoo") + + import os + os.execvp("chroot", ["chroot", str(mount_root), "/bin/bash", "--login"]) + + +def cleanup_chroot(mount_root: Path | None = None) -> None: + """Unmount chroot bind mounts.""" + if mount_root is None: + mount_root = Path("/mnt/gentoo") + + info("Cleaning up chroot mounts...") + + # Unmount in reverse order + for name in ["run", "dev", "sys", "proc"]: + target = mount_root / name + result = run("umount", "-R", str(target), check=False) + if not result.ok: + # Try lazy unmount as fallback + warn(f"Normal unmount failed for {target}, trying lazy unmount...") + run("umount", "-Rl", str(target), check=False) + + success("Chroot mounts cleaned up.") diff --git a/install/disk.py b/install/disk.py new file mode 100644 index 0000000..8904d19 --- /dev/null +++ b/install/disk.py @@ -0,0 +1,384 @@ +"""Disk partitioning, LUKS encryption, and btrfs setup.""" + +import time +from dataclasses import dataclass, field +from pathlib import Path + +from .utils import ( + info, + success, + error, + fatal, + prompt, + confirm, + run, + run_quiet, + is_block_device, + is_mounted, +) + + +@dataclass +class DiskConfig: + """Configuration for disk setup.""" + efi_size_gb: int = 1 + swap_size_gb: int = 24 + luks_label: str = "legion" + btrfs_label: str = "legion" + mapper_name: str = "legion" + btrfs_opts: str = "noatime,compress=zstd" + mount_root: Path = field(default_factory=lambda: Path("/mnt/gentoo")) + + @property + def mapper_path(self) -> Path: + return Path(f"/dev/mapper/{self.mapper_name}") + + +@dataclass +class DiskLayout: + """Resolved disk layout after disk selection.""" + device: Path + part_efi: Path + part_swap: Path + part_root: Path + + @classmethod + def from_device(cls, device: Path) -> "DiskLayout": + """Create layout with correct partition naming for device type.""" + dev_str = str(device) + + if "nvme" in dev_str or "mmcblk" in dev_str: + return cls( + device=device, + part_efi=Path(f"{dev_str}p1"), + part_swap=Path(f"{dev_str}p2"), + part_root=Path(f"{dev_str}p3"), + ) + else: + return cls( + device=device, + part_efi=Path(f"{dev_str}1"), + part_swap=Path(f"{dev_str}2"), + part_root=Path(f"{dev_str}3"), + ) + + +def _is_luks_device(device: Path) -> bool: + """Check if a device is a LUKS container.""" + result = run_quiet("cryptsetup", "isLuks", str(device), check=False) + return result.ok + + +def _is_luks_open(mapper_name: str) -> bool: + """Check if a LUKS container is already open.""" + return Path(f"/dev/mapper/{mapper_name}").exists() + + +def list_disks() -> list[dict]: + """List available disks with metadata.""" + result = run_quiet("lsblk", "-d", "-b", "-n", "-o", "NAME,SIZE,MODEL,TYPE") + disks = [] + + for line in result.stdout.strip().split("\n"): + parts = line.split(None, 3) + if len(parts) >= 2 and parts[-1] == "disk": + name = parts[0] + size_bytes = int(parts[1]) + model = parts[2] if len(parts) > 2 else "Unknown" + disks.append({ + "name": name, + "path": Path(f"/dev/{name}"), + "size_gb": size_bytes // (1024**3), + "model": model, + }) + + return disks + + +def select_disk() -> DiskLayout: + """Interactive disk selection.""" + info("Available disks:") + disks = list_disks() + + for i, disk in enumerate(disks, 1): + print(f" {i}) {disk['path']} - {disk['size_gb']} GB ({disk['model']})") + + print() + choice = prompt("Enter disk number: ") + + try: + index = int(choice) - 1 + if index < 0 or index >= len(disks): + raise ValueError() + except ValueError: + fatal("Invalid selection") + + selected = disks[index] + layout = DiskLayout.from_device(selected["path"]) + + print() + error(f"WARNING: This will DESTROY ALL DATA on {layout.device}") + if not confirm("Proceed?"): + fatal("Aborted by user") + + return layout + + +def wipe_disk(layout: DiskLayout, config: DiskConfig) -> None: + """Wipe disk signatures and close any existing LUKS/mounts.""" + info("Wiping disk signatures...") + + # Unmount target + run("umount", "-R", str(config.mount_root), check=False) + + # Close LUKS if open + run("cryptsetup", "close", config.mapper_name, check=False) + + # Deactivate swap + run("swapoff", str(layout.part_swap), check=False) + + # Wipe signatures + run("wipefs", "-af", str(layout.device), check=False) + + # Zero first 10MB + run( + "dd", "if=/dev/zero", f"of={layout.device}", + "bs=1M", "count=10", "status=none", + check=False, + ) + + run("partprobe", str(layout.device), check=False) + run("sync") + + success("Disk wiped.") + + +def create_partitions(layout: DiskLayout, config: DiskConfig) -> None: + """Create GPT partition table with EFI, swap, and root partitions.""" + info("Creating GPT partition table...") + + device = str(layout.device) + + # Clear and create GPT + run("sgdisk", "--clear", device) + run("sgdisk", "--set-alignment=2048", device) + + # EFI partition + run( + "sgdisk", + "-n", f"1:0:+{config.efi_size_gb}G", + "-t", "1:EF00", + "-c", "1:EFI", + device, + ) + + # Swap partition + run( + "sgdisk", + "-n", f"2:0:+{config.swap_size_gb}G", + "-t", "2:8200", + "-c", "2:Swap", + device, + ) + + # Root partition (LUKS) + run( + "sgdisk", + "-n", "3:0:0", + "-t", "3:8309", + "-c", "3:LUKS", + device, + ) + + run("partprobe", device) + time.sleep(2) + + # Verify partitions exist + for part in [layout.part_efi, layout.part_swap, layout.part_root]: + _wait_for_partition(part, layout.device) + + success("Partitions created.") + run("sgdisk", "-p", device) + + +def _wait_for_partition(part: Path, device: Path, timeout: int = 10) -> None: + """Wait for partition to appear.""" + for _ in range(timeout): + if is_block_device(part): + return + time.sleep(1) + run_quiet("partprobe", str(device), check=False) + + fatal(f"Partition {part} not found after {timeout}s") + + +def format_efi(layout: DiskLayout) -> None: + """Format EFI partition as FAT32.""" + info(f"Formatting EFI partition {layout.part_efi}...") + run("mkfs.vfat", "-F", "32", str(layout.part_efi)) + success("EFI formatted.") + + +def setup_luks(layout: DiskLayout, config: DiskConfig) -> None: + """Create and open LUKS2 container.""" + info(f"Setting up LUKS2 encryption on {layout.part_root}...") + success(">>> Enter your LUKS passphrase (OnlyKey) <<<") + + run( + "cryptsetup", "luksFormat", + "--type", "luks2", + "--label", config.luks_label, + str(layout.part_root), + ) + + info("Opening LUKS container...") + success(">>> Enter passphrase again to open <<<") + + run("cryptsetup", "open", str(layout.part_root), config.mapper_name) + + success(f"LUKS container opened at {config.mapper_path}") + + +def create_btrfs(config: DiskConfig) -> None: + """Create btrfs filesystem on LUKS container.""" + info("Creating btrfs filesystem...") + run("mkfs.btrfs", "-L", config.btrfs_label, str(config.mapper_path)) + success(f"Btrfs created with label '{config.btrfs_label}'.") + + +def create_subvolumes(config: DiskConfig) -> None: + """Create btrfs subvolumes.""" + info("Creating btrfs subvolumes...") + + mount_root = config.mount_root + mapper = str(config.mapper_path) + + mount_root.mkdir(parents=True, exist_ok=True) + run("mount", mapper, str(mount_root)) + + subvolumes = ["@", "@home", "@var", "@log", "@snapshots"] + created = [] + skipped = [] + + for subvol in subvolumes: + subvol_path = mount_root / subvol + if subvol_path.exists(): + skipped.append(subvol) + else: + run("btrfs", "subvolume", "create", str(subvol_path)) + created.append(subvol) + + run("umount", str(mount_root)) + + if created: + success(f"Subvolumes created: {', '.join(created)}") + if skipped: + info(f"Subvolumes already exist: {', '.join(skipped)}") + + +def mount_filesystems(layout: DiskLayout, config: DiskConfig) -> None: + """Mount all filesystems for installation.""" + info("Mounting filesystems...") + + mount_root = config.mount_root + mapper = str(config.mapper_path) + opts = config.btrfs_opts + + # Check if already mounted + if is_mounted(mount_root): + info(f"{mount_root} already mounted") + run("findmnt", "-R", str(mount_root)) + return + + # Mount @ subvolume first + mount_root.mkdir(parents=True, exist_ok=True) + run("mount", "-o", f"{opts},subvol=@", mapper, str(mount_root)) + + # Create mount points + for subdir in ["home", "var", "var/log", ".snapshots", "boot"]: + (mount_root / subdir).mkdir(parents=True, exist_ok=True) + + # Mount remaining subvolumes (skip if already mounted) + mounts = [ + (f"{opts},subvol=@home", mount_root / "home"), + (f"{opts},subvol=@var", mount_root / "var"), + (f"{opts},subvol=@log", mount_root / "var/log"), + (f"{opts},subvol=@snapshots", mount_root / ".snapshots"), + ] + for mount_opts, target in mounts: + if not is_mounted(target): + run("mount", "-o", mount_opts, mapper, str(target)) + + # Mount EFI + if not is_mounted(mount_root / "boot"): + run("mount", str(layout.part_efi), str(mount_root / "boot")) + + success("All filesystems mounted.") + run("findmnt", "-R", str(mount_root)) + + +def prepare_disk(config: DiskConfig | None = None) -> tuple[DiskLayout, DiskConfig]: + """Full disk preparation workflow.""" + if config is None: + config = DiskConfig() + + print() + info("=== Gentoo Disk Preparation ===") + info(f"EFI: {config.efi_size_gb} GB") + info(f"Swap: {config.swap_size_gb} GB (encrypted at boot with random key)") + info("Root: Remaining space (LUKS2 + btrfs)") + print() + + layout = select_disk() + + # Check for existing LUKS container - offer to reuse instead of reformatting + if layout.part_root.exists() and _is_luks_device(layout.part_root): + print() + info("Existing LUKS container detected on root partition.") + response = prompt("Reformat disk (destroys data) or reuse existing? [reformat/reuse]: ").strip().lower() + + if response == "reuse": + info("Reusing existing disk setup...") + # Open LUKS if not already open + if not _is_luks_open(config.mapper_name): + info("Opening existing LUKS container...") + success(">>> Enter LUKS passphrase <<<") + run("cryptsetup", "open", str(layout.part_root), config.mapper_name) + # Mount filesystems + mount_filesystems(layout, config) + _print_summary(layout, config) + return layout, config + elif response != "reformat": + fatal("Aborted - enter 'reformat' or 'reuse'") + + # Full disk setup + wipe_disk(layout, config) + create_partitions(layout, config) + format_efi(layout) + setup_luks(layout, config) + create_btrfs(config) + create_subvolumes(config) + mount_filesystems(layout, config) + + _print_summary(layout, config) + return layout, config + + +def _print_summary(layout: DiskLayout, config: DiskConfig) -> None: + """Print disk preparation summary.""" + print() + success("=== Disk preparation complete ===") + print() + info("Summary:") + print(f" EFI: {layout.part_efi} -> {config.mount_root}/boot") + print(f" Swap: {layout.part_swap} (encrypted at boot)") + print(f" LUKS: {layout.part_root} -> {config.mapper_path}") + print(f" Btrfs: {config.mapper_path}") + print() + info("Subvolumes:") + print(" @ -> /") + print(" @home -> /home") + print(" @var -> /var") + print(" @log -> /var/log") + print(" @snapshots -> /.snapshots") + print() diff --git a/install/fingerprint.py b/install/fingerprint.py new file mode 100644 index 0000000..e30374a --- /dev/null +++ b/install/fingerprint.py @@ -0,0 +1,277 @@ +"""Fingerprint authentication setup for Elan 04f3:0c4b reader. + +This module sets up fingerprint authentication using fprintd with the +Lenovo TOD (Touch OEM Drivers) driver for the Elan fingerprint sensor. + +Requires: +- fprintd and libfprint packages +- Lenovo TOD driver (libfprint-2-tod1-elan.so) +- User account to enroll fingerprints for +""" + +import subprocess +import tempfile +import urllib.request +import zipfile +from pathlib import Path + +from .utils import info, success, warn, error, fatal, run, prompt + +# Lenovo TOD driver download URL (Ubuntu 22.04 package, works on Gentoo) +LENOVO_DRIVER_URL = "https://download.lenovo.com/pccbbs/mobiles/r1elf10w.zip" +TOD_DRIVER_NAME = "libfprint-2-tod1-elan.so" +TOD_INSTALL_DIR = Path("/usr/lib64/libfprint-2/tod-1") + +# PAM configuration for fingerprint (password OR fingerprint) +PAM_FINGERPRINT_LINES = """\ +# Fingerprint authentication - press Enter on empty password to use fingerprint +auth [success=1 new_authtok_reqd=1 default=ignore] pam_unix.so try_first_pass likeauth nullok +auth sufficient pam_fprintd.so +""" + +# PAM files to configure +PAM_FILES = [ + "/etc/pam.d/sddm", + "/etc/pam.d/hyprlock", +] + + +def check_elan_device() -> bool: + """Check if Elan fingerprint reader is present.""" + try: + result = subprocess.run( + ["cat", "/sys/bus/usb/devices/*/product"], + capture_output=True, + text=True, + check=False, + ) + return "ELAN:Fingerprint" in result.stdout + except (FileNotFoundError, OSError): + return False + + +def install_packages() -> None: + """Install fprintd and libfprint.""" + info("Installing fingerprint packages...") + from .utils import emerge + emerge("sys-auth/fprintd", "sys-auth/libfprint") + success("Fingerprint packages installed.") + + +def download_tod_driver() -> Path: + """Download Lenovo TOD driver and extract the .so file.""" + dest = TOD_INSTALL_DIR / TOD_DRIVER_NAME + + # Check if already installed (idempotency) + if dest.exists(): + info("TOD driver already installed") + return dest + + info(f"Downloading Lenovo TOD driver from {LENOVO_DRIVER_URL}...") + + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + zip_path = tmppath / "driver.zip" + + # Download + urllib.request.urlretrieve(LENOVO_DRIVER_URL, zip_path) + success("Downloaded driver package.") + + # Extract + info("Extracting driver...") + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(tmppath) + + # Find the .so file (it's nested in the archive) + so_files = list(tmppath.rglob(TOD_DRIVER_NAME)) + if not so_files: + fatal(f"Could not find {TOD_DRIVER_NAME} in downloaded package") + + # Copy to install location + TOD_INSTALL_DIR.mkdir(parents=True, exist_ok=True) + dest = TOD_INSTALL_DIR / TOD_DRIVER_NAME + + import shutil + shutil.copy2(so_files[0], dest) + dest.chmod(0o755) + + success(f"Installed TOD driver to {dest}") + return dest + + +def verify_device() -> bool: + """Verify fprintd can see the device.""" + info("Verifying fingerprint device...") + result = subprocess.run( + ["fprintd-list", "root"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0 or "No devices available" in result.stderr: + return False + return True + + +def enroll_fingerprints(username: str) -> None: + """Enroll fingerprints for a user.""" + info(f"Enrolling fingerprints for user '{username}'...") + + # Check if already enrolled (idempotency) + result = subprocess.run( + ["fprintd-list", username], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0 and "right-index-finger" in result.stdout: + info(f"Fingerprints already enrolled for {username}") + response = prompt("Re-enroll fingerprints? [y/N]: ").strip().lower() + if response not in ("y", "yes"): + return + + print() + print("You will be prompted to swipe your finger multiple times.") + print("Press Ctrl+C to skip enrollment (you can do this later).") + print() + + fingers = ["right-index-finger", "left-index-finger"] + + for finger in fingers: + response = prompt(f"Enroll {finger}? [Y/n]: ").strip().lower() + if response in ("", "y", "yes"): + try: + run("fprintd-enroll", "-f", finger, username) + success(f"Enrolled {finger}.") + except KeyboardInterrupt: + warn(f"Skipped {finger}.") + except subprocess.CalledProcessError: + warn(f"Failed to enroll {finger}.") + else: + info(f"Skipped {finger}.") + + print() + info("Testing fingerprint verification...") + run("fprintd-verify", username, check=False) + + +def configure_pam() -> None: + """Configure PAM files for fingerprint authentication.""" + info("Configuring PAM for fingerprint authentication...") + + for pam_file in PAM_FILES: + pam_path = Path(pam_file) + if not pam_path.exists(): + warn(f"PAM file not found: {pam_file} (skipping)") + continue + + content = pam_path.read_text() + + # Check if already configured + if "pam_fprintd.so" in content: + info(f"PAM already configured: {pam_file}") + continue + + # Find the first 'auth' line and insert before it + lines = content.splitlines() + new_lines = [] + inserted = False + + for line in lines: + if not inserted and line.strip().startswith("auth"): + # Insert fingerprint config before first auth line + new_lines.extend(PAM_FINGERPRINT_LINES.strip().splitlines()) + inserted = True + new_lines.append(line) + + if inserted: + pam_path.write_text("\n".join(new_lines) + "\n") + success(f"Configured {pam_file}") + else: + warn(f"No auth line found in {pam_file}") + + +def configure_sudo() -> None: + """Optionally configure sudo for fingerprint.""" + response = prompt("Enable fingerprint for sudo? [y/N]: ").strip().lower() + if response not in ("y", "yes"): + info("Skipped sudo fingerprint configuration.") + return + + pam_path = Path("/etc/pam.d/sudo") + if not pam_path.exists(): + warn("PAM file not found: /etc/pam.d/sudo") + return + + content = pam_path.read_text() + if "pam_fprintd.so" in content: + info("sudo PAM already configured for fingerprint.") + return + + lines = content.splitlines() + new_lines = [] + inserted = False + + for line in lines: + if not inserted and line.strip().startswith("auth"): + new_lines.extend(PAM_FINGERPRINT_LINES.strip().splitlines()) + inserted = True + new_lines.append(line) + + if inserted: + pam_path.write_text("\n".join(new_lines) + "\n") + success("Configured /etc/pam.d/sudo") + + +def setup_fingerprint(source_dir: Path) -> None: + """Main fingerprint setup routine.""" + info("=== Fingerprint Authentication Setup ===") + print() + + # Check for hardware + if not check_elan_device(): + fatal("Elan fingerprint reader not detected. Is the device connected?") + + info("Detected Elan fingerprint reader (04f3:0c4b)") + print() + + # Install packages + install_packages() + print() + + # Install TOD driver + download_tod_driver() + print() + + # Verify device is recognized + if not verify_device(): + error("fprintd cannot see the device after driver installation.") + error("You may need to reboot and run this command again.") + return + success("Fingerprint device recognized by fprintd.") + print() + + # Get username for enrollment + username = prompt("Username to enroll fingerprints for: ").strip() + if not username: + fatal("Username required for enrollment.") + + # Enroll fingerprints + enroll_fingerprints(username) + print() + + # Configure PAM + configure_pam() + print() + + # Optionally configure sudo + configure_sudo() + print() + + success("=== Fingerprint setup complete ===") + print() + print("Usage:") + print(" - SDDM: Press Enter on empty password field to use fingerprint") + print(" - hyprlock: Press Enter on empty password field to use fingerprint") + print(" - Enroll more fingers: fprintd-enroll -f ") + print(" - Test: fprintd-verify ") diff --git a/install/fstab.py b/install/fstab.py new file mode 100644 index 0000000..cb40ff3 --- /dev/null +++ b/install/fstab.py @@ -0,0 +1,126 @@ +"""Generate fstab and crypttab configuration files.""" + +from pathlib import Path + +from .utils import info, success, run_quiet +from .disk import DiskLayout, DiskConfig + + +def get_uuid(device: Path) -> str: + """Get UUID of a block device.""" + result = run_quiet("blkid", "-s", "UUID", "-o", "value", str(device)) + return result.stdout.strip() + + +def get_luks_uuid(device: Path) -> str: + """Get UUID of a LUKS container.""" + return get_uuid(device) + + +def generate_fstab( + layout: DiskLayout, + config: DiskConfig, + mount_root: Path | None = None, +) -> None: + """Generate /etc/fstab for the new system.""" + if mount_root is None: + mount_root = config.mount_root + + info("Generating /etc/fstab...") + + efi_uuid = get_uuid(layout.part_efi) + mapper = config.mapper_path + opts = config.btrfs_opts + + fstab_content = f"""\ +# /etc/fstab - Generated by install-installer v4 +# Legion S7 with LUKS encryption + Btrfs subvolumes + +# EFI System Partition +UUID={efi_uuid} /boot vfat noatime,defaults 0 2 + +# Encrypted swap - NOTE: This entry is NOT used by swapon directly. +# Swap activation is handled by the OpenRC dmcrypt service (/etc/conf.d/dmcrypt) +# which creates /dev/mapper/swap with a random key at boot. +{layout.part_swap} none swap sw,cipher=aes-xts-plain64,size=256 0 0 + +# Btrfs on LUKS ({config.mapper_name}) +{mapper} / btrfs {opts},subvol=@ 0 0 +{mapper} /home btrfs {opts},subvol=@home 0 0 +{mapper} /var btrfs {opts},subvol=@var 0 0 +{mapper} /var/log btrfs {opts},subvol=@log 0 0 +{mapper} /.snapshots btrfs {opts},subvol=@snapshots 0 0 +""" + + fstab_path = mount_root / "etc/fstab" + fstab_path.write_text(fstab_content) + + success(f"Generated {fstab_path}") + + +def generate_crypttab( + layout: DiskLayout, + config: DiskConfig, + mount_root: Path | None = None, +) -> None: + """Generate /etc/crypttab for the new system.""" + if mount_root is None: + mount_root = config.mount_root + + info("Generating /etc/crypttab...") + + luks_uuid = get_luks_uuid(layout.part_root) + + crypttab_content = f"""\ +# /etc/crypttab - Generated by install-installer v4 +# LUKS root partition + +{config.mapper_name} UUID={luks_uuid} none luks +""" + + crypttab_path = mount_root / "etc/crypttab" + crypttab_path.write_text(crypttab_content) + + success(f"Generated {crypttab_path}") + + +def generate_dmcrypt_conf( + layout: DiskLayout, + mount_root: Path | None = None, +) -> None: + """Generate /etc/conf.d/dmcrypt for OpenRC encrypted swap.""" + if mount_root is None: + mount_root = Path("/mnt/gentoo") + + info("Generating /etc/conf.d/dmcrypt...") + + dmcrypt_content = f"""\ +# /etc/conf.d/dmcrypt - Generated by install-installer v4 +# Encrypted swap with random key (no hibernate support) + +swap=swap +source='{layout.part_swap}' +""" + + dmcrypt_path = mount_root / "etc/conf.d/dmcrypt" + dmcrypt_path.parent.mkdir(parents=True, exist_ok=True) + dmcrypt_path.write_text(dmcrypt_content) + + success(f"Generated {dmcrypt_path}") + + +def generate_all( + layout: DiskLayout, + config: DiskConfig, + mount_root: Path | None = None, +) -> None: + """Generate all filesystem configuration files.""" + if mount_root is None: + mount_root = config.mount_root + + generate_fstab(layout, config, mount_root) + generate_crypttab(layout, config, mount_root) + generate_dmcrypt_conf(layout, mount_root) + + print() + success("=== Filesystem configuration complete ===") diff --git a/install/nvidia.py b/install/nvidia.py new file mode 100644 index 0000000..feb5053 --- /dev/null +++ b/install/nvidia.py @@ -0,0 +1,171 @@ +"""NVIDIA driver installation and configuration.""" + +import shutil +from pathlib import Path + +from .utils import info, success, error, run, emerge, prompt + + +def emerge_nvidia() -> None: + """Install NVIDIA drivers.""" + info("=== Installing NVIDIA Drivers ===") + + emerge("x11-drivers/nvidia-drivers") + + success("NVIDIA drivers installed.") + + +def blacklist_nouveau() -> None: + """Blacklist nouveau driver.""" + info("=== Blacklisting Nouveau ===") + + modprobe_dir = Path("/etc/modprobe.d") + blacklist_conf = modprobe_dir / "blacklist-nouveau.conf" + + # Check if already configured (idempotency) + if blacklist_conf.exists(): + info("Nouveau already blacklisted") + return + + modprobe_dir.mkdir(parents=True, exist_ok=True) + blacklist_conf.write_text( + "# Blacklist nouveau in favor of proprietary nvidia driver\n" + "blacklist nouveau\n" + "options nouveau modeset=0\n" + ) + + success(f"Created {blacklist_conf}") + + +def configure_nvidia_drm() -> None: + """Configure NVIDIA DRM modeset.""" + info("=== Configuring NVIDIA DRM ===") + + modprobe_dir = Path("/etc/modprobe.d") + nvidia_conf = modprobe_dir / "nvidia.conf" + + # Check if already configured (idempotency) + if nvidia_conf.exists(): + info("NVIDIA DRM already configured") + return + + modprobe_dir.mkdir(parents=True, exist_ok=True) + nvidia_conf.write_text( + "# Enable NVIDIA DRM kernel mode setting\n" + "options nvidia_drm modeset=1 fbdev=1\n" + ) + + success(f"Created {nvidia_conf}") + + +def copy_dracut_config(source_dir: Path) -> None: + """Copy NVIDIA dracut configuration.""" + info("=== Copying Dracut NVIDIA Config ===") + + dracut_src = source_dir / "dracut.conf.d" / "nvidia.conf" + dracut_dst = Path("/etc/dracut.conf.d") + dracut_dst.mkdir(parents=True, exist_ok=True) + + if dracut_src.exists(): + shutil.copy2(dracut_src, dracut_dst / "nvidia.conf") + success("Copied nvidia.conf to /etc/dracut.conf.d/") + else: + # Create default if not in source + nvidia_conf = dracut_dst / "nvidia.conf" + nvidia_conf.write_text( + "# NVIDIA early KMS\n" + 'add_drivers+=" nvidia nvidia_modeset nvidia_uvm nvidia_drm "\n' + 'force_drivers+=" nvidia nvidia_modeset nvidia_uvm nvidia_drm "\n' + ) + success("Created default /etc/dracut.conf.d/nvidia.conf") + + +def rebuild_initramfs() -> None: + """Rebuild initramfs to include NVIDIA modules.""" + info("=== Rebuilding Initramfs ===") + + # Find installed kernel version + modules_dir = Path("/lib/modules") + if not modules_dir.exists(): + error("No kernel modules found. Install kernel first.") + return + + kernels = sorted(modules_dir.iterdir(), reverse=True) + if not kernels: + error("No kernel versions found in /lib/modules") + return + + kernel_version = kernels[0].name + info(f"Rebuilding initramfs for kernel {kernel_version}") + + run("dracut", "--force", f"/boot/initramfs-{kernel_version}.img", kernel_version) + + success("Initramfs rebuilt.") + + +def verify_initramfs() -> None: + """Verify NVIDIA modules are in initramfs.""" + info("=== Verifying Initramfs ===") + + # Find latest initramfs + boot = Path("/boot") + initramfs_files = sorted(boot.glob("initramfs-*.img"), reverse=True) + + if not initramfs_files: + error("No initramfs found in /boot") + return + + initramfs = initramfs_files[0] + info(f"Checking {initramfs.name}...") + + # Check for NVIDIA modules + result = run("lsinitrd", str(initramfs), capture=True, check=False) + + if result.ok and result.stdout: + nvidia_modules = [ + line for line in result.stdout.split("\n") + if "nvidia" in line.lower() + ] + + if nvidia_modules: + success("NVIDIA modules found in initramfs:") + for module in nvidia_modules[:10]: # Show first 10 + print(f" {module}") + else: + error("WARNING: No NVIDIA modules found in initramfs!") + print(" Initramfs may need to be rebuilt after kernel installation.") + else: + error("Could not inspect initramfs") + + +def setup_nvidia(source_dir: Path | None = None) -> None: + """Full NVIDIA setup workflow.""" + if source_dir is None: + source_dir = Path("/root/gentoo") + + emerge_nvidia() + print() + blacklist_nouveau() + print() + configure_nvidia_drm() + print() + copy_dracut_config(source_dir) + print() + + # Ask about rebuilding initramfs + print() + info("Initramfs should be rebuilt to include NVIDIA modules.") + response = prompt("Rebuild initramfs now? (y/n): ").strip().lower() + + if response == "y": + rebuild_initramfs() + print() + verify_initramfs() + + print() + success("=== NVIDIA Setup Complete ===") + print() + info("Next steps:") + print(" 1. Install and configure GRUB bootloader") + print(" 2. Verify initramfs has NVIDIA and crypt modules") + print(" 3. Reboot and test") diff --git a/install/portage.py b/install/portage.py new file mode 100644 index 0000000..e19a7df --- /dev/null +++ b/install/portage.py @@ -0,0 +1,105 @@ +"""Portage configuration management.""" + +import shutil +from pathlib import Path + +from .utils import info, success, error + + +def copy_portage_config( + source_dir: Path, + mount_root: Path | None = None, +) -> None: + """Copy Portage configuration files to the new system.""" + if mount_root is None: + mount_root = Path("/mnt/gentoo") + + info("=== Copying Portage Configuration ===") + + portage_dst = mount_root / "etc/portage" + etc_dst = mount_root / "etc" + + # Source directories + portage_src = source_dir / "portage" + + # Check if already configured (idempotency check) + make_conf = portage_dst / "make.conf" + if make_conf.exists(): + info("Portage configuration already exists, updating...") + + # Files to copy directly to /etc/portage/ + portage_files = ["make.conf"] + + # Directories to copy to /etc/portage/ + portage_dirs = [ + "package.accept_keywords", + "package.use", + "package.mask", + "package.env", + "package.license", + "env", + "sets", + ] + + # Directories to copy to /etc/ (from source_dir root, not portage/) + etc_dirs = ["dracut.conf.d"] + + # Copy portage files (from portage/ subdir) + for filename in portage_files: + src = portage_src / filename + if src.exists(): + dst = portage_dst / filename + shutil.copy2(src, dst) + success(f"Copied {filename}") + else: + error(f"Warning: {filename} not found in {portage_src}") + + # Copy portage directories (from portage/ subdir, merge don't destroy) + for dirname in portage_dirs: + src = portage_src / dirname + if src.is_dir(): + dst = portage_dst / dirname + shutil.copytree(src, dst, dirs_exist_ok=True) + success(f"Copied {dirname}/") + + # Copy etc directories (from source_dir root, merge don't destroy) + for dirname in etc_dirs: + src = source_dir / dirname + if src.is_dir(): + dst = etc_dst / dirname + shutil.copytree(src, dst, dirs_exist_ok=True) + success(f"Copied {dirname}/") + + print() + success("=== Portage configuration copied ===") + + +def copy_user_config( + source_dir: Path, + mount_root: Path | None = None, + username: str = "damien", +) -> None: + """Copy user configuration files.""" + if mount_root is None: + mount_root = Path("/mnt/gentoo") + + info("=== Copying User Configuration ===") + + home_dir = mount_root / "home" / username + + # Files to copy to home directory + user_files = [".zshrc", "starship.toml"] + + for filename in user_files: + src = source_dir / filename + if src.exists(): + if filename == "starship.toml": + dst = home_dir / ".config" / filename + dst.parent.mkdir(parents=True, exist_ok=True) + else: + dst = home_dir / filename + shutil.copy2(src, dst) + success(f"Copied {filename}") + + print() + success("=== User configuration copied ===") diff --git a/install/services.py b/install/services.py new file mode 100644 index 0000000..5119123 --- /dev/null +++ b/install/services.py @@ -0,0 +1,213 @@ +"""Service packages installation and OpenRC configuration.""" + + +import shutil +from pathlib import Path + +from .utils import info, success, error, run, emerge + + +# Packages to emerge for system services +SERVICE_PACKAGES = [ + # System essentials + "app-admin/sysklogd", + "sys-process/cronie", + # Network + "net-misc/networkmanager", + "net-misc/chrony", + "net-firewall/iptables", + # Bluetooth + "net-wireless/bluez", + "net-wireless/blueman", + # Desktop services + "sys-fs/udisks", + "sys-power/power-profiles-daemon", + "sys-auth/rtkit", + # Display manager + "x11-misc/sddm", + "gui-libs/display-manager-init", + # Containers + "app-containers/podman", + "app-containers/podman-compose", + "app-containers/podman-tui", + # Backup + "app-backup/snapper", +] + +# OpenRC boot runlevel services +BOOT_SERVICES = [ + "dbus", + "elogind", + "dmcrypt", +] + +# OpenRC default runlevel services +DEFAULT_SERVICES = [ + "sysklogd", + "sshd", + "NetworkManager", + "chronyd", + "cronie", + "power-profiles-daemon", + "display-manager", + "bluetooth", + "iptables", + "ip6tables", +] + + +def emerge_services() -> None: + """Emerge service packages.""" + info("=== Emerging Service Packages ===") + + print() + info("Packages to install:") + for pkg in SERVICE_PACKAGES: + print(f" - {pkg}") + print() + + emerge(*SERVICE_PACKAGES) + + success("Service packages installed.") + + +def configure_display_manager() -> None: + """Configure SDDM as the display manager.""" + info("=== Configuring Display Manager ===") + + dm_conf = Path("/etc/conf.d/display-manager") + + if dm_conf.exists(): + content = dm_conf.read_text() + + # Check if already configured correctly (idempotency) + if 'DISPLAYMANAGER="sddm"' in content: + info("SDDM already configured as display manager") + return + + # Update existing config + lines = content.split("\n") + new_lines = [] + found = False + + for line in lines: + if line.startswith("DISPLAYMANAGER="): + new_lines.append('DISPLAYMANAGER="sddm"') + found = True + else: + new_lines.append(line) + + if not found: + new_lines.append('DISPLAYMANAGER="sddm"') + + dm_conf.write_text("\n".join(new_lines)) + else: + dm_conf.write_text('DISPLAYMANAGER="sddm"\n') + + success("SDDM configured as display manager.") + + +def copy_system_configs(source_dir: Path) -> None: + """Copy system configuration files.""" + info("=== Copying System Configuration ===") + + # conf.d/ -> /etc/conf.d/ + conf_d_src = source_dir / "conf.d" + if conf_d_src.is_dir(): + conf_d_dst = Path("/etc/conf.d") + for f in conf_d_src.iterdir(): + if f.is_file(): + shutil.copy2(f, conf_d_dst / f.name) + success(f"Copied conf.d/{f.name}") + + # iptables/ -> /etc/iptables/ (for reference) + # Also copy to /var/lib/iptables/ and /var/lib/ip6tables/ (OpenRC locations) + iptables_src = source_dir / "iptables" + if iptables_src.is_dir(): + # Copy to /etc/iptables for reference + iptables_etc = Path("/etc/iptables") + iptables_etc.mkdir(parents=True, exist_ok=True) + for f in iptables_src.iterdir(): + if f.is_file(): + shutil.copy2(f, iptables_etc / f.name) + success(f"Copied iptables/{f.name}") + + # Copy to OpenRC service locations + iptables_var = Path("/var/lib/iptables") + ip6tables_var = Path("/var/lib/ip6tables") + iptables_var.mkdir(parents=True, exist_ok=True) + ip6tables_var.mkdir(parents=True, exist_ok=True) + + rules_v4 = iptables_src / "iptables.rules" + rules_v6 = iptables_src / "ip6tables.rules" + + if rules_v4.exists(): + shutil.copy2(rules_v4, iptables_var / "rules-save") + success("Installed iptables rules to /var/lib/iptables/rules-save") + + if rules_v6.exists(): + shutil.copy2(rules_v6, ip6tables_var / "rules-save") + success("Installed ip6tables rules to /var/lib/ip6tables/rules-save") + + # udev/ -> /etc/udev/rules.d/ + udev_src = source_dir / "udev" + if udev_src.is_dir(): + udev_dst = Path("/etc/udev/rules.d") + udev_dst.mkdir(parents=True, exist_ok=True) + for f in udev_src.iterdir(): + if f.is_file(): + shutil.copy2(f, udev_dst / f.name) + success(f"Copied udev/{f.name}") + + +def setup_openrc_services() -> None: + """Add services to OpenRC runlevels.""" + info("=== Configuring OpenRC Services ===") + + # Boot runlevel + info("Adding boot runlevel services...") + for service in BOOT_SERVICES: + result = run("rc-update", "add", service, "boot", check=False) + if result.ok: + success(f" Added {service} to boot") + else: + error(f" Warning: Could not add {service} (may already exist)") + + # Default runlevel + info("Adding default runlevel services...") + for service in DEFAULT_SERVICES: + result = run("rc-update", "add", service, "default", check=False) + if result.ok: + success(f" Added {service} to default") + else: + error(f" Warning: Could not add {service} (may already exist)") + + print() + info("Current runlevel configuration:") + run("rc-update", "show") + + +def setup_services(source_dir: Path | None = None) -> None: + """Full services setup workflow.""" + if source_dir is None: + # Assume config files are in /root/gentoo or similar + # This should be passed from setup.py + source_dir = Path("/root/gentoo") + + emerge_services() + print() + configure_display_manager() + print() + copy_system_configs(source_dir) + print() + setup_openrc_services() + + print() + success("=== Services Setup Complete ===") + print() + info("Next steps:") + print(" 1. Install kernel: emerge gentoo-kernel-bin (or gentoo-kernel)") + print(" 2. Configure bootloader") + print(" 3. Set root password: passwd") + print(" 4. Create user account") + print(" 5. Reboot and test") diff --git a/install/stage3.py b/install/stage3.py new file mode 100644 index 0000000..f5d3782 --- /dev/null +++ b/install/stage3.py @@ -0,0 +1,179 @@ +"""Stage3 tarball download and extraction.""" + +import hashlib +import re +from dataclasses import dataclass, field +from pathlib import Path +from urllib.request import urlopen, Request +from urllib.error import URLError + +from .utils import info, success, error, fatal, run + + +@dataclass +class Stage3Config: + """Configuration for stage3 download.""" + init_system: str = "openrc" # or "systemd" + mirrors: list[str] = field(default_factory=lambda: [ + "https://mirrors.rit.edu/gentoo/", + "https://distfiles.gentoo.org/", + "https://gentoo.osuosl.org/", + ]) + mount_root: Path = field(default_factory=lambda: Path("/mnt/gentoo")) + + @property + def variant(self) -> str: + return f"stage3-amd64-{self.init_system}" + + +def _fetch_url(url: str, timeout: int = 30) -> bytes: + """Fetch URL content.""" + request = Request(url, headers={"User-Agent": "install-installer/4.0"}) + with urlopen(request, timeout=timeout) as response: + return response.read() + + +def _find_stage3_filename(mirror: str, config: Stage3Config) -> str | None: + """Find current stage3 filename from mirror.""" + base_url = f"{mirror}releases/amd64/autobuilds/current-{config.variant}/" + latest_url = f"{base_url}latest-{config.variant}.txt" + + try: + content = _fetch_url(latest_url).decode("utf-8") + except URLError as e: + error(f"Failed to fetch {latest_url}: {e}") + return None + + # Parse PGP-signed content or direct listing + pattern = rf"{config.variant}-\d{{8}}T\d{{6}}Z\.tar\.xz" + match = re.search(pattern, content) + + if match: + return match.group(0) + + return None + + +def _verify_sha512(filepath: Path, expected: str) -> bool: + """Verify SHA512 checksum of file.""" + sha512 = hashlib.sha512() + with open(filepath, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha512.update(chunk) + return sha512.hexdigest() == expected + + +def _parse_digests(content: str, filename: str) -> str | None: + """Extract SHA512 hash from DIGESTS file.""" + lines = content.split("\n") + for i, line in enumerate(lines): + if "SHA512" in line and i + 1 < len(lines) and filename in lines[i + 1]: + # Hash is on next line, first field + hash_line = lines[i + 1].strip() + return hash_line.split()[0] + + # Alternative format: hash followed by filename on same line + for line in lines: + if filename in line and len(line.split()) >= 2: + parts = line.split() + if len(parts[0]) == 128: # SHA512 is 128 hex chars + return parts[0] + + return None + + +def download_stage3(config: Stage3Config | None = None) -> Path: + """Download and verify stage3 tarball.""" + if config is None: + config = Stage3Config() + + info(f"=== Downloading Stage3 ({config.init_system}) ===") + + filename = None + working_mirror = None + + for mirror in config.mirrors: + info(f"Trying mirror: {mirror}") + filename = _find_stage3_filename(mirror, config) + if filename: + working_mirror = mirror + success(f"Found: {filename}") + break + error(f"Mirror {mirror} failed, trying next...") + + if not filename or not working_mirror: + fatal("Could not find stage3 on any mirror") + + base_url = f"{working_mirror}releases/amd64/autobuilds/current-{config.variant}/" + tarball_url = f"{base_url}{filename}" + digests_url = f"{base_url}{filename}.DIGESTS" + + target_dir = config.mount_root + tarball_path = target_dir / filename + digests_path = target_dir / f"{filename}.DIGESTS" + + # Download tarball + info(f"Downloading {filename}...") + run("wget", "--progress=bar:force", "-O", str(tarball_path), tarball_url) + + # Download digests + info("Downloading DIGESTS...") + run("wget", "-q", "-O", str(digests_path), digests_url) + + # Verify checksum + info("Verifying SHA512 checksum...") + digests_content = digests_path.read_text() + expected_hash = _parse_digests(digests_content, filename) + + if not expected_hash: + error("Could not parse checksum from DIGESTS file") + error("Continuing without verification (manual check recommended)") + else: + if _verify_sha512(tarball_path, expected_hash): + success("Checksum verified.") + else: + fatal("Checksum verification FAILED") + + # Cleanup digests file + digests_path.unlink() + + return tarball_path + + +def extract_stage3(tarball_path: Path, mount_root: Path | None = None) -> None: + """Extract stage3 tarball.""" + if mount_root is None: + mount_root = Path("/mnt/gentoo") + + info(f"Extracting {tarball_path.name}...") + + run( + "tar", "xpf", str(tarball_path), + "--xattrs-include=*.*", + "--numeric-owner", + "--skip-old-files", # Resume capability - skip files that already exist + "-C", str(mount_root), + ) + + success("Stage3 extracted.") + + # Cleanup tarball + tarball_path.unlink() + success("Cleaned up tarball.") + + +def fetch_stage3(config: Stage3Config | None = None) -> None: + """Full stage3 workflow: download, verify, extract.""" + if config is None: + config = Stage3Config() + + # Check if already extracted (idempotency check) + if (config.mount_root / "etc/portage").exists(): + info("Stage3 already extracted (skipping)") + return + + tarball = download_stage3(config) + extract_stage3(tarball, config.mount_root) + + print() + success("=== Stage3 installation complete ===") diff --git a/install/sync.py b/install/sync.py new file mode 100644 index 0000000..93c794e --- /dev/null +++ b/install/sync.py @@ -0,0 +1,324 @@ +"""Initial portage sync and profile setup (run inside chroot).""" + +import re +from pathlib import Path + +from .utils import info, success, error, fatal, prompt, run, run_quiet, emerge + + +DESIRED_PROFILE = "default/linux/amd64/23.0/desktop" +SWAP_MAPPER = "swap" + +# System defaults +DEFAULT_TIMEZONE = "America/New_York" +DEFAULT_LOCALE = "en_US.UTF-8" +DEFAULT_HOSTNAME = "legion" + + +def get_swap_partition() -> Path | None: + """Read swap partition from /etc/conf.d/dmcrypt.""" + dmcrypt_conf = Path("/etc/conf.d/dmcrypt") + if not dmcrypt_conf.exists(): + return None + + content = dmcrypt_conf.read_text() + for line in content.split("\n"): + if line.startswith("source="): + # Extract path from source='...' or source="..." + match = re.match(r"source=['\"]?([^'\"]+)['\"]?", line) + if match: + return Path(match.group(1)) + return None + + +def activate_swap() -> None: + """Activate encrypted swap for use during compilation. + + In a chroot, OpenRC isn't running, so we manually set up swap. + """ + info("=== Activating Encrypted Swap ===") + + swap_part = get_swap_partition() + if swap_part is None: + error("Could not find swap partition in /etc/conf.d/dmcrypt") + error("Skipping swap activation - builds may be slower") + return + + mapper_path = Path(f"/dev/mapper/{SWAP_MAPPER}") + + # Check if already active + if mapper_path.exists(): + info("Swap already active") + run("swapon", "--show") + return + + info(f"Setting up encrypted swap on {swap_part}...") + + # Create encrypted swap with random key + run( + "cryptsetup", "open", + "--type", "plain", + "--cipher", "aes-xts-plain64", + "--key-size", "256", + "--key-file", "/dev/urandom", + str(swap_part), SWAP_MAPPER, + ) + + # Format as swap + run("mkswap", str(mapper_path)) + + # Activate + run("swapon", str(mapper_path)) + + success("Encrypted swap activated.") + run("swapon", "--show") + + +def deactivate_swap() -> None: + """Deactivate encrypted swap before exiting chroot.""" + mapper_path = Path(f"/dev/mapper/{SWAP_MAPPER}") + + if not mapper_path.exists(): + return + + info("Deactivating encrypted swap...") + run("swapoff", str(mapper_path), check=False) + run("cryptsetup", "close", SWAP_MAPPER, check=False) + success("Swap deactivated.") + + +def emerge_webrsync() -> None: + """Initial portage tree sync via webrsync.""" + info("=== Syncing Portage Tree (webrsync) ===") + run("emerge-webrsync") + success("Portage tree synced.") + + +def list_profiles() -> list[tuple[int, str, bool]]: + """List available profiles and return parsed list. + + Returns list of (number, profile_name, is_selected). + """ + result = run_quiet("eselect", "profile", "list") + + profiles = [] + for line in result.stdout.strip().split("\n"): + # Format: " [N] profile/name (stable) *" + # The * indicates currently selected + match = re.match(r'\s*\[(\d+)\]\s+(\S+)(?:\s+\(.*\))?(\s+\*)?', line) + if match: + num = int(match.group(1)) + name = match.group(2) + selected = match.group(3) is not None + profiles.append((num, name, selected)) + + return profiles + + +def find_profile_number(profiles: list[tuple[int, str, bool]], target: str) -> int | None: + """Find profile number for a given profile name.""" + for num, name, _ in profiles: + if name == target: + return num + return None + + +def select_profile() -> None: + """Interactive profile selection.""" + info("=== Profile Selection ===") + + profiles = list_profiles() + + # Display profiles + print() + info("Available profiles:") + run("eselect", "profile", "list") + + # Find desired profile + target_num = find_profile_number(profiles, DESIRED_PROFILE) + + if target_num is None: + error(f"Could not find profile: {DESIRED_PROFILE}") + print() + choice = prompt("Enter profile number manually: ") + try: + target_num = int(choice) + except ValueError: + fatal("Invalid profile number") + else: + print() + success(f"Recommended: [{target_num}] {DESIRED_PROFILE}") + + response = prompt(f"Set profile to {DESIRED_PROFILE}? (y/n/other number): ").strip() + + if response.lower() == "n": + fatal("Profile selection cancelled") + elif response.lower() != "y": + try: + target_num = int(response) + except ValueError: + fatal("Invalid input") + + # Set the profile + info(f"Setting profile to [{target_num}]...") + run("eselect", "profile", "set", str(target_num)) + + # Verify + print() + info("Current profile:") + run("eselect", "profile", "show") + success("Profile set.") + + +def setup_repositories() -> None: + """Install eselect-repository and enable guru overlay.""" + info("=== Setting Up Repositories ===") + + # Install eselect-repository + info("Installing eselect-repository...") + emerge("app-eselect/eselect-repository", ask=False, verbose=False) + success("eselect-repository installed.") + + # Enable guru overlay + info("Enabling guru overlay...") + run("eselect", "repository", "enable", "guru") + success("guru overlay enabled.") + + # Sync guru + info("Syncing guru overlay...") + run("emerge", "--sync", "guru") + success("guru overlay synced.") + + +def setup_timezone(tz: str = DEFAULT_TIMEZONE) -> None: + """Set system timezone.""" + info(f"=== Setting Timezone: {tz} ===") + + tz_file = Path(f"/usr/share/zoneinfo/{tz}") + if not tz_file.exists(): + error(f"Timezone {tz} not found") + return + + localtime = Path("/etc/localtime") + + # Check if already set correctly (idempotency) + if localtime.is_symlink(): + try: + if localtime.resolve() == tz_file.resolve(): + info(f"Timezone already set to {tz}") + return + except OSError: + pass # Broken symlink, continue to fix it + + if localtime.exists() or localtime.is_symlink(): + localtime.unlink() + + localtime.symlink_to(tz_file) + success(f"Timezone set to {tz}") + + +def setup_locale(locale: str = DEFAULT_LOCALE) -> None: + """Configure locale.gen and generate locales.""" + info(f"=== Configuring Locale: {locale} ===") + + locale_gen = Path("/etc/locale.gen") + + # Read existing content + if locale_gen.exists(): + content = locale_gen.read_text() + else: + content = "" + + # Check if locale is already uncommented + locale_line = f"{locale} UTF-8" + if locale_line in content and not f"#{locale_line}" in content: + info(f"{locale} already enabled") + else: + # Uncomment or append the locale + if f"#{locale_line}" in content: + content = content.replace(f"#{locale_line}", locale_line) + elif f"# {locale_line}" in content: + content = content.replace(f"# {locale_line}", locale_line) + else: + content += f"\n{locale_line}\n" + + locale_gen.write_text(content) + success(f"Enabled {locale} in /etc/locale.gen") + + # Generate locales + info("Running locale-gen...") + run("locale-gen") + + # Set system locale + env_locale = Path("/etc/env.d/02locale") + env_locale.write_text(f'LANG="{locale}"\nLC_COLLATE="C.UTF-8"\n') + success(f"Set LANG={locale} in /etc/env.d/02locale") + + # Update environment + run("env-update", check=False) + + +def setup_hostname(hostname: str = DEFAULT_HOSTNAME) -> None: + """Set system hostname.""" + info(f"=== Setting Hostname: {hostname} ===") + + # /etc/hostname + hostname_file = Path("/etc/hostname") + + # Check if already set correctly (idempotency) + if hostname_file.exists() and hostname_file.read_text().strip() == hostname: + info(f"Hostname already set to {hostname}") + return + + hostname_file.write_text(f"{hostname}\n") + success(f"Set /etc/hostname to {hostname}") + + # /etc/conf.d/hostname (OpenRC) + conf_hostname = Path("/etc/conf.d/hostname") + conf_hostname.write_text(f'hostname="{hostname}"\n') + success("Set /etc/conf.d/hostname") + + # Update /etc/hosts + hosts_file = Path("/etc/hosts") + if hosts_file.exists(): + content = hosts_file.read_text() + # Check if we need to add the hostname + if hostname not in content: + # Add hostname to localhost line + lines = content.split("\n") + new_lines = [] + for line in lines: + if line.startswith("127.0.0.1"): + if hostname not in line: + line = f"{line} {hostname}" + new_lines.append(line) + hosts_file.write_text("\n".join(new_lines)) + success(f"Added {hostname} to /etc/hosts") + + +def initial_sync() -> None: + """Full initial sync workflow.""" + activate_swap() + print() + emerge_webrsync() + print() + select_profile() + print() + setup_repositories() + print() + setup_timezone() + print() + setup_locale() + print() + setup_hostname() + + print() + success("=== Initial Portage Setup Complete ===") + print() + info("Swap is active for compilation. Next steps:") + print(" 1. Review /etc/portage/make.conf") + print(" 2. emerge -avuDN @world") + print(" 3. Install kernel and bootloader") + print() + info("When done, deactivate swap before exiting chroot:") + print(" python setup.py swap-off") diff --git a/install/users.py b/install/users.py new file mode 100644 index 0000000..c74f81c --- /dev/null +++ b/install/users.py @@ -0,0 +1,195 @@ +"""User configuration and shell setup.""" + +import re +import shutil +from pathlib import Path + +from .utils import info, success, error, run, run_quiet, emerge, prompt + + +# Shell and portage tool packages +SHELL_PACKAGES = [ + # Shell + "app-shells/zsh", + "app-shells/gentoo-zsh-completions", + "app-shells/zsh-autosuggestions", + "app-shells/zsh-syntax-highlighting", + # Prompt & tools + "app-shells/starship", + "app-shells/zoxide", + "app-shells/fzf", + "sys-apps/lsd", + # Portage tools + "app-portage/gentoolkit", + "app-portage/portage-utils", + "app-portage/eix", +] + +# User groups for desktop use +# Minimal set - elogind grants device access to active session +USER_GROUPS = [ + "users", + "wheel", # sudo access + "input", # Wayland compositor input devices +] + + +def emerge_shell_packages() -> None: + """Emerge shell and portage tool packages.""" + info("=== Installing Shell & Portage Tools ===") + + print() + info("Packages to install:") + for pkg in SHELL_PACKAGES: + print(f" - {pkg}") + print() + + emerge(*SHELL_PACKAGES) + + success("Shell packages installed.") + + +def install_sudo() -> None: + """Install and configure sudo.""" + info("=== Installing sudo ===") + + emerge("app-admin/sudo") + + # Configure sudoers - enable wheel group + sudoers = Path("/etc/sudoers") + if sudoers.exists(): + content = sudoers.read_text() + + # Check if wheel group already has sudo access (any format) + if re.search(r"^%wheel\s+ALL=", content, re.MULTILINE): + success("Wheel group already enabled in sudoers") + elif re.search(r"^#\s*%wheel\s+ALL=", content, re.MULTILINE): + # Uncomment the existing commented line + content = re.sub( + r"^#\s*(%wheel\s+ALL=.*$)", + r"\1", + content, + flags=re.MULTILINE + ) + sudoers.write_text(content) + success("Enabled wheel group in sudoers") + else: + # Append if not present at all + with open(sudoers, "a") as f: + f.write("\n# Allow wheel group to use sudo\n") + f.write("%wheel ALL=(ALL:ALL) ALL\n") + success("Added wheel group to sudoers") + + success("sudo configured.") + + +def copy_shell_configs(source_dir: Path, target_home: Path, owner: str | None = None) -> None: + """Copy shell configuration files to a home directory.""" + # .zshrc + zshrc_src = source_dir / ".zshrc" + if zshrc_src.exists(): + shutil.copy2(zshrc_src, target_home / ".zshrc") + success(f"Copied .zshrc to {target_home}") + + # starship.toml + starship_src = source_dir / "starship.toml" + if starship_src.exists(): + config_dir = target_home / ".config" + config_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(starship_src, config_dir / "starship.toml") + success(f"Copied starship.toml to {config_dir}") + + # Fix ownership if specified + if owner: + run("chown", "-R", f"{owner}:{owner}", str(target_home), check=False) + + +def setup_root_shell(source_dir: Path) -> None: + """Configure root's shell environment.""" + info("=== Configuring Root Shell ===") + + copy_shell_configs(source_dir, Path("/root")) + + # Change root's shell to zsh + run("chsh", "-s", "/bin/zsh", "root") + success("Root shell set to zsh") + + +def set_root_password() -> None: + """Prompt to set root password.""" + info("=== Set Root Password ===") + print() + run("passwd") + print() + + +def create_user(username: str, source_dir: Path) -> None: + """Create a new user with proper groups and shell config.""" + info(f"=== Creating User: {username} ===") + + # Build groups string + groups = ",".join(USER_GROUPS) + + # Check if user already exists using id command (more reliable than string matching) + result = run_quiet("id", username, check=False) + if result.ok: + info(f"User {username} already exists, ensuring groups are correct...") + run("usermod", "-aG", groups, username, check=False) + else: + # Create user + result = run( + "useradd", "-m", + "-G", groups, + "-s", "/bin/zsh", + username, + check=False + ) + if not result.ok: + error(f"Failed to create user: {result.stderr}") + return + + success(f"User {username} configured with groups: {groups}") + + # Copy shell configs + user_home = Path(f"/home/{username}") + copy_shell_configs(source_dir, user_home, owner=username) + + # Set user password + print() + info(f"Set password for {username}:") + run("passwd", username) + print() + + success(f"User {username} configured.") + + +def setup_users(source_dir: Path | None = None) -> None: + """Full user setup workflow.""" + if source_dir is None: + source_dir = Path("/root/gentoo") + + emerge_shell_packages() + print() + install_sudo() + print() + setup_root_shell(source_dir) + print() + set_root_password() + + # Prompt for username + print() + info("=== Create User Account ===") + username = prompt("Enter username to create: ").strip() + + if username: + create_user(username, source_dir) + else: + info("Skipped user creation.") + + print() + success("=== User Setup Complete ===") + print() + info("Next steps:") + print(" 1. Install @hyprland set for desktop environment") + print(" 2. Configure display manager (SDDM)") + print(" 3. Reboot and login as your user") diff --git a/install/utils.py b/install/utils.py new file mode 100644 index 0000000..5058ec2 --- /dev/null +++ b/install/utils.py @@ -0,0 +1,209 @@ +"""Common utilities for Gentoo installation.""" + +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import NoReturn + + +# --- Colors --- + +class Color: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[0;33m" + BOLD = "\033[1m" + NC = "\033[0m" # No color + + +def info(msg: str) -> None: + print(f"{Color.YELLOW}{msg}{Color.NC}") + + +def success(msg: str) -> None: + print(f"{Color.GREEN}{msg}{Color.NC}") + + +def error(msg: str) -> None: + print(f"{Color.RED}{msg}{Color.NC}", file=sys.stderr) + + +def warn(msg: str) -> None: + print(f"{Color.YELLOW}WARNING: {msg}{Color.NC}", file=sys.stderr) + + +def fatal(msg: str) -> NoReturn: + error(f"FATAL: {msg}") + sys.exit(1) + + +def prompt(msg: str) -> str: + return input(f"{Color.YELLOW}{msg}{Color.NC}") + + +def confirm(msg: str, require: str = "yes") -> bool: + response = prompt(f"{msg} (type '{require}' to confirm): ") + return response == require + + +# --- Command Execution --- + +@dataclass +class RunResult: + returncode: int + stdout: str + stderr: str + + @property + def ok(self) -> bool: + return self.returncode == 0 + + +def run( + *args: str, + check: bool = True, + capture: bool = False, + quiet: bool = False, +) -> RunResult: + """Run a command with proper error handling. + + Args: + *args: Command and arguments + check: Raise exception on non-zero exit + capture: Capture stdout/stderr (otherwise inherit terminal) + quiet: Suppress command echo + """ + if not quiet: + info(f"$ {' '.join(args)}") + + result = subprocess.run( + args, + capture_output=capture, + text=True, + ) + + run_result = RunResult( + returncode=result.returncode, + stdout=result.stdout if capture else "", + stderr=result.stderr if capture else "", + ) + + if check and not run_result.ok: + error(f"Command failed with exit code {result.returncode}") + if capture and result.stderr: + error(result.stderr) + raise subprocess.CalledProcessError(result.returncode, args) + + return run_result + + +def run_quiet(*args: str, check: bool = True) -> RunResult: + """Run a command silently, capturing output.""" + return run(*args, check=check, capture=True, quiet=True) + + +# --- System Checks --- + +def check_root() -> None: + """Ensure running as root.""" + import os + if os.geteuid() != 0: + fatal("This script must be run as root") + + +def check_uefi() -> None: + """Ensure booted in UEFI mode.""" + if not Path("/sys/firmware/efi/efivars").is_dir(): + fatal("UEFI boot mode not detected. This script requires UEFI.") + success("UEFI boot mode confirmed.") + + +def is_mounted(path: Path) -> bool: + """Check if a path is a mount point.""" + result = run_quiet("mountpoint", "-q", str(path), check=False) + return result.ok + + +def is_block_device(path: Path) -> bool: + """Check if path is a block device.""" + return path.is_block_device() + + +# --- Portage Helpers --- + +def dispatch_config() -> bool: + """Auto-merge pending portage config changes. + + Returns True if changes were dispatched, False if none found. + """ + portage_dir = Path("/etc/portage") + + # Check for pending changes + has_cfg_files = bool(list(portage_dir.glob("._cfg*"))) + has_autounmask = any( + (portage_dir / subdir / "zz-autounmask").exists() + for subdir in ["package.use", "package.accept_keywords", "package.license"] + ) + + if not has_cfg_files and not has_autounmask: + return False + + info("Auto-merging pending portage config changes...") + + # Try dispatch-conf first, fall back to etc-update + result = run( + "bash", "-c", "yes u | dispatch-conf", + check=False, capture=True, quiet=True + ) + if not result.ok: + run( + "etc-update", "--automode", "-5", + check=False, capture=True, quiet=True + ) + + return True + + +def emerge( + *packages: str, + ask: bool = True, + verbose: bool = True, + extra_args: list[str] | None = None, + max_retries: int = 3, +) -> None: + """Run emerge with automatic handling of USE/keyword/license changes. + + Args: + *packages: Package atoms to emerge + ask: Prompt for confirmation (--ask) + verbose: Show detailed output (--verbose) + extra_args: Additional emerge arguments (e.g., ["--update", "--deep"]) + max_retries: Max attempts after auto-dispatching config changes + """ + args = ["emerge", "--autounmask-write", "--autounmask-continue"] + if ask: + args.append("--ask") + if verbose: + args.append("--verbose") + if extra_args: + args.extend(extra_args) + args.extend(packages) + + for attempt in range(1, max_retries + 1): + if max_retries > 1: + info(f"Emerge attempt {attempt}/{max_retries}...") + + result = run(*args, check=False) + + if result.ok: + return + + # Check if we can auto-dispatch and retry + if dispatch_config(): + continue + + # No config changes to dispatch, actual failure + fatal(f"Emerge failed: {' '.join(packages)}") + + fatal(f"Emerge failed after {max_retries} attempts") diff --git a/iptables/ip6tables.rules b/iptables/ip6tables.rules new file mode 100644 index 0000000..364168e --- /dev/null +++ b/iptables/ip6tables.rules @@ -0,0 +1,42 @@ +*filter +:INPUT DROP [0:0] +:FORWARD DROP [0:0] +:OUTPUT ACCEPT [0:0] + +# ============================================================ +# Stateful connection tracking +# ============================================================ + +# Allow established and related connections +-A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + +# ============================================================ +# Loopback interface - always allow +# ============================================================ + +-A INPUT -i lo -j ACCEPT + +# ============================================================ +# ICMPv6 - required for IPv6 neighbor discovery +# ============================================================ + +-A INPUT -p icmpv6 --icmpv6-type router-solicitation -j ACCEPT +-A INPUT -p icmpv6 --icmpv6-type router-advertisement -j ACCEPT +-A INPUT -p icmpv6 --icmpv6-type neighbor-solicitation -j ACCEPT +-A INPUT -p icmpv6 --icmpv6-type neighbor-advertisement -j ACCEPT +-A INPUT -p icmpv6 --icmpv6-type echo-request -j ACCEPT +-A INPUT -p icmpv6 --icmpv6-type echo-reply -j ACCEPT + +# ============================================================ +# Link-local addresses only +# ============================================================ + +-A INPUT -s fe80::/10 -j ACCEPT + +# ============================================================ +# Default deny - drop everything not explicitly allowed +# ============================================================ + +-A INPUT -j DROP + +COMMIT diff --git a/iptables/iptables.rules b/iptables/iptables.rules new file mode 100644 index 0000000..a1409ad --- /dev/null +++ b/iptables/iptables.rules @@ -0,0 +1,42 @@ +*filter +:INPUT DROP [0:0] +:FORWARD DROP [0:0] +:OUTPUT ACCEPT [0:0] + +# ============================================================ +# Stateful connection tracking +# ============================================================ + +# Allow established and related connections (return traffic) +-A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + +# ============================================================ +# Loopback interface - always allow +# ============================================================ + +-A INPUT -i lo -j ACCEPT + +# ============================================================ +# Trusted networks - customize for your environment +# ============================================================ + +# Example: Allow from specific interface (VPN, etc.) +# -A INPUT -i tun0 -j ACCEPT + +# Example: Allow from local network +# -A INPUT -s 192.168.1.0/24 -j ACCEPT + +# ============================================================ +# ICMP - allow ping for diagnostics +# ============================================================ + +-A INPUT -p icmp --icmp-type echo-request -j ACCEPT +-A INPUT -p icmp --icmp-type echo-reply -j ACCEPT + +# ============================================================ +# Default deny - drop everything not explicitly allowed +# ============================================================ + +-A INPUT -j DROP + +COMMIT diff --git a/portage/env/clang b/portage/env/clang new file mode 100644 index 0000000..485cd28 --- /dev/null +++ b/portage/env/clang @@ -0,0 +1,5 @@ +# Use clang/clang++ instead of GCC +# Workaround for GCC 15 ICE (internal compiler error) with -march=znver3 +# and C++26 modules on certain packages +CC="clang" +CXX="clang++" diff --git a/portage/env/reduced b/portage/env/reduced new file mode 100644 index 0000000..dd0dabf --- /dev/null +++ b/portage/env/reduced @@ -0,0 +1,3 @@ +# Reduced parallelism for memory-hungry builds +MAKEOPTS="-j6" +NINJAOPTS="-j6" diff --git a/portage/make.conf b/portage/make.conf new file mode 100644 index 0000000..18dce93 --- /dev/null +++ b/portage/make.conf @@ -0,0 +1,55 @@ +# make.conf for Legion S7 15ACH6 +# AMD Ryzen 9 5900HX with Radeon Graphics (16) @ 4.683GHz +# AMD ATI Radeon Vega Series / Radeon Vega Mobile Series +# NVIDIA GeForce RTX 3050 Ti Mobile / Max-Q + +# Compiler Settings +COMMON_FLAGS="-march=znver3 -O2 -pipe" +CFLAGS="${COMMON_FLAGS}" +CXXFLAGS="${COMMON_FLAGS}" +FCFLAGS="${COMMON_FLAGS}" +FFLAGS="${COMMON_FLAGS}" +CHOST="x86_64-pc-linux-gnu" + +# Build Settings +MAKEOPTS="-j16 -l14" +EMERGE_DEFAULT_OPTS="--jobs=2 --load-average=14 --autounmask-write" + +# Mirrors and Boot +GENTOO_MIRRORS="https://mirrors.rit.edu/gentoo/ https://gentoo.osuosl.org/" +GRUB_PLATFORMS="efi-64" + +# Portage Features +FEATURES="ccache parallel-fetch candy" +CCACHE_DIR="/var/cache/ccache" + +# Cleaner build output +LC_MESSAGES=C + +# Hardware +VIDEO_CARDS="amdgpu nvidia" +INPUT_DEVICES="libinput" + +# USE Flags - Optimized for Hyprland Desktop + +# Core system +USE="btrfs wayland X opengl vulkan egl gbm" +# Graphics - hybrid AMD iGPU + NVIDIA dGPU +USE="${USE} nvidia amdgpu opencl vaapi" +# Audio - PipeWire only +USE="${USE} pipewire alsa" +# Network and connectivity +USE="${USE} networkmanager bluetooth wifi" +# Desktop environment essentials +USE="${USE} udisks cups nls threads ssl crypt zstd" +# GUI toolkits and libraries +USE="${USE} gtk qt5 qt6 ffmpeg webp libnotify harfbuzz icu" +# Wayland/Hyprland specific +USE="${USE} screencast udev" +# Kernel +USE="${USE} dist-kernel" +# Firmware (explicit for linux-firmware) +USE="${USE} redistributable" + +# License Acceptance +ACCEPT_LICENSE="linux-fw-redistributable all-rights-reserved NVIDIA-* ValveSteamLicense BUSL-1.1" diff --git a/portage/package.accept_keywords/gvfs b/portage/package.accept_keywords/gvfs new file mode 100644 index 0000000..652b836 --- /dev/null +++ b/portage/package.accept_keywords/gvfs @@ -0,0 +1,2 @@ +# GVFS virtual filesystem +gnome-base/gvfs ~amd64 diff --git a/portage/package.accept_keywords/hyprland b/portage/package.accept_keywords/hyprland new file mode 100644 index 0000000..563cace --- /dev/null +++ b/portage/package.accept_keywords/hyprland @@ -0,0 +1,104 @@ +# Hyprland ecosystem packages requiring ~amd64 +# Verified against packages.gentoo.org on 2026-01-13 + +# ============================================================================= +# GURU OVERLAY PACKAGES +# ============================================================================= + +# Hyprland core and libraries +gui-wm/hyprland ~amd64 +gui-libs/aquamarine ~amd64 +gui-libs/hyprutils ~amd64 +gui-libs/hyprtoolkit ~amd64 +gui-libs/hyprwire ~amd64 +gui-libs/hyprland-guiutils ~amd64 +dev-libs/hyprlang ~amd64 +dev-libs/hyprgraphics ~amd64 +dev-util/hyprwayland-scanner ~amd64 + +# Hyprland apps (GURU) +gui-apps/hypridle ~amd64 +gui-apps/hyprlock ~amd64 +gui-apps/hyprpaper ~amd64 +gui-apps/hyprsunset ~amd64 +gui-apps/quickshell ~amd64 +gui-apps/hyprpicker ~amd64 +gui-libs/hyprcursor ~amd64 +sys-auth/hyprpolkitagent ~amd64 + +# Wayland utilities (GURU) +gui-apps/swaync ~amd64 +gui-apps/awww ~amd64 +gui-apps/wlogout ~amd64 +gui-apps/nwg-displays ~amd64 +app-misc/nwg-look ~amd64 +gui-apps/rofi-wayland ~amd64 + +# Clipboard (GURU) +app-misc/cliphist ~amd64 + +# Theming (GURU) +x11-themes/kvantum ~amd64 +x11-themes/qogir-icon-theme ~amd64 +x11-themes/bibata-cursor ~amd64 +x11-themes/gtk-engines-murrine ~amd64 + +# D-Bus library (GURU) +dev-cpp/sdbus-c++ ~amd64 + +# GTK4 layer shell (GURU) +gui-libs/gtk4-layer-shell ~amd64 + +# Audio (GURU) +media-sound/pamixer ~amd64 + +# Wallpaper tools (GURU) +x11-misc/wallust ~amd64 + +# XDG portals (GURU) +gui-libs/xdg-desktop-portal-hyprland ~amd64 +# Note: sys-apps/xdg-desktop-portal-gtk is stable in main tree, no keyword needed + +# Brightness control (GURU) +app-misc/brightnessctl ~amd64 + +# Fonts (GURU) +media-fonts/nerdfonts ~amd64 +media-fonts/fontawesome ~amd64 + +# Media plugins (GURU) +mpv-plugin/mpv-mpris ~amd64 + +# NVIDIA VA-API (GURU) +media-libs/nvidia-vaapi-driver ~amd64 + +# Dialogs (GURU) +gnome-extra/yad ~amd64 + +# Shell plugins (GURU) +app-shells/zsh-autosuggestions ~amd64 + +# Libraries +# Note: dev-util/umockdev is stable in main tree, no keyword needed +# Note: dev-libs/libdbusmenu is stable in main tree, no keyword needed + +# Utilities (GURU) +app-misc/bc ~amd64 + +# ============================================================================= +# MAIN TREE PACKAGES (testing only, no stable version) +# ============================================================================= + +# Shell utilities (main tree, ~amd64 only) +app-shells/zoxide ~amd64 + +# Image viewer (main tree, ~amd64 only) +media-gfx/loupe ~amd64 + +# Build dependencies +dev-cpp/glaze ~amd64 +dev-util/breakpad ~amd64 +dev-libs/linux-syscall-support ~amd64 +dev-embedded/libdisasm ~amd64 +gui-apps/wlr-randr ~amd64 +x11-apps/xcur2png ~amd64 diff --git a/portage/package.accept_keywords/jetbrains b/portage/package.accept_keywords/jetbrains new file mode 100644 index 0000000..495b942 --- /dev/null +++ b/portage/package.accept_keywords/jetbrains @@ -0,0 +1,2 @@ +# JetBrains Toolbox +dev-util/jetbrains-toolbox ~amd64 diff --git a/portage/package.accept_keywords/nvidia b/portage/package.accept_keywords/nvidia new file mode 100644 index 0000000..d23ea0e --- /dev/null +++ b/portage/package.accept_keywords/nvidia @@ -0,0 +1,3 @@ +# NVIDIA drivers and EGL Wayland +x11-drivers/nvidia-drivers ~amd64 +gui-libs/egl-wayland2 ~amd64 diff --git a/portage/package.accept_keywords/podman b/portage/package.accept_keywords/podman new file mode 100644 index 0000000..47c25a2 --- /dev/null +++ b/portage/package.accept_keywords/podman @@ -0,0 +1,3 @@ +# Podman container tools +app-containers/podman-compose ~amd64 +app-containers/podman-tui ~amd64 diff --git a/portage/package.accept_keywords/steam b/portage/package.accept_keywords/steam new file mode 100644 index 0000000..65617da --- /dev/null +++ b/portage/package.accept_keywords/steam @@ -0,0 +1,4 @@ +# Steam overlay +*/*::steam-overlay +games-util/game-device-udev-rules +sys-libs/libudev-compat diff --git a/portage/package.accept_keywords/udisks b/portage/package.accept_keywords/udisks new file mode 100644 index 0000000..f3b2d30 --- /dev/null +++ b/portage/package.accept_keywords/udisks @@ -0,0 +1,3 @@ +# UDisks disk management +sys-fs/udisks ~amd64 +sys-libs/libblockdev ~amd64 diff --git a/portage/package.env/clang b/portage/package.env/clang new file mode 100644 index 0000000..35a49dc --- /dev/null +++ b/portage/package.env/clang @@ -0,0 +1 @@ +llvm-core/clang reduced diff --git a/portage/package.env/hyprland b/portage/package.env/hyprland new file mode 100644 index 0000000..01f91f4 --- /dev/null +++ b/portage/package.env/hyprland @@ -0,0 +1 @@ +gui-wm/hyprland clang diff --git a/portage/package.env/llvm b/portage/package.env/llvm new file mode 100644 index 0000000..42ebd31 --- /dev/null +++ b/portage/package.env/llvm @@ -0,0 +1 @@ +llvm-core/llvm reduced diff --git a/portage/package.env/webkit b/portage/package.env/webkit new file mode 100644 index 0000000..ad662c2 --- /dev/null +++ b/portage/package.env/webkit @@ -0,0 +1,2 @@ +# webkit-gtk is extremely memory-hungry +net-libs/webkit-gtk reduced diff --git a/portage/package.use/circular-dependencies b/portage/package.use/circular-dependencies new file mode 100644 index 0000000..ee4bf59 --- /dev/null +++ b/portage/package.use/circular-dependencies @@ -0,0 +1,10 @@ +# Circular dependency workarounds for initial @world compile +# +# IMPORTANT: After @world compiles successfully, you should: +# Run: emerge -1 media-libs/freetype media-libs/harfbuzz +# +# This ensures both packages are built with full feature support. + +dev-python/pillow -truetype +media-libs/libwebp -tiff +media-libs/freetype -harfbuzz diff --git a/portage/package.use/gvfs b/portage/package.use/gvfs new file mode 100644 index 0000000..18873c5 --- /dev/null +++ b/portage/package.use/gvfs @@ -0,0 +1,2 @@ +# GVFS - FUSE support for user-space mounts +gnome-base/gvfs fuse diff --git a/portage/package.use/initial-use-flags b/portage/package.use/initial-use-flags new file mode 100644 index 0000000..d62ff6e --- /dev/null +++ b/portage/package.use/initial-use-flags @@ -0,0 +1,33 @@ +# Package-specific USE flags for Hyprland desktop + +# Python target for nwg-displays +gui-apps/nwg-displays python_targets_python3_12 + +# GTK layer shell for Wayland +gui-libs/gtk-layer-shell introspection vala + +# NerdFonts variants to install +media-fonts/nerdfonts firacode jetbrainsmono fantasque sourcecodepro hack + +# PipeWire extras +media-video/pipewire ffmpeg extra + +# Thunar panel integration +xfce-base/thunar-volman udisks + +# MTP support for phones/devices +gnome-base/gvfs mtp + +# QuickShell Qt6 support +dev-qt/qt5compat qml + +# NVIDIA driver options +x11-drivers/nvidia-drivers modules-sign persistenced + +# Legion laptop hardware control (fan curves, power management) +sys-firmware/lenovolegionlinux gui elogind + +# Linux firmware redistributable blobs +# Prevent linux-firmware from pulling in gentoo-kernel before @world is compiled +# The global dist-kernel USE flag would otherwise trigger kernel installation +sys-kernel/linux-firmware redistributable -dist-kernel diff --git a/portage/package.use/iptables b/portage/package.use/iptables new file mode 100644 index 0000000..b099c16 --- /dev/null +++ b/portage/package.use/iptables @@ -0,0 +1,2 @@ +# Required by podman for container networking +net-firewall/iptables nftables diff --git a/portage/package.use/llvm b/portage/package.use/llvm new file mode 100644 index 0000000..f157be1 --- /dev/null +++ b/portage/package.use/llvm @@ -0,0 +1,4 @@ +# Build LLVM/clang with clang instead of GCC +# Workaround for GCC 15 ICE on AMDGPURewriteAGPRCopyMFMA.cpp +llvm-core/llvm clang +llvm-core/clang clang diff --git a/portage/package.use/nodejs b/portage/package.use/nodejs new file mode 100644 index 0000000..1cc8d25 --- /dev/null +++ b/portage/package.use/nodejs @@ -0,0 +1,2 @@ +# Node.js - include npm package manager +net-libs/nodejs npm diff --git a/portage/package.use/podman b/portage/package.use/podman new file mode 100644 index 0000000..6cdc617 --- /dev/null +++ b/portage/package.use/podman @@ -0,0 +1,2 @@ +# Podman container runtime +app-containers/podman wrapper diff --git a/portage/package.use/steam b/portage/package.use/steam new file mode 100644 index 0000000..3bba9d2 --- /dev/null +++ b/portage/package.use/steam @@ -0,0 +1,140 @@ +# Steam 32-bit dependencies +app-accessibility/at-spi2-core abi_x86_32 +app-arch/bzip2 abi_x86_32 +app-arch/lz4 abi_x86_32 +app-arch/xz-utils abi_x86_32 +app-arch/zstd abi_x86_32 +app-crypt/p11-kit abi_x86_32 +dev-db/sqlite abi_x86_32 +dev-lang/rust-bin abi_x86_32 +dev-libs/dbus-glib abi_x86_32 +dev-libs/elfutils abi_x86_32 +dev-libs/expat abi_x86_32 +dev-libs/fribidi abi_x86_32 +dev-libs/glib abi_x86_32 +dev-libs/gmp abi_x86_32 +dev-libs/icu abi_x86_32 +dev-libs/json-glib abi_x86_32 +dev-libs/libevdev abi_x86_32 +dev-libs/libffi abi_x86_32 +dev-libs/libgcrypt abi_x86_32 +dev-libs/libgpg-error abi_x86_32 +dev-libs/libgudev abi_x86_32 +dev-libs/libgusb abi_x86_32 +dev-libs/libpcre2 abi_x86_32 +dev-libs/libtasn1 abi_x86_32 +dev-libs/libunistring abi_x86_32 +dev-libs/libusb abi_x86_32 +dev-libs/libxml2 abi_x86_32 +dev-libs/lzo abi_x86_32 +dev-libs/nettle abi_x86_32 +dev-libs/nspr abi_x86_32 +dev-libs/nss abi_x86_32 +dev-libs/openssl abi_x86_32 +dev-libs/wayland abi_x86_32 +dev-util/directx-headers abi_x86_32 +dev-util/spirv-tools abi_x86_32 +dev-util/sysprof-capture abi_x86_32 +gnome-base/librsvg abi_x86_32 +gui-libs/libdecor abi_x86_32 +llvm-core/clang abi_x86_32 +llvm-core/llvm abi_x86_32 +media-gfx/graphite2 abi_x86_32 +media-libs/alsa-lib abi_x86_32 +media-libs/flac abi_x86_32 +media-libs/fontconfig abi_x86_32 +media-libs/freetype abi_x86_32 +media-libs/glu abi_x86_32 +media-libs/harfbuzz abi_x86_32 +media-libs/lcms abi_x86_32 +media-libs/libepoxy abi_x86_32 +media-libs/libglvnd abi_x86_32 +media-libs/libjpeg-turbo abi_x86_32 +media-libs/libogg abi_x86_32 +media-libs/libpng abi_x86_32 +media-libs/libpulse abi_x86_32 +media-libs/libsdl2 abi_x86_32 +media-libs/libsndfile abi_x86_32 +media-libs/libva abi_x86_32 +media-libs/libvorbis abi_x86_32 +media-libs/libwebp abi_x86_32 +media-libs/mesa abi_x86_32 vulkan +media-libs/openal abi_x86_32 +media-libs/opus abi_x86_32 +media-libs/tiff abi_x86_32 +media-libs/vulkan-loader abi_x86_32 +media-sound/lame abi_x86_32 +media-sound/mpg123-base abi_x86_32 +media-video/pipewire abi_x86_32 +net-dns/c-ares abi_x86_32 +net-dns/libidn2 abi_x86_32 +net-libs/gnutls abi_x86_32 +net-libs/libasyncns abi_x86_32 +net-libs/libndp abi_x86_32 +net-libs/libpsl abi_x86_32 +net-libs/nghttp2 abi_x86_32 +net-libs/nghttp3 abi_x86_32 +net-misc/curl abi_x86_32 +net-misc/networkmanager abi_x86_32 +net-print/cups abi_x86_32 +sys-apps/dbus abi_x86_32 +sys-apps/systemd-utils abi_x86_32 +sys-apps/util-linux abi_x86_32 +sys-libs/gdbm abi_x86_32 +sys-libs/gpm abi_x86_32 +sys-libs/libcap abi_x86_32 +sys-libs/libudev-compat abi_x86_32 +sys-libs/ncurses abi_x86_32 +sys-libs/pam abi_x86_32 +sys-libs/readline abi_x86_32 +sys-libs/zlib abi_x86_32 +virtual/glu abi_x86_32 +virtual/libelf abi_x86_32 +virtual/libiconv abi_x86_32 +virtual/libintl abi_x86_32 +virtual/libudev abi_x86_32 +virtual/libusb abi_x86_32 +virtual/opengl abi_x86_32 +virtual/rust abi_x86_32 +virtual/zlib abi_x86_32 +x11-libs/cairo abi_x86_32 +x11-libs/extest abi_x86_32 +x11-libs/gdk-pixbuf abi_x86_32 +x11-libs/gtk+ abi_x86_32 +x11-libs/libdrm abi_x86_32 +x11-libs/libICE abi_x86_32 +x11-libs/libpciaccess abi_x86_32 +x11-libs/libSM abi_x86_32 +x11-libs/libvdpau abi_x86_32 +x11-libs/libX11 abi_x86_32 +x11-libs/libXau abi_x86_32 +x11-libs/libxcb abi_x86_32 +x11-libs/libXcomposite abi_x86_32 +x11-libs/libXcursor abi_x86_32 +x11-libs/libXdamage abi_x86_32 +x11-libs/libXdmcp abi_x86_32 +x11-libs/libXext abi_x86_32 +x11-libs/libXfixes abi_x86_32 +x11-libs/libXft abi_x86_32 +x11-libs/libXi abi_x86_32 +x11-libs/libXinerama abi_x86_32 +x11-libs/libxkbcommon abi_x86_32 +x11-libs/libXrandr abi_x86_32 +x11-libs/libXrender abi_x86_32 +x11-libs/libXScrnSaver abi_x86_32 +x11-libs/libxshmfence abi_x86_32 +x11-libs/libXtst abi_x86_32 +x11-libs/libXxf86vm abi_x86_32 +x11-libs/pango abi_x86_32 +x11-libs/pixman abi_x86_32 +x11-libs/xcb-util-keysyms abi_x86_32 +x11-misc/colord abi_x86_32 + +# NVIDIA 32-bit support +gui-libs/egl-gbm abi_x86_32 +gui-libs/egl-wayland abi_x86_32 +gui-libs/egl-x11 abi_x86_32 +x11-drivers/nvidia-drivers abi_x86_32 + +# Easy Anti-Cheat support +sys-libs/glibc hash-sysv-compat diff --git a/portage/package.use/waybar b/portage/package.use/waybar new file mode 100644 index 0000000..33e6d62 --- /dev/null +++ b/portage/package.use/waybar @@ -0,0 +1,2 @@ +# Waybar - status bar for Wayland +gui-apps/waybar network tray mpris diff --git a/portage/sets/hyprland b/portage/sets/hyprland new file mode 100644 index 0000000..bafade4 --- /dev/null +++ b/portage/sets/hyprland @@ -0,0 +1,173 @@ +# Hyprland Desktop Set for Legion S7 15ACH6 +# Based on JaKooLit Arch-Hyprland dotfiles +# For use with: emerge @hyprland + +# ============================================================================= +# HYPRLAND CORE +# ============================================================================= +gui-wm/hyprland +gui-apps/hypridle +gui-apps/hyprlock +gui-apps/quickshell +gui-libs/hyprcursor +gui-apps/hyprsunset +gui-apps/awww +sys-auth/hyprpolkitagent +gui-apps/hyprpicker + +# ============================================================================= +# DESKTOP UTILITIES +# ============================================================================= +sys-devel/bc +app-misc/cliphist +app-misc/jq +gui-apps/grim +gui-apps/slurp +gui-apps/swappy +gui-apps/swaync +gui-apps/waybar +gui-apps/wl-clipboard +gui-apps/wlogout +gui-apps/rofi-wayland +x11-misc/wallust + +# ============================================================================= +# QT/GTK THEMING +# ============================================================================= +dev-qt/qt5compat +dev-qt/qtsvg +dev-qt/qtdeclarative +gui-apps/qt6ct +x11-misc/qt5ct +x11-themes/kvantum + +# ============================================================================= +# SYSTEM UTILITIES +# ============================================================================= +gnome-base/gvfs +gnome-extra/nm-applet +gnome-extra/polkit-gnome +gnome-extra/yad +media-gfx/imagemagick +media-libs/libspng +sys-apps/inxi +x11-misc/xdg-user-dirs +x11-misc/xdg-utils + +# ============================================================================= +# AUDIO +# ============================================================================= +media-sound/pamixer +media-sound/pavucontrol +media-sound/playerctl + +# ============================================================================= +# PYTHON DEPENDENCIES +# ============================================================================= +dev-python/pyquery +dev-python/requests +dev-python/beautifulsoup4 +dev-python/pygments +dev-python/pyyaml +dev-python/secretstorage +dev-python/uv + +# ============================================================================= +# TERMINAL & TOOLS +# ============================================================================= +app-arch/unzip +app-arch/xarchiver +net-misc/curl +net-misc/wget +x11-terms/kitty + +# ============================================================================= +# MONITORING +# ============================================================================= +app-misc/fastfetch +media-sound/cava +sys-process/btop +sys-process/htop +sys-process/nvtop + +# ============================================================================= +# MEDIA +# ============================================================================= +media-video/mpv +mpv-plugin/mpv-mpris +media-video/ffmpegthumbnailer +net-misc/yt-dlp + +# ============================================================================= +# DESKTOP EXTRAS +# ============================================================================= +app-editors/mousepad +app-text/evince +gnome-extra/gnome-system-monitor +gui-apps/nwg-displays +app-misc/nwg-look +media-gfx/loupe +sci-calculators/qalculate-gtk +app-misc/brightnessctl +sys-power/upower + +# ============================================================================= +# FILE MANAGER +# ============================================================================= +gnome-base/nautilus +xfce-base/tumbler + +# ============================================================================= +# FONTS +# ============================================================================= +media-fonts/fantasque-sans-mono +media-fonts/fira-code +media-fonts/fontawesome +media-fonts/jetbrains-mono +media-fonts/nerdfonts +media-fonts/noto +media-fonts/noto-emoji + +# ============================================================================= +# THEMES & ICONS +# ============================================================================= +lxde-base/lxappearance +x11-themes/gtk-engines-murrine +x11-libs/gtk+:3 +x11-themes/adwaita-qt +x11-themes/bibata-cursor +x11-themes/gtk-engines +x11-themes/papirus-icon-theme +x11-themes/qogir-icon-theme + +# ============================================================================= +# XDG PORTALS +# ============================================================================= +sys-apps/xdg-desktop-portal-gtk +gui-libs/xdg-desktop-portal-hyprland +dev-util/umockdev + +# ============================================================================= +# ADDITIONAL DEV/LIBS +# ============================================================================= +dev-build/meson +dev-libs/gjs +dev-libs/glib +dev-libs/gobject-introspection +gnome-base/gnome-keyring +net-libs/libsoup:3.0 +net-libs/nodejs +dev-libs/libdbusmenu +# Note: sddm is installed via services.sh + +# ============================================================================= +# GRAPHICS (NVIDIA/AMD HYBRID) +# ============================================================================= +media-libs/libva +media-libs/nvidia-vaapi-driver + +# ============================================================================= +# POWER MANAGEMENT & FIRMWARE +# ============================================================================= +sys-power/cpupower +sys-apps/fwupd diff --git a/procedure.md b/procedure.md new file mode 100644 index 0000000..5452ad0 --- /dev/null +++ b/procedure.md @@ -0,0 +1,410 @@ +# Gentoo Installation Procedure - Legion Laptop + +Automated installation using Python scripts for Lenovo Legion laptops with AMD + NVIDIA hybrid graphics. + +## Hardware + +Tested on Legion S7 15ACH6: +- **CPU**: AMD Ryzen 9 5900HX (16 threads, Zen 3) +- **iGPU**: AMD Radeon Vega (Cezanne) +- **dGPU**: NVIDIA GeForce RTX 3050 Ti Mobile +- **RAM**: 24GB DDR4 +- **Storage**: NVMe with LUKS2 encryption + +Should work on other Legion models with similar hardware. + +## Quick Start + +Two commands handle the entire installation: + +```bash +# Phase 1: Live environment -> chroot -> first reboot +python setup.py --install # Run once outside chroot, once inside + +# Phase 2: After first boot +python setup.py --desktop # Installs Hyprland + optional fingerprint +``` + +`--install` auto-detects whether you're in the live environment or chroot: +- **Outside chroot**: Runs disk → stage3 → config → fstab → chroot +- **Inside chroot**: Runs sync → world → firmware → kernel → services → users → nvidia → bootloader + +### Idempotency + +**All commands are safe to re-run.** If interrupted or if something fails: +- Simply run the same command again +- Completed steps are detected and skipped +- Partial state is handled gracefully (e.g., LUKS exists but not mounted) + +Example: If `world` fails mid-compile, just run `python setup.py --install` again. + +--- + +## Pre-Installation + +### 1. Prepare LUKS Passphrase + +Generate a strong passphrase for disk encryption: + +```bash +# Generate 56-char alphanumeric passphrase +openssl rand -base64 64 | tr -dc 'A-Za-z0-9' | head -c 56 +``` + +Store securely (password manager, hardware key, offline backup). + +### 2. Boot Live Environment + +At GRUB menu, press `e`, add `video=1920x1080` to the linux line (HiDPI fix), then `Ctrl+X`. + +```bash +# Configure network +net-setup + +# Verify DNS +ping -c 2 gentoo.org +``` + +### 3. Clone Repository + +```bash +emerge --ask dev-vcs/git +git clone https://github.com//gentoo-legion-python /root/gentoo +cd /root/gentoo +``` + +--- + +## Stage 1: Disk Setup (Pre-Chroot) + +Run from live environment: + +```bash +python setup.py +``` + +Select commands in order, or run individually: + +### 1) disk - Partition, Encrypt, Mount + +- Creates GPT partition table +- EFI partition (1GB) +- Swap partition (24GB) +- LUKS2 encrypted root (remaining space) +- Btrfs with subvolumes (@, @home, @var, @log, @snapshots) +- Mounts everything to /mnt/gentoo + +**Re-running**: If LUKS already exists, you'll be prompted to `reformat` or `reuse`. Select `reuse` to skip destructive steps and just mount the existing setup. + +### 2) stage3 - Download & Extract + +- Fetches latest stage3-amd64-openrc tarball +- Verifies checksum +- Extracts to /mnt/gentoo + +### 3) config - Copy Portage Configuration + +Copies from `portage/` to `/mnt/gentoo/etc/portage/`: +- make.conf +- package.use/ +- package.accept_keywords/ +- package.env/ +- package.license/ +- env/ +- sets/ + +Also copies from repo root to `/mnt/gentoo/etc/`: +- dracut.conf.d/ + +### 4) fstab - Generate Filesystem Config + +Generates: +- /etc/fstab (EFI, Btrfs subvolumes) +- /etc/conf.d/dmcrypt (encrypted swap) + +### 5) chroot - Prepare & Enter + +- Copies resolv.conf for network +- Mounts /proc, /sys, /dev, /run +- Enters chroot + +**Or run all pre-chroot steps:** +```bash +python setup.py all +``` + +--- + +## Stage 2: Base System (Inside Chroot) + +After entering chroot, clone the repo: + +```bash +# Sync portage first (needed to install anything) +emerge --sync + +# Install git and clone repo +emerge --ask dev-vcs/git +git clone https://github.com//gentoo-legion-python /root/gentoo +cd /root/gentoo +python setup.py --install +``` + +The `--install` command will pick up where it left off (inside chroot it runs sync → world → ... → bootloader). + +### 7) sync - Portage Sync & Profile + +- Syncs portage tree (webrsync) +- Sets profile: default/linux/amd64/23.0/desktop +- Enables GURU overlay (required for Hyprland) +- Sets timezone (America/New_York) +- Configures locale (en_US.UTF-8) +- Sets hostname +- Activates encrypted swap for builds + +Note: ccache is enabled automatically via `FEATURES="ccache"` in make.conf. + +### 8) world - Update @world + +```bash +emerge --ask --verbose --update --deep --newuse @world +``` + +This takes several hours. Swap is active to prevent OOM. + +**Circular Dependencies:** After @world completes, the script automatically: +1. Reads `package.use/circular-dependencies` for temporarily disabled USE flags +2. Clears the file (keeps header comments) +3. Rebuilds affected packages with full USE flags (freetype, harfbuzz, libwebp, pillow) + +### 9) firmware - Install Linux Firmware + +```bash +emerge sys-kernel/linux-firmware +``` + +### 10) kernel - Install Kernel + +Choose: +1. `gentoo-kernel-bin` - Precompiled, fastest +2. `gentoo-kernel` - Compiled locally + +Dracut auto-generates initramfs with crypt + NVIDIA modules. + +--- + +## Stage 3: Services & Configuration + +### 11) services - System Services + +Installs and configures: +- **System**: sysklogd, cronie +- **Network**: NetworkManager, chrony, iptables +- **Bluetooth**: bluez, blueman +- **Desktop**: udisks, power-profiles-daemon, rtkit +- **Display Manager**: SDDM +- **Containers**: podman, podman-compose +- **Backup**: snapper + +Configures OpenRC runlevels: +- **boot**: dbus, elogind, dmcrypt +- **default**: All services listed above + +Copies from repo: +- `conf.d/` → `/etc/conf.d/` (dmcrypt, iptables, snapper configs) +- `iptables/` → `/var/lib/iptables/rules-save` and `/var/lib/ip6tables/rules-save` +- `udev/` → `/etc/udev/rules.d/` (power profile rules) + +### 12) users - Shell & User Setup + +Installs shell tools: +- zsh, zsh-autosuggestions, zsh-syntax-highlighting +- starship, zoxide, fzf, lsd +- gentoolkit, portage-utils, eix + +Then: +1. Copies .zshrc and starship.toml to /root +2. Sets root shell to zsh +3. Prompts for root password +4. Configures sudo (enables wheel group) +5. Creates user with groups: users, wheel, input +6. Copies shell config to user home +7. Prompts for user password + +--- + +## Stage 4: Graphics & Bootloader + +### 13) nvidia - NVIDIA Drivers + +1. Installs nvidia-drivers +2. Blacklists nouveau (`/etc/modprobe.d/blacklist-nouveau.conf`) +3. Configures DRM modeset (`/etc/modprobe.d/nvidia.conf`) +4. Copies dracut nvidia.conf for early KMS +5. Optionally rebuilds initramfs +6. Verifies NVIDIA modules in initramfs + +### 14) bootloader - GRUB Installation + +1. Copies dracut crypt.conf for LUKS support +2. Installs GRUB +3. Installs to EFI partition (--bootloader-id=Gentoo) +4. Configures /etc/default/grub: + - `GRUB_CMDLINE_LINUX_DEFAULT="nvidia_drm.modeset=1 acpi_backlight=native"` + - `GRUB_ENABLE_CRYPTODISK=y` + - `GRUB_GFXMODE=1920x1080` (HiDPI fix) +5. Generates grub.cfg +6. **Verifies initramfs** - checks for crypt/LUKS and NVIDIA modules + +--- + +## First Boot + +After GRUB, you'll be prompted for your LUKS passphrase, then login as root. + +### Connect to WiFi + +NetworkManager has no saved connections yet. Use the TUI: + +```bash +nmtui +``` + +Select **Activate a connection** → choose your network → enter password. + +Or via command line: + +```bash +nmcli device wifi list +nmcli device wifi connect "YourSSID" password "YourPassword" +``` + +Verify connectivity: + +```bash +ping -c 2 gentoo.org +``` + +### Clone Repository (as user) + +Switch to your user account and clone the repo: + +```bash +su - +git clone https://github.com//gentoo-legion-python ~/gentoo +``` + +--- + +## Stage 5: Desktop Environment + +### Install Hyprland Desktop + +As your user (not root): + +```bash +sudo emerge --ask @hyprland +``` + +This installs: +- Hyprland compositor +- Waybar, swaync, rofi, wlogout +- Qt/GTK theming (Kvantum, qt5ct, qt6ct) +- Fonts (Nerd Fonts, JetBrains Mono, etc.) +- Media tools (mpv, ffmpeg) +- File manager (Nautilus) +- And more... + +### Post-Install Configuration + +Copy Hyprland configs for multi-monitor setup: +```bash +# GPU auto-detect (comment out explicit AQ_* settings) +cp ~/gentoo/hypr/ENVariables.conf ~/.config/hypr/UserConfigs/ + +# Triple monitor layout (customize for your setup) +cp ~/gentoo/hypr/monitors.conf ~/.config/hypr/ +``` + +### 15) fingerprint - Fingerprint Authentication (Optional) + +```bash +python setup.py fingerprint +``` + +Sets up the Elan fingerprint reader (04f3:0c4b) for authentication: + +1. Installs `fprintd` and `libfprint` packages +2. Downloads Lenovo TOD driver (`libfprint-2-tod1-elan.so`) +3. Enrolls fingerprints for user +4. Configures PAM for SDDM and hyprlock + +**Usage after setup:** +- **SDDM/hyprlock**: Press Enter on empty password field to activate fingerprint +- **Enroll more fingers**: `fprintd-enroll -f ` +- **Test**: `fprintd-verify ` + +**Note**: Fingerprint is configured as an alternative to password, not a replacement. + +--- + +## Utilities + +### Swap Management + +```bash +python setup.py swap-on # Activate encrypted swap +python setup.py swap-off # Deactivate encrypted swap +``` + +Swap auto-activates during `sync` for heavy builds. + +--- + +## Quick Reference + +### Complete Installation Order + +**Pre-chroot (live environment):** +``` +disk → stage3 → config → fstab → chroot +``` + +**Inside chroot:** +``` +sync → world → firmware → kernel → services → users → nvidia → bootloader +``` + +**After reboot:** +``` +nmtui (connect WiFi) → su - → emerge @hyprland → configure Hyprland +``` + +### Troubleshooting + +**Command failed mid-way**: Just run the same command again. All commands are idempotent and will skip completed steps. + +**Build OOM**: Activate swap with `python setup.py swap-on` + +**Initramfs missing modules**: Run `python setup.py bootloader` to verify + +**NVIDIA not loading**: Check `/etc/modprobe.d/` configs, rebuild initramfs with `dracut --force` + +**Firewall not active**: Verify `/var/lib/iptables/rules-save` exists + +**USE flag / keyword changes**: Handled automatically. The installer uses `--autounmask-write` and auto-dispatches config changes on retry. + +**Disk already partitioned**: The `disk` command detects existing LUKS and offers to reuse it instead of reformatting. + +**WiFi not working after reboot**: Ensure `NetworkManager` service is running (`rc-service NetworkManager status`). Check `nmcli device` to see if the WiFi adapter is recognized. + +### File Locations + +| Config | Location | +|--------|----------| +| Portage | /etc/portage/ | +| Dracut | /etc/dracut.conf.d/ | +| Firewall rules | /var/lib/iptables/rules-save | +| NVIDIA modprobe | /etc/modprobe.d/nvidia.conf | +| GRUB | /etc/default/grub | +| Shell config | ~/.zshrc, ~/.config/starship.toml | diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..116616e --- /dev/null +++ b/setup.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +"""Gentoo installation orchestrator for Legion S7 15ACH6. + +Usage: + python setup.py [command] + python setup.py --install # Full install up to first reboot + python setup.py --desktop # Post-reboot desktop setup + +Major Phases: + --install Full installation to first reboot (auto-detects chroot state) + Outside chroot: disk -> stage3 -> config -> fstab -> chroot + Inside chroot: sync -> world -> firmware -> kernel -> services + -> users -> nvidia -> bootloader + --desktop Post-reboot desktop setup (Hyprland + optional fingerprint) + +Individual Commands: + disk Partition, encrypt, and mount disks + stage3 Download and extract stage3 tarball + chroot Prepare and enter chroot environment + config Copy portage and system configuration + fstab Generate fstab, crypttab, and dmcrypt config + sync Initial portage sync, profile, and overlays (inside chroot) + world Update @world (inside chroot) + firmware Install linux-firmware (inside chroot) + kernel Install kernel (inside chroot) + services Install and configure system services (inside chroot) + users Configure shell, sudo, and create user (inside chroot) + nvidia Install NVIDIA drivers and configure (inside chroot) + bootloader Install GRUB and verify initramfs (inside chroot) + fingerprint Set up fingerprint authentication (post-install) + swap-on Activate encrypted swap (inside chroot) + swap-off Deactivate encrypted swap (inside chroot) + all Run pre-chroot steps (disk -> chroot) + +Examples: + python setup.py --install # Run full install phase (pre-reboot) + python setup.py --desktop # Run desktop setup (post-reboot) + python setup.py disk # Just partition disks + python setup.py # Interactive menu +""" + +import argparse +import sys +from pathlib import Path + +from install.utils import info, success, error, fatal, check_root, check_uefi, prompt, emerge +from install.disk import DiskConfig, DiskLayout, prepare_disk +from install.stage3 import Stage3Config, fetch_stage3 +from install.chroot import prepare_chroot, enter_chroot +from install.fstab import generate_all as generate_fstab_all +from install.portage import copy_portage_config +from install.sync import initial_sync, activate_swap, deactivate_swap +from install.services import setup_services +from install.users import setup_users +from install.nvidia import setup_nvidia +from install.bootloader import setup_bootloader + + +# Path to config files (same directory as this script) +SCRIPT_DIR = Path(__file__).parent.resolve() +CONFIG_DIR = SCRIPT_DIR # Config files live alongside setup.py + + +# Global state (set during disk preparation) +_disk_layout: DiskLayout | None = None +_disk_config: DiskConfig | None = None + + +def cmd_disk() -> tuple[DiskLayout, DiskConfig]: + """Partition, encrypt, and mount disks.""" + global _disk_layout, _disk_config + + check_uefi() + layout, config = prepare_disk() + + _disk_layout = layout + _disk_config = config + + return layout, config + + +def cmd_stage3() -> None: + """Download and extract stage3.""" + config = Stage3Config( + init_system="openrc", + mount_root=Path("/mnt/gentoo"), + ) + fetch_stage3(config) + + +def cmd_config() -> None: + """Copy portage configuration.""" + copy_portage_config( + source_dir=CONFIG_DIR, + mount_root=Path("/mnt/gentoo"), + ) + + +def cmd_fstab(layout: DiskLayout | None = None, config: DiskConfig | None = None) -> None: + """Generate filesystem configuration files.""" + if layout is None or config is None: + fatal("Disk layout not available. Run 'disk' command first.") + + generate_fstab_all(layout, config) + + +def cmd_chroot() -> None: + """Prepare and enter chroot.""" + prepare_chroot() + + print() + response = prompt("Enter chroot now? (y/n): ") + if response.lower() == "y": + enter_chroot() + + +def cmd_sync() -> None: + """Initial portage sync and profile setup (run inside chroot).""" + initial_sync() + + +def cmd_swap_on() -> None: + """Activate encrypted swap (inside chroot).""" + activate_swap() + + +def cmd_swap_off() -> None: + """Deactivate encrypted swap (inside chroot).""" + deactivate_swap() + + +def cmd_world() -> None: + """Update @world (inside chroot).""" + info("=== Updating @world ===") + emerge("@world", extra_args=["--update", "--deep", "--newuse"]) + success("@world update complete.") + + # Handle circular dependencies - rebuild with full USE flags + circ_deps_file = Path("/etc/portage/package.use/circular-dependencies") + if circ_deps_file.exists(): + packages = parse_circular_deps(circ_deps_file) + if packages: + print() + info("=== Rebuilding Circular Dependency Packages ===") + info(f"Packages: {', '.join(packages)}") + + # Clear the file (keep header) + clear_circular_deps(circ_deps_file) + + # Rebuild with full USE flags + emerge(*packages, extra_args=["--oneshot"], ask=False) + success("Circular dependency packages rebuilt with full USE flags.") + + +def parse_circular_deps(filepath: Path) -> list[str]: + """Parse package names from circular-dependencies file.""" + packages = [] + for line in filepath.read_text().splitlines(): + line = line.strip() + # Skip comments and empty lines + if not line or line.startswith("#"): + continue + # Extract package name (first token before USE flags) + parts = line.split() + if parts: + packages.append(parts[0]) + return packages + + +def clear_circular_deps(filepath: Path) -> None: + """Clear circular-dependencies file, keeping header comments.""" + lines = filepath.read_text().splitlines() + header = [] + for line in lines: + # Keep comments and blank lines in header + if line.startswith("#") or line.strip() == "": + header.append(line) + else: + # Stop at first package line + break + # Write header, ensure trailing newline + if header: + filepath.write_text("\n".join(header) + "\n") + else: + filepath.write_text("") + + +def cmd_firmware() -> None: + """Install linux-firmware (inside chroot).""" + info("=== Installing Linux Firmware ===") + emerge("sys-kernel/linux-firmware") + success("Linux firmware installed.") + + +def cmd_kernel() -> None: + """Install kernel (inside chroot).""" + info("=== Installing Kernel ===") + print() + info("Options:") + print(" 1) gentoo-kernel-bin - Precompiled, fastest") + print(" 2) gentoo-kernel - Compiled locally with your USE flags") + print() + + choice = prompt("Select kernel [1]: ").strip() + + if choice == "2": + emerge("sys-kernel/gentoo-kernel") + else: + emerge("sys-kernel/gentoo-kernel-bin") + + success("Kernel installed.") + print() + info("Kernel modules installed. Dracut will generate initramfs.") + + +def cmd_services() -> None: + """Install and configure services (inside chroot).""" + setup_services(source_dir=CONFIG_DIR) + + +def cmd_users() -> None: + """Configure shell, sudo, and create user (inside chroot).""" + setup_users(source_dir=CONFIG_DIR) + + +def cmd_nvidia() -> None: + """Install NVIDIA drivers and configure (inside chroot).""" + setup_nvidia(source_dir=CONFIG_DIR) + + +def cmd_bootloader() -> None: + """Install GRUB and verify initramfs (inside chroot).""" + setup_bootloader(source_dir=CONFIG_DIR) + + +def cmd_fingerprint() -> None: + """Set up fingerprint authentication (post-install).""" + from install.fingerprint import setup_fingerprint + setup_fingerprint(source_dir=CONFIG_DIR) + + +def is_in_chroot() -> bool: + """Detect if we're running inside chroot vs live environment.""" + # If /mnt/gentoo is mounted, we're in the live environment + # If not, we're either in chroot or on the installed system + from install.utils import is_mounted + return not is_mounted(Path("/mnt/gentoo")) + + +def cmd_install() -> None: + """Full installation up to first reboot.""" + if is_in_chroot(): + # Inside chroot: run sync through bootloader + info("=== Install Phase (inside chroot) ===") + print() + info("Running: sync -> world -> firmware -> kernel -> services -> users -> nvidia -> bootloader") + print() + + cmd_sync() + print() + cmd_world() + print() + cmd_firmware() + print() + cmd_kernel() + print() + cmd_services() + print() + cmd_users() + print() + cmd_nvidia() + print() + cmd_bootloader() + print() + + success("=== Install phase complete ===") + print() + print("Next steps:") + print(" 1. Exit chroot: exit") + print(" 2. Unmount: umount -R /mnt/gentoo") + print(" 3. Reboot: reboot") + print(" 4. After reboot, run: python setup.py --desktop") + else: + # Outside chroot: run disk through chroot, then prompt + info("=== Install Phase (pre-chroot) ===") + print() + + layout, config = cmd_disk() + print() + cmd_stage3() + print() + cmd_config() + print() + cmd_fstab(layout, config) + print() + cmd_chroot() + + # After chroot exits, remind user + print() + success("=== Pre-chroot phase complete ===") + print() + print("You exited the chroot. To continue installation:") + print(" 1. Re-enter chroot: chroot /mnt/gentoo /bin/bash") + print(" 2. Source profile: source /etc/profile && export PS1='(chroot) $PS1'") + print(" 3. Run: python /root/gentoo/v4/setup.py --install") + + +def cmd_desktop() -> None: + """Post-reboot desktop setup.""" + info("=== Desktop Setup (post-reboot) ===") + print() + + # Install Hyprland desktop + info("Installing Hyprland desktop environment...") + emerge("@hyprland", extra_args=["--update", "--deep", "--newuse"]) + success("Hyprland desktop installed.") + print() + + # Offer fingerprint setup + response = prompt("Set up fingerprint authentication? [y/N]: ").strip().lower() + if response in ("y", "yes"): + print() + cmd_fingerprint() + else: + info("Skipped fingerprint setup. Run 'python setup.py fingerprint' later if needed.") + + print() + success("=== Desktop setup complete ===") + print() + print("Next steps:") + print(" 1. Reboot to apply display manager") + print(" 2. Log in via SDDM") + print(" 3. Copy Hyprland configs:") + print(" cp ~/gentoo/v4/hypr/ENVariables.conf ~/.config/hypr/UserConfigs/") + print(" cp ~/gentoo/v4/hypr/monitors.conf ~/.config/hypr/") + + +def cmd_all() -> None: + """Full installation workflow.""" + info("=== Gentoo Full Installation ===") + print() + + # Step 1: Disk preparation + info("Step 1: Disk Preparation") + layout, config = cmd_disk() + + # Step 2: Stage3 + print() + info("Step 2: Stage3 Download & Extract") + cmd_stage3() + + # Step 3: Configuration + print() + info("Step 3: Copy Configuration") + cmd_config() + + # Step 4: Generate fstab/crypttab + print() + info("Step 4: Generate Filesystem Config") + cmd_fstab(layout, config) + + # Step 5: Chroot + print() + info("Step 5: Chroot Preparation") + cmd_chroot() + + +def interactive_menu() -> None: + """Interactive command selection.""" + print() + info("=== Gentoo Installer for Legion S7 15ACH6 ===") + print() + print("Commands (pre-chroot):") + print(" 1) disk - Partition, encrypt, and mount disks") + print(" 2) stage3 - Download and extract stage3") + print(" 3) config - Copy portage configuration") + print(" 4) fstab - Generate fstab/crypttab") + print(" 5) chroot - Prepare and enter chroot") + print(" 6) all - Full pre-chroot installation") + print() + print("Commands (inside chroot):") + print(" 7) sync - Portage sync, profile, overlays (activates swap)") + print(" 8) world - emerge -avuDN @world") + print(" 9) firmware - Install linux-firmware") + print(" 10) kernel - Install kernel") + print(" 11) services - Install and configure services") + print(" 12) users - Configure shell, sudo, create user") + print(" 13) nvidia - Install NVIDIA drivers") + print(" 14) bootloader - Install GRUB, verify initramfs") + print() + print("Post-install:") + print(" 15) fingerprint - Set up fingerprint authentication") + print() + print("Utilities:") + print(" 16) swap-on - Activate encrypted swap") + print(" 17) swap-off - Deactivate encrypted swap") + print() + print("Full workflows:") + print(" i) install - Full install to first reboot (auto-detects chroot)") + print(" d) desktop - Post-reboot desktop setup (Hyprland + fingerprint)") + print() + print(" q) quit") + print() + + choice = prompt("Select command: ").strip().lower() + + if choice in ("1", "disk"): + cmd_disk() + elif choice in ("2", "stage3"): + cmd_stage3() + elif choice in ("3", "config"): + cmd_config() + elif choice in ("4", "fstab"): + if _disk_layout is None or _disk_config is None: + error("Disk layout not available.") + error("Run 'disk' command first in this session, or use 'all' for full installation.") + return + cmd_fstab(_disk_layout, _disk_config) + elif choice in ("5", "chroot"): + cmd_chroot() + elif choice in ("6", "all"): + cmd_all() + elif choice in ("7", "sync"): + cmd_sync() + elif choice in ("8", "world"): + cmd_world() + elif choice in ("9", "firmware"): + cmd_firmware() + elif choice in ("10", "kernel"): + cmd_kernel() + elif choice in ("11", "services"): + cmd_services() + elif choice in ("12", "users"): + cmd_users() + elif choice in ("13", "nvidia"): + cmd_nvidia() + elif choice in ("14", "bootloader"): + cmd_bootloader() + elif choice in ("15", "fingerprint"): + cmd_fingerprint() + elif choice in ("16", "swap-on"): + cmd_swap_on() + elif choice in ("17", "swap-off"): + cmd_swap_off() + elif choice in ("i", "install"): + cmd_install() + elif choice in ("d", "desktop"): + cmd_desktop() + elif choice == "q": + sys.exit(0) + else: + error("Invalid choice") + interactive_menu() + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Gentoo installation orchestrator", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "command", + nargs="?", + choices=["disk", "stage3", "chroot", "config", "fstab", "sync", "world", "firmware", "kernel", "services", "users", "nvidia", "bootloader", "fingerprint", "swap-on", "swap-off", "all"], + help="Command to run (interactive menu if not specified)", + ) + parser.add_argument( + "--install", + action="store_true", + help="Full installation up to first reboot (auto-detects chroot state)", + ) + parser.add_argument( + "--desktop", + action="store_true", + help="Post-reboot desktop setup (Hyprland + fingerprint)", + ) + + args = parser.parse_args() + + check_root() + + # Handle major phase flags first + if args.install: + cmd_install() + elif args.desktop: + cmd_desktop() + elif args.command is None: + interactive_menu() + elif args.command == "disk": + cmd_disk() + elif args.command == "stage3": + cmd_stage3() + elif args.command == "config": + cmd_config() + elif args.command == "fstab": + # For standalone fstab, we need disk info + error("Run 'disk' first, or use 'all' for full installation") + sys.exit(1) + elif args.command == "chroot": + cmd_chroot() + elif args.command == "sync": + cmd_sync() + elif args.command == "world": + cmd_world() + elif args.command == "firmware": + cmd_firmware() + elif args.command == "kernel": + cmd_kernel() + elif args.command == "services": + cmd_services() + elif args.command == "users": + cmd_users() + elif args.command == "nvidia": + cmd_nvidia() + elif args.command == "bootloader": + cmd_bootloader() + elif args.command == "fingerprint": + cmd_fingerprint() + elif args.command == "swap-on": + cmd_swap_on() + elif args.command == "swap-off": + cmd_swap_off() + elif args.command == "all": + cmd_all() + + +if __name__ == "__main__": + main() diff --git a/starship.toml b/starship.toml new file mode 100644 index 0000000..c69d78f --- /dev/null +++ b/starship.toml @@ -0,0 +1,146 @@ +## FIRST LINE/ROW: Info & Status +# First param ─┌ +[username] +format = " [╭─$user]($style)@" +show_always = true +style_root = "bold #ff5f5f" +style_user = "bold #61afef" + +# Second param +[hostname] +disabled = false +format = "[$hostname]($style) in " +ssh_only = false +style = "bold #56b6c2" +trim_at = "-" + +# Third param +[directory] +style = "#c678dd" +truncate_to_repo = true +truncation_length = 0 +truncation_symbol = "repo: " + +# Fourth param +[sudo] +disabled = false + +# Before all the version info (python, nodejs, php, etc.) +[git_status] +ahead = "⇡${count}" +behind = "⇣${count}" +deleted = "x" +diverged = "⇕⇡${ahead_count}⇣${behind_count}" +style = "bold #e5c07b" + +# Last param in the first line/row +[cmd_duration] +disabled = false +format = "took [$duration]($style)" +min_time = 1 + +## SECOND LINE/ROW: Prompt +# Somethere at the beginning +[battery] +charging_symbol = "" +disabled = true +discharging_symbol = "" +full_symbol = "" + +[[battery.display]] # "bold red" style when capacity is between 0% and 15% +disabled = false +style = "bold #ff5f5f" +threshold = 15 + +[[battery.display]] # "bold yellow" style when capacity is between 15% and 50% +disabled = true +style = "bold #e5c07b" +threshold = 50 + +[[battery.display]] # "bold green" style when capacity is between 50% and 80% +disabled = true +style = "bold #98c379" +threshold = 80 + +# Prompt: optional param 1 +[time] +disabled = true +format = " 🕙 $time($style)\n" +style = "#abb2bf" +time_format = "%T" + +# Prompt: param 2 +[character] +error_symbol = " [×](bold #ff5f5f)" +success_symbol = " [╰─>](bold #61afef)" + +# SYMBOLS +[status] +disabled = false +format = '[\[$symbol$status_common_meaning$status_signal_name$status_maybe_int\]]($style)' +map_symbol = true +pipestatus = true +symbol = "🔴" + +[aws] +symbol = " " + +[conda] +symbol = " " + +[dart] +symbol = " " + +[docker_context] +symbol = " " + +[elixir] +symbol = " " + +[elm] +symbol = " " + +[git_branch] +symbol = " " + +[golang] +symbol = " " + +[hg_branch] +symbol = " " + +[java] +symbol = " " + +[julia] +symbol = " " + +[nim] +symbol = " " + +[nix_shell] +symbol = " " + +[nodejs] +symbol = " " + +[package] +symbol = " " + +[perl] +symbol = " " + +[php] +symbol = " " + +[python] +symbol = " " + +[ruby] +symbol = " " + +[rust] +symbol = " " + +[swift] +symbol = " " \ No newline at end of file diff --git a/udev/99-power-profile.rules b/udev/99-power-profile.rules new file mode 100644 index 0000000..14225f4 --- /dev/null +++ b/udev/99-power-profile.rules @@ -0,0 +1,3 @@ +# Automatic power profile switching based on AC state +ACTION=="change", SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/powerprofilesctl set performance" +ACTION=="change", SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/powerprofilesctl set power-saver"