diff --git a/pi4/build/chroot-image.sh b/pi4/build/chroot-image.sh new file mode 100755 index 0000000..ac504b9 --- /dev/null +++ b/pi4/build/chroot-image.sh @@ -0,0 +1,198 @@ +#! /bin/bash +# +# Copyright 2025, Stormux, +# +# This is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free +# Software Foundation; either version 3, or (at your option) any later +# version. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this package; see the file COPYING. If not, write to the Free +# Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. +# + +set -euo pipefail + +mountedPoints=() +loopDev="" +mountRoot="" +rootPart="" +bootPart="" +imagePath="" + +cleanup() { + statusCode=$? + set +e + + if [[ ${#mountedPoints[@]} -gt 0 ]]; then + for ((index=${#mountedPoints[@]}-1; index>=0; index--)); do + if mountpoint -q "${mountedPoints[$index]}"; then + umount "${mountedPoints[$index]}" || true + fi + done + fi + + if [[ -n "${mountRoot}" && -d "${mountRoot}" && "${mountRoot}" != "/mnt" ]]; then + rmdir "${mountRoot}" 2>/dev/null || true + fi + + if [[ -n "${loopDev}" ]]; then + partx -d "${loopDev}" 2>/dev/null || true + losetup --detach "${loopDev}" 2>/dev/null || true + fi + + exit "$statusCode" +} + +trap cleanup EXIT INT TERM + +die() { + echo "Error: $*" >&2 + exit 1 +} + +help() { + echo "Usage: $0 " + echo "Mounts a Stormux image, chroots into it, and cleans up on exit." + exit 0 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" +} + +if [[ $# -ne 1 ]]; then + help +fi + +if [[ "$1" == "-h" || "$1" == "--help" ]]; then + help +fi + +imagePath="$(readlink -f "$1" 2>/dev/null || true)" +if [[ -z "${imagePath}" ]]; then + die "Unable to resolve image path: $1" +fi +if [[ ! -f "${imagePath}" ]]; then + die "Image file not found: ${imagePath}" +fi + +if [[ "$(whoami)" != "root" ]]; then + die "This script must be run as root." +fi + +if command -v pacman >/dev/null 2>&1; then + for packageName in arch-install-scripts util-linux; do + if ! pacman -Q "$packageName" &>/dev/null; then + die "Please install ${packageName} before continuing." + fi + done +else + require_cmd arch-chroot + require_cmd losetup + require_cmd partx + require_cmd lsblk + require_cmd mount + require_cmd umount + require_cmd findmnt + require_cmd mountpoint +fi + +if [[ "$(uname -m)" == "x86_64" ]]; then + if command -v pacman >/dev/null 2>&1; then + if ! pacman -Q qemu-user-static &>/dev/null; then + die "Please install qemu-user-static and qemu-user-static-binfmt before continuing." + fi + if ! pacman -Q qemu-user-static-binfmt &>/dev/null; then + die "Please install qemu-user-static and qemu-user-static-binfmt before continuing." + fi + else + require_cmd qemu-aarch64-static + fi +fi + +require_cmd arch-chroot +require_cmd losetup +require_cmd partx +require_cmd lsblk +require_cmd mount +require_cmd umount +require_cmd findmnt +require_cmd mountpoint + +baseMountDir="/mnt" +if [[ ! -d "${baseMountDir}" || ! -w "${baseMountDir}" ]]; then + die "/mnt is missing or not writable. Please create or fix permissions." +fi + +if mountpoint -q "${baseMountDir}"; then + if [[ -t 0 ]]; then + echo "/mnt is already mounted. Unmount it to continue? [y/N]" + read -r answerText + case "${answerText}" in + y|Y|yes|YES) + umount "${baseMountDir}" || die "Failed to unmount /mnt" + ;; + *) + die "Refusing to proceed while /mnt is mounted." + ;; + esac + else + die "/mnt is already mounted. Refusing to proceed in non-interactive mode." + fi +fi + +mountRoot="${baseMountDir}" + +loopDev="$(losetup --find --show --partscan "${imagePath}")" +partx -u "${loopDev}" >/dev/null 2>&1 || true + +declare -a rootCandidates=() +declare -a bootCandidates=() + +while read -r deviceName fsType nodeType; do + if [[ "${nodeType}" != "part" ]]; then + continue + fi + case "${fsType}" in + ext4|ext3|ext2) + rootCandidates+=("${deviceName}") + ;; + vfat|fat|fat32|fat16) + bootCandidates+=("${deviceName}") + ;; + esac +done < <(lsblk -nrpo NAME,FSTYPE,TYPE "${loopDev}") + +if [[ ${#rootCandidates[@]} -gt 0 ]]; then + rootPart="${rootCandidates[0]}" +elif [[ -b "${loopDev}p2" ]]; then + rootPart="${loopDev}p2" +else + die "Unable to locate root partition inside ${imagePath}" +fi + +if [[ ${#bootCandidates[@]} -gt 0 ]]; then + bootPart="${bootCandidates[0]}" +elif [[ -b "${loopDev}p1" ]]; then + bootPart="${loopDev}p1" +fi + +mount "${rootPart}" "${mountRoot}" +mountedPoints+=("${mountRoot}") + +if [[ -n "${bootPart}" ]]; then + mkdir -p "${mountRoot}/boot" + mount "${bootPart}" "${mountRoot}/boot" + mountedPoints+=("${mountRoot}/boot") +fi + +echo "Mounted image at ${mountRoot}. Entering chroot..." +PS1="(Chroot) [\\u@\\h \\W] \\$ " arch-chroot "${mountRoot}" diff --git a/pi4/files/etc/udev/rules.d/99-brltty.rules b/pi4/files/etc/udev/rules.d/99-brltty.rules new file mode 100644 index 0000000..43250ca --- /dev/null +++ b/pi4/files/etc/udev/rules.d/99-brltty.rules @@ -0,0 +1 @@ +SUBSYSTEM=="tty", KERNEL=="tty[0-9]*|hvc[0-9]*|sclp_line[0-9]*|ttysclp[0-9]*|3270/tty[0-9]*", GROUP="tty", MODE="0620" diff --git a/pi4/files/usr/local/bin/sas b/pi4/files/usr/local/bin/sas new file mode 100755 index 0000000..4b289d5 --- /dev/null +++ b/pi4/files/usr/local/bin/sas @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 + +import os +import re +import secrets +import signal +import string +import subprocess +import sys +import tempfile +import time +import pwd +import threading + + +stormuxAdmin = ("storm",) +ircServer = "irc.stormux.org" +ircPort = 6667 +ircChannel = "#stormux" +remoteHost = "billysballoons.com" +sasUser = "sas" +pingIntervalSeconds = 180 +pingCount = 5 +maxWormholeFailures = 3 + +sudoKeepaliveThread = None +sudoKeepaliveStop = threading.Event() + + +def speak_message(message): + try: + subprocess.run(["spd-say", message], check=False) + except FileNotFoundError: + print(message, flush=True) + + +def say_or_print(message, useSpeech): + if useSpeech: + speak_message(message) + else: + print(message, flush=True) + + +def run_command(command, inputText=None, check=False, env=None): + return subprocess.run( + command, + input=inputText, + text=True, + capture_output=True, + check=check, + env=env, + ) + + +def ensure_sudo(useSpeech): + if os.geteuid() == 0: + return True + if useSpeech: + speak_message("Sudo password required. Please enter your password now.") + result = run_command(["sudo", "-v"]) + if result.returncode == 0: + start_sudo_keepalive() + return True + return False + + +def start_sudo_keepalive(): + global sudoKeepaliveThread + if sudoKeepaliveThread and sudoKeepaliveThread.is_alive(): + return + + def keepalive_loop(): + while not sudoKeepaliveStop.wait(240): + run_command(["sudo", "-n", "-v"]) + + sudoKeepaliveThread = threading.Thread(target=keepalive_loop, daemon=True) + sudoKeepaliveThread.start() + + +def run_privileged(command, useSpeech, inputText=None, check=True): + if os.geteuid() == 0: + fullCommand = command + else: + if not ensure_sudo(useSpeech): + raise RuntimeError("sudo authentication failed") + fullCommand = ["sudo"] + command + return run_command(fullCommand, inputText=inputText, check=check) + + +def run_as_user(userName, command, useSpeech, check=True): + if os.geteuid() == 0: + fullCommand = ["sudo", "-u", userName, "-H"] + command + else: + if not ensure_sudo(useSpeech): + raise RuntimeError("sudo authentication failed") + fullCommand = ["sudo", "-u", userName, "-H"] + command + return run_command(fullCommand, check=check) + + +def user_exists(userName): + result = run_command(["getent", "passwd", userName]) + return result.returncode == 0 + + +def ensure_wheel(userName, useSpeech): + result = run_command(["id", "-nG", userName]) + groups = result.stdout.strip().split() + if "wheel" not in groups: + run_privileged(["usermod", "-a", "-G", "wheel", userName], useSpeech) + + +def generate_password(): + allowedChars = string.ascii_letters + string.digits + length = secrets.randbelow(5) + 6 + return "".join(secrets.choice(allowedChars) for _ in range(length)) + + +def get_user_home(userName): + return pwd.getpwnam(userName).pw_dir + + +def set_password(userName, password, useSpeech): + run_privileged(["chpasswd"], useSpeech, inputText=f"{userName}:{password}\n") + + +def generate_ssh_key(userName, useSpeech): + userHome = get_user_home(userName) + sshDir = os.path.join(userHome, ".ssh") + privateKeyPath = os.path.join(sshDir, "id_ed25519") + publicKeyPath = f"{privateKeyPath}.pub" + + run_privileged(["mkdir", "-p", sshDir], useSpeech) + run_privileged(["chown", f"{userName}:{userName}", sshDir], useSpeech) + run_privileged(["chmod", "700", sshDir], useSpeech) + for entry in list_subdirs(sshDir): + if os.path.isfile(entry) or os.path.islink(entry): + run_privileged(["rm", "-f", entry], useSpeech, check=False) + + run_as_user( + userName, + ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", privateKeyPath], + useSpeech, + ) + + run_privileged(["chmod", "600", privateKeyPath], useSpeech) + run_privileged(["chmod", "644", publicKeyPath], useSpeech) + run_privileged(["chown", f"{userName}:{userName}", privateKeyPath, publicKeyPath], useSpeech) + + return privateKeyPath, publicKeyPath + + +def path_exists_for_user(path, userName, useSpeech): + result = run_as_user(userName, ["stat", path], useSpeech, check=False) + return result.returncode == 0 + + +def extract_message_text(line): + if "> " in line: + return line.split("> ", 1)[1].strip() + if ": " in line: + return line.split(": ", 1)[1].strip() + return line.strip() + + +def parse_sender(line): + match = re.search(r"<([^>]+)>", line) + if match: + return match.group(1) + return None + + +def find_wormhole_code(message): + lowered = message.strip().lower() + if lowered in ("yes", "accept"): + return None + match = re.search(r"\b\d+-[A-Za-z0-9-]+\b", message) + if match: + return match.group(0) + return None + + +class IrcSession: + def __init__(self, server, port, nick, channel, baseDir): + self.server = server + self.port = port + self.nick = nick + self.channel = channel + self.baseDir = baseDir + self.serverDir = None + self.serverInPath = None + self.channelInPath = None + self.iiProcess = None + self.pmOffsets = {} + + def start(self): + if not shutil_which("ii"): + raise RuntimeError("ii is not installed") + supportsI = ii_supports_i() + processEnv = os.environ.copy() + iiCommand = ["ii", "-s", self.server, "-p", str(self.port), "-n", self.nick] + if supportsI: + iiCommand += ["-i", self.baseDir] + else: + processEnv["HOME"] = self.baseDir + self.iiProcess = subprocess.Popen( + iiCommand, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=processEnv, + ) + self.serverDir = self.wait_for_server_dir() + self.serverInPath = os.path.join(self.serverDir, "in") + + def stop(self): + if self.channelInPath and os.path.exists(self.channelInPath): + try: + self.write_line(self.channelInPath, f"/part {self.channel}") + except OSError: + pass + if self.serverInPath and os.path.exists(self.serverInPath): + try: + self.write_line(self.serverInPath, "/quit") + except OSError: + pass + if self.iiProcess and self.iiProcess.poll() is None: + self.iiProcess.terminate() + try: + self.iiProcess.wait(timeout=5) + except subprocess.TimeoutExpired: + self.iiProcess.kill() + + def join_channel(self): + joinMessage = f"/join {self.channel}" + channelDir = os.path.join(self.serverDir, self.channel) + channelAltDir = os.path.join(self.serverDir, self.channel.lstrip("#")) + startTime = time.monotonic() + nextJoinTime = startTime + while time.monotonic() - startTime < 60: + if time.monotonic() >= nextJoinTime: + self.write_line(self.serverInPath, joinMessage) + nextJoinTime = time.monotonic() + 5 + for candidate in (channelDir, channelAltDir): + inPath = os.path.join(candidate, "in") + if os.path.exists(inPath): + self.channelInPath = inPath + return + time.sleep(0.5) + self.channelInPath = None + + def send_channel_message(self, message): + if not self.channelInPath: + self.refresh_channel_in_path() + if self.channelInPath and os.path.exists(self.channelInPath): + self.write_line(self.channelInPath, message) + else: + self.write_line(self.serverInPath, f"/msg {self.channel} {message}") + + def send_private_message(self, nick, message): + nickDir = os.path.join(self.serverDir, nick) + inPath = os.path.join(nickDir, "in") + if os.path.exists(inPath): + self.write_line(inPath, message) + else: + self.write_line(self.serverInPath, f"/msg {nick} {message}") + + def get_private_messages(self, allowedUsers): + messages = [] + for nick in allowedUsers: + nickDir = os.path.join(self.serverDir, nick) + outPath = os.path.join(nickDir, "out") + if not os.path.exists(outPath): + continue + lastPos = self.pmOffsets.get(outPath, 0) + with open(outPath, "r", encoding="utf-8", errors="ignore") as fileHandle: + fileHandle.seek(lastPos) + for line in fileHandle: + sender = parse_sender(line) + if sender and sender == self.nick: + continue + if sender and sender != nick: + continue + messageText = extract_message_text(line) + if messageText: + messages.append((nick, messageText)) + self.pmOffsets[outPath] = fileHandle.tell() + return messages + + def refresh_channel_in_path(self): + channelDir = os.path.join(self.serverDir, self.channel) + channelAltDir = os.path.join(self.serverDir, self.channel.lstrip("#")) + for candidate in (channelDir, channelAltDir): + inPath = os.path.join(candidate, "in") + if os.path.exists(inPath): + self.channelInPath = inPath + return + + def wait_for_server_dir(self): + for _ in range(120): + for rootDir in [self.baseDir] + list_subdirs(self.baseDir): + if not os.path.isdir(rootDir): + continue + for entry in os.listdir(rootDir): + path = os.path.join(rootDir, entry) + if os.path.isdir(path) and self.server in entry: + inPath = os.path.join(path, "in") + if os.path.exists(inPath): + return path + time.sleep(0.5) + raise RuntimeError("ii server directory not found") + + @staticmethod + def write_line(path, message): + with open(path, "w", encoding="utf-8", errors="ignore") as fileHandle: + fileHandle.write(message + "\n") + fileHandle.flush() + + +def ii_supports_i(): + result = run_command(["ii", "-h"]) + output = (result.stdout or "") + (result.stderr or "") + return "-i" in output + + +def list_subdirs(path): + try: + return [os.path.join(path, entry) for entry in os.listdir(path)] + except OSError: + return [] + + +def shutil_which(command): + for path in os.environ.get("PATH", "").split(os.pathsep): + candidate = os.path.join(path, command) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + +def build_nick(): + baseUser = os.environ.get("SUDO_USER") or os.environ.get("USER") or "sas" + return f"{baseUser}-{int(time.time())}" + + +def main(): + say_or_print("Checking accessibility. Is your screen reader working? (y/n)", True) + answer = input().strip().lower() + useSpeech = answer in ("n", "no") + + shouldRemoveUser = False + cleanupDone = False + tempDir = tempfile.mkdtemp(prefix="sas-ii-") + ircSession = None + sshProcess = None + + def cleanup(exitMessage=None): + nonlocal cleanupDone + if cleanupDone: + return + cleanupDone = True + nonlocal sshProcess + if exitMessage: + say_or_print(exitMessage, useSpeech) + + if sshProcess and sshProcess.poll() is None: + sshProcess.terminate() + try: + sshProcess.wait(timeout=5) + except subprocess.TimeoutExpired: + sshProcess.kill() + + if ircSession: + ircSession.stop() + + if shouldRemoveUser: + try: + run_privileged(["pkill", "-u", sasUser], useSpeech, check=False) + time.sleep(1) + result = run_privileged(["userdel", "-r", sasUser], useSpeech, check=False) + run_privileged(["rm", "-rf", f"/home/{sasUser}"], useSpeech, check=False) + if result.returncode != 0 and user_exists(sasUser): + say_or_print( + "Cleanup warning: failed to remove sas user. Please remove it manually.", + useSpeech, + ) + except Exception: + pass + + sudoKeepaliveStop.set() + if sudoKeepaliveThread: + sudoKeepaliveThread.join(timeout=2) + + try: + remove_tree(tempDir) + except Exception: + pass + + def handle_signal(signum, frame): + cleanup("Interrupted. Cleaning up.") + sys.exit(1) + + signal.signal(signal.SIGINT, handle_signal) + signal.signal(signal.SIGTERM, handle_signal) + + try: + if not user_exists(sasUser): + run_privileged( + ["useradd", "-m", "-d", f"/home/{sasUser}", "-s", "/bin/bash", "-G", "wheel", sasUser], + useSpeech, + ) + shouldRemoveUser = True + else: + say_or_print( + "User 'sas' exists. Remove and recreate it? This will delete /home/sas. (y/n)", + useSpeech, + ) + response = input().strip().lower() + if response not in ("y", "yes"): + cleanup("The sas user is unavailable. Remove it manually and try again.") + return 1 + run_privileged(["pkill", "-u", sasUser], useSpeech, check=False) + run_privileged(["userdel", "-r", sasUser], useSpeech, check=False) + run_privileged(["rm", "-rf", f"/home/{sasUser}"], useSpeech, check=False) + run_privileged( + ["useradd", "-m", "-d", f"/home/{sasUser}", "-s", "/bin/bash", "-G", "wheel", sasUser], + useSpeech, + ) + shouldRemoveUser = True + + ensure_wheel(sasUser, useSpeech) + + password = generate_password() + set_password(sasUser, password, useSpeech) + + privateKeyPath, publicKeyPath = generate_ssh_key(sasUser, useSpeech) + sasHome = get_user_home(sasUser) + knownHostsPath = os.path.join(sasHome, ".ssh", "known_hosts_sas") + run_privileged(["touch", knownHostsPath], useSpeech) + run_privileged(["chmod", "600", knownHostsPath], useSpeech) + run_privileged(["chown", f"{sasUser}:{sasUser}", knownHostsPath], useSpeech) + + nick = build_nick() + ircSession = IrcSession(ircServer, ircPort, nick, ircChannel, tempDir) + ircSession.start() + ircSession.join_channel() + + say_or_print("Waiting for assistance on IRC.", useSpeech) + startTime = time.monotonic() + nextPingTime = startTime + pingsSent = 0 + confirmedAdmin = None + + while time.monotonic() - startTime < pingIntervalSeconds * pingCount: + now = time.monotonic() + if pingsSent < pingCount and now >= nextPingTime: + ircSession.send_channel_message(f"{nick} is requesting assistance.") + pingsSent += 1 + nextPingTime = startTime + (pingsSent * pingIntervalSeconds) + + for adminNick, messageText in ircSession.get_private_messages(stormuxAdmin): + if messageText.strip().lower() in ("yes", "accept"): + confirmedAdmin = adminNick + break + if confirmedAdmin: + break + time.sleep(1) + + if not confirmedAdmin: + cleanup("No one was available to help, please try again later.") + return 1 + + ircSession.send_private_message( + confirmedAdmin, + f'password: "{password}" please send wormhole ssh invite code', + ) + + failures = 0 + while failures < maxWormholeFailures: + inviteCode = None + while inviteCode is None: + for adminNick, messageText in ircSession.get_private_messages(stormuxAdmin): + inviteCode = find_wormhole_code(messageText) + if inviteCode: + break + if inviteCode: + break + time.sleep(1) + + if not path_exists_for_user(publicKeyPath, sasUser, useSpeech): + raise RuntimeError(f"Public key missing: {publicKeyPath}") + + wormholeCommand = [ + "wormhole", + "ssh", + "accept", + "--yes", + inviteCode, + ] + result = run_as_user(sasUser, wormholeCommand, useSpeech, check=False) + if result.returncode == 0: + say_or_print("Wormhole key transfer succeeded.", useSpeech) + break + + failures += 1 + errorTextFull = (result.stderr or result.stdout or "").strip() + if errorTextFull and not useSpeech: + print("Wormhole ssh accept error:", flush=True) + print(errorTextFull, flush=True) + errorText = errorTextFull + if errorText: + errorText = " ".join(errorText.split()) + if len(errorText) > 400: + errorText = errorText[:400] + "..." + ircSession.send_private_message( + confirmedAdmin, + f"Wormhole ssh accept failed: {errorText}", + ) + ircSession.send_private_message( + confirmedAdmin, + "Wormhole ssh accept failed. Please send a new invite code.", + ) + + if failures >= maxWormholeFailures: + cleanup("Wormhole failed too many times. Exiting.") + return 1 + + say_or_print("Starting reverse SSH tunnel. Press Ctrl+C to stop.", useSpeech) + sshCommand = [ + "ssh", + "-N", + "-R", + "localhost:2232:localhost:22", + "-o", + "ExitOnForwardFailure=yes", + "-o", + "BatchMode=yes", + "-o", + "ServerAliveInterval=30", + "-o", + "ServerAliveCountMax=3", + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + f"UserKnownHostsFile={knownHostsPath}", + "-i", + privateKeyPath, + f"{sasUser}@{remoteHost}", + ] + sshCommand = ["sudo", "-u", sasUser, "-H"] + sshCommand + sshProcess = subprocess.Popen(sshCommand) + sshProcess.wait() + + except Exception as exc: + cleanup(f"Error: {exc}") + return 1 + finally: + cleanup() + + return 0 + + +def remove_tree(path): + if not os.path.exists(path): + return + for rootDir, dirNames, fileNames in os.walk(path, topdown=False): + for fileName in fileNames: + try: + os.unlink(os.path.join(rootDir, fileName)) + except OSError: + pass + for dirName in dirNames: + try: + os.rmdir(os.path.join(rootDir, dirName)) + except OSError: + pass + try: + os.rmdir(path) + except OSError: + pass + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/x86_64/airootfs/etc/udev/rules.d/99-brltty.rules b/x86_64/airootfs/etc/udev/rules.d/99-brltty.rules new file mode 100644 index 0000000..43250ca --- /dev/null +++ b/x86_64/airootfs/etc/udev/rules.d/99-brltty.rules @@ -0,0 +1 @@ +SUBSYSTEM=="tty", KERNEL=="tty[0-9]*|hvc[0-9]*|sclp_line[0-9]*|ttysclp[0-9]*|3270/tty[0-9]*", GROUP="tty", MODE="0620" diff --git a/x86_64/airootfs/usr/local/bin/install-stormux b/x86_64/airootfs/usr/local/bin/install-stormux index 60957f2..11d89ce 100755 --- a/x86_64/airootfs/usr/local/bin/install-stormux +++ b/x86_64/airootfs/usr/local/bin/install-stormux @@ -822,28 +822,38 @@ install_base_system() { log_info "Configuring pacman with Stormux repository" log_info "Configuring package manager" - # Copy pacman.conf from live system - if [[ -f /etc/pacman.conf ]]; then - mkdir -p "$mountPoint/etc" - cp /etc/pacman.conf "$mountPoint/etc/pacman.conf" + local copyItems=( + "/etc/udev/rules.d/99-brltty.rules" + "/etc/pacman.conf" + "/usr/local/bin/sas" + "/usr/share/pacman/keyrings/stormux*" + "/etc/RHVoice/dicts/English" + ) + local copyItem sourcePath destPath destDir nullglobWasSet=false + + if shopt -q nullglob; then + nullglobWasSet=true + else + shopt -s nullglob fi - # Copy Stormux Assistance Service (sas) - if [[ -f /usr/local/bin/sas ]]; then - mkdir -p "$mountPoint/usr/local/bin" - cp /usr/local/bin/sas "$mountPoint/usr/local/bin/sas" - fi + for copyItem in "${copyItems[@]}"; do + for sourcePath in $copyItem; do + if [[ -d "$sourcePath" ]]; then + destPath="$mountPoint$sourcePath" + mkdir -p "$destPath" + cp -a "$sourcePath/." "$destPath/" + elif [[ -e "$sourcePath" ]]; then + destPath="$mountPoint$sourcePath" + destDir="$(dirname "$destPath")" + mkdir -p "$destDir" + cp -a "$sourcePath" "$destPath" + fi + done + done - # Copy Stormux repo GPG key - if [[ -f /usr/share/pacman/keyrings/stormux.gpg ]]; then - mkdir -p "$mountPoint/usr/share/pacman/keyrings" - cp /usr/share/pacman/keyrings/stormux* "$mountPoint/usr/share/pacman/keyrings/" - fi - - # Copy RHVoice English dict fixes from live system if present - if [[ -d /etc/RHVoice/dicts/English ]]; then - mkdir -p "$mountPoint/etc/RHVoice/dicts/English" - cp -a /etc/RHVoice/dicts/English/. "$mountPoint/etc/RHVoice/dicts/English/" + if ! $nullglobWasSet; then + shopt -u nullglob fi # Define package groups @@ -883,23 +893,24 @@ install_base_system() { local allPackages=("${basePackages[@]}" "${audioPackages[@]}" "${accessibilityPackages[@]}" "${utilityPackages[@]}") # Add desktop-specific packages - case "$desktopEnvironment" in - i3) - allPackages+=(i3-wm orca python-psutil lxterminal pluma) - allPackages+=(discount jq libnotify xfce4-notifyd pamixer playerctl) - allPackages+=(python-i3ipc python-wxpython sox yad) - allPackages+=(lxsession magic-wormhole pcmanfm) - allPackages+=(python-gobject python-pillow python-pytesseract scrot tesseract) - allPackages+=(tesseract-data-eng udiskie xorg-setxkbmap xdotool) - # Add Stormux-specific i3 packages - stormuxPackages+=(xlibre-xserver xlibre-input-libinput nodm-dgw brave-bin) - ;; - mate) - allPackages+=(mate mate-extra orca python-psutil) - # Add Stormux-specific MATE packages - stormuxPackages+=(xlibre-xserver xlibre-input-libinput nodm-dgw brave-bin) - ;; - esac + if [[ "$hasDesktop" == true ]]; then + case "$desktopEnvironment" in + *) + allPackages+=(xlibre-xserver xlibre-input-libinput nodm-dgw brave-bin) + ;;& + i3) + allPackages+=(i3-wm orca python-psutil lxterminal pluma) + allPackages+=(discount jq libnotify xfce4-notifyd pamixer playerctl) + allPackages+=(python-i3ipc python-wxpython sox yad) + allPackages+=(lxsession magic-wormhole pcmanfm) + allPackages+=(python-gobject python-pillow python-pytesseract scrot tesseract) + allPackages+=(tesseract-data-eng udiskie xorg-setxkbmap xdotool) + ;; + mate) + allPackages+=(mate mate-extra orca python-psutil) + ;; + esac + fi # Add bootloader packages if [[ "$bootMode" == "bios" ]]; then @@ -1202,6 +1213,9 @@ export EDITOR=nano export VISUAL=nano ENV_EOF +# Suppress git default-branch warning for root-run git commands +git config --global init.defaultBranch master + # Note: realtime group is created by realtime-privileges package # Create users (commands built dynamically before chroot) diff --git a/x86_64/profiledef.sh b/x86_64/profiledef.sh index 3a3e23f..b997337 100644 --- a/x86_64/profiledef.sh +++ b/x86_64/profiledef.sh @@ -35,5 +35,6 @@ file_permissions=( ["/usr/share/fenrirscreenreader/scripts/ssh-login-monitor.sh"]="0:0:755" ["/etc/stormux-assist"]="0:0:755" ["/etc/stormux-assist/client.conf"]="0:0:644" + ["/etc/udev/rules.d/99-brltty.rules"]="0:0:644" ["/home/stormux"]="1000:1000:755" )