diff --git a/docs/superpowers/specs/2026-04-02-xlibre-updater-design.md b/docs/superpowers/specs/2026-04-02-xlibre-updater-design.md deleted file mode 100644 index 3a2ecf3..0000000 --- a/docs/superpowers/specs/2026-04-02-xlibre-updater-design.md +++ /dev/null @@ -1,57 +0,0 @@ -# XLibre Updater Design - -## Goal -Add a repository-managed workflow for updating and building the Stormux XLibre package set in the correct dependency order, while keeping `xlibre-video-dummy-with-vt` manually maintained and reviewed against upstream `xlibre-video-dummy`. - -## Scope -This design covers: -- correcting the package build order in `scripts/upgrade-xlibre.sh` -- reviewing and updating `scripts/xlibre-video-dummy-with-vt/PKGBUILD` -- regenerating `.SRCINFO` after PKGBUILD changes - -This design does not automate AUR publication or rewrite the downstream package from upstream sources. - -## Current State -The updater script currently builds packages in an order that does not match the XLibre dependency chain. `xlibre-video-dummy-with-vt` is also version-skewed relative to the current AUR `xlibre-video-dummy` package and needs a manual rebase of relevant packaging changes. - -## Dependency Order -The package build order will be: - -1. `xlibre-xserver-common` -2. `xlibre-xserver-devel` -3. `xlibre-input-libinput` -4. `xlibre-xserver` -5. `xlibre-video-fbdev` -6. `xlibre-video-dummy-with-vt` - -This order reflects current AUR dependencies: `xlibre-xserver` requires `xlibre-xserver-common` and `xlibre-input-libinput`, while the input and video driver packages require `xlibre-xserver-devel` to build. - -## Downstream Package Policy -`xlibre-video-dummy-with-vt` remains a separate, manually maintained package because it has a different maintainer and should not be auto-derived from the upstream AUR package. - -The maintenance rule is: -- treat AUR `xlibre-video-dummy` as the packaging baseline -- manually port relevant upstream PKGBUILD changes into `xlibre-video-dummy-with-vt` -- keep only the intentional downstream delta needed for VT behavior - -## Implementation Shape -`scripts/upgrade-xlibre.sh` will continue cloning packages from AUR and building them locally, but with the corrected order. - -`scripts/xlibre-video-dummy-with-vt/PKGBUILD` will be updated to match the current upstream package structure where appropriate: -- current version and release -- current `depends` and `makedepends` -- current build flags and source layout -- regenerated checksums - -The VT-specific patch remains the only behavioral divergence. - -## Error Handling -The script should fail fast on clone or build errors and stop rather than continuing with a broken package chain. Because package order is intentional, partial success should be considered incomplete. - -## Verification -Verification will be task-focused: -- confirm the updater script contains the corrected package order -- compare the downstream PKGBUILD against current upstream `xlibre-video-dummy` -- regenerate and verify `.SRCINFO` -- run `shellcheck` on the updater script -- run `makepkg --printsrcinfo` in the downstream package directory diff --git a/pi4/build/build-stormux.sh b/pi4/build/build-stormux.sh index 9963392..bc05971 100755 --- a/pi4/build/build-stormux.sh +++ b/pi4/build/build-stormux.sh @@ -24,13 +24,6 @@ mounted=1 set -e # Don't want to destroy stuff if this goes majorly wrong. trap cleanup EXIT # make sure the script cleans up after itself before closing. -# shellcheck disable=SC2329 # verify_image is invoked from cleanup, which is invoked via trap EXIT -verify_image() { - echo "Checking completed image filesystems..." - fsck.vfat -n "${loopdev}p1" - e2fsck -fn "${loopdev}p2" -} - # shellcheck disable=SC2329 # compress_image is invoked from finish_build compress_image() { local compressedImage="${imageName}.xz" @@ -50,16 +43,11 @@ compress_image() { # shellcheck disable=SC2329 # cleanup_image is invoked from cleanup and finish_build cleanup_image() { - local verifyFilesystems="${1:-false}" local cleanupStatus=0 if [[ $mounted -eq 0 ]]; then if ! umount -R /mnt; then cleanupStatus=1 - elif [[ "$verifyFilesystems" == true ]]; then - if ! verify_image; then - cleanupStatus=1 - fi fi fi @@ -85,12 +73,12 @@ cleanup() { local status=$? # capture original exit status so failures propagate local cleanupStatus=0 - if ! cleanup_image false; then + if ! cleanup_image; then cleanupStatus=1 fi if [[ $status -eq 0 && $cleanupStatus -ne 0 ]]; then - echo "Image build commands completed, but cleanup or filesystem verification failed." + echo "Image build commands completed, but cleanup failed." status=1 fi @@ -100,8 +88,8 @@ cleanup() { finish_build() { trap - EXIT - if ! cleanup_image true; then - echo "Image build commands completed, but cleanup or filesystem verification failed." + if ! cleanup_image; then + echo "Image build commands completed, but cleanup failed." exit 1 fi @@ -112,6 +100,30 @@ finish_build() { exit 0 } +# shellcheck disable=SC2329 # install_sas is emitted into the chroot heredoc with declare -f +install_sas() { + local sasRepo="https://git.stormux.org/storm/sas" + local sasPath="/usr/local/bin/sas" + local tempDir + local installStatus=0 + + tempDir="$(mktemp -d)" + + echo "Installing latest sas..." + if ! git clone --depth 1 "$sasRepo" "$tempDir"; then + rm -rf "$tempDir" + return 1 + fi + + rm -f "$sasPath" + if ! install -m 755 "$tempDir/sas.py" "$sasPath"; then + installStatus=1 + fi + + rm -rf "$tempDir" + return "$installStatus" +} + help() { echo -e "Usage:\n" echo "With no arguments, build with default parameters." @@ -196,7 +208,7 @@ for i in arch-install-scripts dosfstools parted ; do exit 1 fi done -for i in e2fsck fsck.vfat sha1sum xz ; do +for i in sha1sum xz ; do if ! command -v "$i" &> /dev/null ; then echo "Please install ${i} before continuing." exit 1 @@ -330,6 +342,7 @@ packages=( bluez-utils brltty cronie + curl dialog espeak-ng fake-hwclock @@ -370,6 +383,7 @@ packages=( rsync screen sox + speech-dispatcher w3m-git wget wireless-regdb @@ -382,6 +396,9 @@ packages=( pacman -Su --needed --noconfirm "\${packages[@]}" +$(declare -f install_sas) +install_sas + # Fix mkinitcpio preset for linux-rpi - kms hook fails on aarch64 # See: https://archlinuxarm.org/forum/viewtopic.php?t=16672 if [[ -f /etc/mkinitcpio.d/linux-rpi.preset ]]; then diff --git a/pi4/files/usr/local/bin/sas b/pi4/files/usr/local/bin/sas deleted file mode 100755 index 4b289d5..0000000 --- a/pi4/files/usr/local/bin/sas +++ /dev/null @@ -1,582 +0,0 @@ -#!/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/scripts/add-stormux.sh b/scripts/add-stormux.sh new file mode 100755 index 0000000..505d33c --- /dev/null +++ b/scripts/add-stormux.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly repoName="stormux" +readonly repoKeyUrl="https://packages.stormux.org/stormux_repo.pub" +readonly repoKeyId="52ADA49000F1FF0456F8AEEFB4CDE1CD56EF8E82" +readonly pacmanConf="${STORMUX_PACMAN_CONF:-/etc/pacman.conf}" +readonly osRelease="${STORMUX_OS_RELEASE:-/etc/os-release}" +readonly effectiveUid="${STORMUX_TEST_EUID:-${EUID}}" + +keyFile="" + +cleanup() { + if [[ -n "$keyFile" && -f "$keyFile" ]]; then + rm -f "$keyFile" + fi +} + +die() { + printf 'Error: %s\n' "$1" >&2 + exit 1 +} + +require_root() { + [[ "$effectiveUid" == "0" ]] || die "This script must be run as root." +} + +require_command() { + local commandName="$1" + + command -v "$commandName" >/dev/null 2>&1 || die "Required command not found: ${commandName}" +} + +is_arch_based_system() { + local osId="" + local osIdLike="" + + [[ -f "$osRelease" ]] || return 1 + + while IFS='=' read -r keyName keyValue; do + keyValue="${keyValue%\"}" + keyValue="${keyValue#\"}" + + case "$keyName" in + ID) + osId="$keyValue" + ;; + ID_LIKE) + osIdLike="$keyValue" + ;; + esac + done < "$osRelease" + + [[ "$osId" == "arch" || "$osId" == "archarm" || " ${osIdLike} " == *" arch "* ]] +} + +check_prerequisites() { + require_root + require_command curl + require_command pacman + require_command pacman-key + + is_arch_based_system || die "This script supports Arch-based pacman systems only." + [[ -f "$pacmanConf" ]] || die "pacman.conf not found: ${pacmanConf}" +} + +download_and_trust_key() { + keyFile="$(mktemp)" + trap cleanup EXIT + + printf 'Downloading Stormux repository key...\n' + curl -fsSL "$repoKeyUrl" > "$keyFile" + + printf 'Adding Stormux repository key to pacman keyring...\n' + pacman-key --add "$keyFile" + + printf 'Locally signing Stormux repository key...\n' + pacman-key --lsign-key "$repoKeyId" +} + +update_pacman_conf() { + local tempConf + + tempConf="$(mktemp)" + awk -v repoName="$repoName" ' + function print_repo_block() { + print "[" repoName "]" + print "SigLevel = Required DatabaseOptional" + print "Server = https://packages.stormux.org/$arch" + } + + /^\[stormux\][[:space:]]*$/ { + inStormux = 1 + next + } + + inStormux && /^\[[^]]+\][[:space:]]*$/ { + inStormux = 0 + } + + inStormux { + next + } + + !inserted && /^\[core\][[:space:]]*$/ { + print_repo_block() + print "" + inserted = 1 + } + + { + print + } + + END { + if (!inserted) { + print "" + print_repo_block() + } + } + ' "$pacmanConf" > "$tempConf" + + cat "$tempConf" > "$pacmanConf" + rm -f "$tempConf" +} + +refresh_databases() { + printf 'Refreshing package databases...\n' + pacman -Sy +} + +main() { + check_prerequisites + download_and_trust_key + update_pacman_conf + refresh_databases + printf 'Stormux repository is configured.\n' +} + +main "$@" diff --git a/scripts/check-aur-versions.sh b/scripts/check-aur-versions.sh new file mode 100755 index 0000000..7f99fbe --- /dev/null +++ b/scripts/check-aur-versions.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash + +set -euo pipefail +shopt -s nullglob + +repoDir="/var/www/packages.stormux.org" +aurRpcUrl="https://aur.archlinux.org/rpc/v5/info" +exclude=("gzdoom") + +require_cmd() { + local cmd="$1" + + if ! command -v "$cmd" >/dev/null 2>&1; then + printf 'Required command not found: %s\n' "$cmd" >&2 + exit 1 + fi +} + +is_excluded() { + local packageName="$1" + local excludedPackage + + for excludedPackage in "${exclude[@]}"; do + if [[ "$excludedPackage" == "$packageName" ]]; then + return 0 + fi + done + + return 1 +} + +record_local_package_version() { + local packageName="$1" + local packageVersion="$2" + local packageMapName="$3" + local -n packageMapRef="$packageMapName" + local existingVersion="${packageMapRef[$packageName]:-}" + + if [[ -z "$existingVersion" ]] || (( $(vercmp "$packageVersion" "$existingVersion") > 0 )); then + packageMapRef["$packageName"]="$packageVersion" + fi +} + +read_package_metadata() { + local packageFile="$1" + + pacman -Qip "$packageFile" | parse_pacman_info +} + +parse_pacman_info() { + awk ' + /^Name[[:space:]]*:/ { + packageName=$0 + sub(/^Name[[:space:]]*:[[:space:]]*/, "", packageName) + } + /^Version[[:space:]]*:/ { + packageVersion=$0 + sub(/^Version[[:space:]]*:[[:space:]]*/, "", packageVersion) + } + END { + if (packageName == "" || packageVersion == "") { + exit 1 + } + + printf "%s\t%s\n", packageName, packageVersion + } + ' +} + +collect_local_packages() { + local packageMapName="$1" + # shellcheck disable=SC2178 + local -n packageMapRef="$packageMapName" + local archDir packageFile metadata packageName packageVersion + local -a archDirs=("$repoDir/x86_64" "$repoDir/aarch64") + + for archDir in "${archDirs[@]}"; do + if [[ ! -d "$archDir" ]]; then + continue + fi + + for packageFile in "$archDir"/*.pkg.tar.zst "$archDir"/*.pkg.tar.xz; do + metadata="$(read_package_metadata "$packageFile")" || { + printf 'Unable to read package metadata: %s\n' "$packageFile" >&2 + exit 1 + } + + packageName="${metadata%%$'\t'*}" + packageVersion="${metadata#*$'\t'}" + record_local_package_version "$packageName" "$packageVersion" "$packageMapName" + done + done +} + +extract_aur_version_from_json() { + local packageName="$1" + + jq -r --arg packageName "$packageName" ' + .results[] + | select(.Name == $packageName) + | .Version + ' | head -n1 +} + +fetch_aur_version() { + local packageName="$1" + + curl -fsS --get \ + --data-urlencode "arg[]=${packageName}" \ + "$aurRpcUrl" | + extract_aur_version_from_json "$packageName" +} + +print_outdated_packages() { + local packageMapName="$1" + # shellcheck disable=SC2178 + local -n packageMapRef="$packageMapName" + local packageName localVersion aurVersion + local -a packageNames=() + + mapfile -t packageNames < <(printf '%s\n' "${!packageMapRef[@]}" | sort) + + for packageName in "${packageNames[@]}"; do + if is_excluded "$packageName"; then + continue + fi + + localVersion="${packageMapRef[$packageName]}" + aurVersion="$(fetch_aur_version "$packageName" || true)" + + if [[ -z "$aurVersion" ]]; then + continue + fi + + if (( $(vercmp "$localVersion" "$aurVersion") < 0 )); then + printf '%s %s\n' "$packageName" "$aurVersion" + fi + done +} + +assert_equals() { + local expected="$1" + local actual="$2" + local message="$3" + + if [[ "$expected" != "$actual" ]]; then + printf 'FAIL: %s\nExpected: %s\nActual: %s\n' "$message" "$expected" "$actual" >&2 + exit 1 + fi +} + +assert_success() { + local message="$1" + shift + + if ! "$@"; then + printf 'FAIL: %s\n' "$message" >&2 + exit 1 + fi +} + +assert_failure() { + local message="$1" + shift + + if "$@"; then + printf 'FAIL: %s\n' "$message" >&2 + exit 1 + fi +} + +main() { + if [[ "${1:-}" == "--self-test" ]]; then + run_self_tests + return + fi + + require_cmd "curl" + require_cmd "jq" + require_cmd "pacman" + require_cmd "vercmp" + + if [[ ! -d "$repoDir" ]]; then + printf 'Repo dir does not exist: %s\n' "$repoDir" >&2 + exit 1 + fi + + # shellcheck disable=SC2034 + declare -A localPackages=() + collect_local_packages localPackages + print_outdated_packages localPackages +} + +run_self_tests() { + declare -A packageMap=() + local exactMatchJson noMatchJson + local extractedVersion="" + local pacmanInfo="" + + assert_success "excluded package should match" is_excluded "gzdoom" + assert_failure "non-excluded package should not match" is_excluded "fenrir" + + record_local_package_version "fenrir" "1:2026.01.20-1" packageMap + record_local_package_version "fenrir" "1:2026.01.28-1" packageMap + record_local_package_version "fenrir" "1:2026.01.10-1" packageMap + assert_equals "1:2026.01.28-1" "${packageMap[fenrir]}" "newest local version should win" + + exactMatchJson='{"results":[{"Name":"fenrir-git","Version":"1:r3322.4672592d-1"},{"Name":"fenrir","Version":"1:2026.01.28-1"}]}' + extractedVersion="$(printf '%s\n' "$exactMatchJson" | extract_aur_version_from_json "fenrir")" + assert_equals "1:2026.01.28-1" "$extractedVersion" "exact package name should be selected from AUR JSON" + + noMatchJson='{"results":[{"Name":"fenrir-git","Version":"1:r3322.4672592d-1"}]}' + extractedVersion="$(printf '%s\n' "$noMatchJson" | extract_aur_version_from_json "fenrir")" + assert_equals "" "$extractedVersion" "missing exact AUR match should stay empty" + + pacmanInfo='Name : fenrir +Version : 1:2026.01.28-1 +Description : A user space console screen reader written in python3' + extractedVersion="$(printf '%s\n' "$pacmanInfo" | parse_pacman_info)" + assert_equals $'fenrir\t1:2026.01.28-1' "$extractedVersion" "pacman metadata parsing should return name and version" + + if (( $(vercmp "1:2026.01.20-1" "1:2026.01.28-1") >= 0 )); then + printf 'FAIL: older local version should compare lower than AUR version\n' >&2 + exit 1 + fi + + printf 'Self-test passed\n' +} + +main "$@" diff --git a/x86_64/airootfs/root/customize_airootfs.sh b/x86_64/airootfs/root/customize_airootfs.sh index 95eb097..69f07ab 100644 --- a/x86_64/airootfs/root/customize_airootfs.sh +++ b/x86_64/airootfs/root/customize_airootfs.sh @@ -5,6 +5,29 @@ set -e -u +install_sas() { + local sasRepo="https://git.stormux.org/storm/sas" + local sasPath="/usr/local/bin/sas" + local tempDir + local installStatus=0 + + tempDir="$(mktemp -d)" + + echo "Installing latest sas..." + if ! git clone --depth 1 "$sasRepo" "$tempDir"; then + rm -rf "$tempDir" + return 1 + fi + + rm -f "$sasPath" + if ! install -m 755 "$tempDir/sas.py" "$sasPath"; then + installStatus=1 + fi + + rm -rf "$tempDir" + return "$installStatus" +} + # Initialize pacman keyring echo "Initializing pacman keyring..." pacman-key --init @@ -25,6 +48,8 @@ fi echo "en_US.UTF-8 UTF-8" > /etc/locale.gen locale-gen +install_sas + # Enable system services systemctl enable NetworkManager.service systemctl enable fenrirscreenreader.service diff --git a/x86_64/airootfs/usr/local/bin/sas b/x86_64/airootfs/usr/local/bin/sas deleted file mode 120000 index 741a126..0000000 --- a/x86_64/airootfs/usr/local/bin/sas +++ /dev/null @@ -1 +0,0 @@ -sas.sh \ No newline at end of file diff --git a/x86_64/airootfs/usr/local/bin/sas.sh b/x86_64/airootfs/usr/local/bin/sas.sh deleted file mode 100644 index 4b289d5..0000000 --- a/x86_64/airootfs/usr/local/bin/sas.sh +++ /dev/null @@ -1,582 +0,0 @@ -#!/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/packages.x86_64 b/x86_64/packages.x86_64 index 4e6ba0b..4456c83 100644 --- a/x86_64/packages.x86_64 +++ b/x86_64/packages.x86_64 @@ -18,6 +18,7 @@ btrfs-progs clonezilla cloud-init cryptsetup +curl darkhttpd ddrescue dhcpcd diff --git a/x86_64/profiledef.sh b/x86_64/profiledef.sh index b997337..a7959eb 100644 --- a/x86_64/profiledef.sh +++ b/x86_64/profiledef.sh @@ -19,7 +19,6 @@ file_permissions=( ["/etc/shadow"]="0:0:400" ["/root"]="0:0:750" ["/root/.automated_script.sh"]="0:0:755" - ["/usr/local/bin/sas"]="0:0:755" ["/root/customize_airootfs.sh"]="0:0:755" ["/root/.gnupg"]="0:0:700" ["/root/.config"]="0:0:700" @@ -30,8 +29,6 @@ file_permissions=( ["/usr/local/bin/install-stormux"]="0:0:755" ["/usr/local/bin/stormux-setup.sh"]="0:0:755" ["/usr/local/bin/init-pipewire-sound.sh"]="0:0:755" - ["/usr/local/bin/sas.sh"]="0:0:755" - ["/usr/local/bin/sas"]="0:0:755" ["/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"