Compare commits
18 Commits
140a1c8f88
...
testing
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ec1d7727d | |||
| 426069f36a | |||
| 4aeff109f7 | |||
| 46e3f0d084 | |||
| 13e3ce64fd | |||
| f6e2e2f4c8 | |||
| c94a71e0d6 | |||
| f1dfe1737b | |||
| eb36d6d976 | |||
| 040eecca09 | |||
| 973b2573c8 | |||
| 9a0a6fef00 | |||
| 9496559875 | |||
| ddab0d827f | |||
| aacdf8eb4a | |||
| a6c65ca973 | |||
| 19fd4b1ed4 | |||
| 185d098bdd |
@@ -5,3 +5,4 @@ CLAUDE.md
|
||||
*.sha1sum
|
||||
*.xz
|
||||
*.zst
|
||||
scripts/xlibre-video-dummy-with-vt/
|
||||
|
||||
+17
-16
@@ -3,17 +3,17 @@
|
||||
Stormux provides an Arch-based Raspberry Pi image that already includes Fenrir, Orca, and other screen-reader friendly defaults. This repository hosts the scripts maintained by the Stormux community (originally crafted by Storm) so anyone can rebuild the image, customize the overlay, and share accessible Pi spins.
|
||||
|
||||
## Purpose & Scope
|
||||
This repo captures the complete build pipeline for the Stormux Raspberry Pi images: downloading Arch Linux ARM, layering the accessible defaults, compiling Fenrir and helper tools, and producing a ready-to-flash `.img`. Running the script yourself lets you reproduce the same system the project ships for the Pi 4/400, tweak it, and publish new spins without reverse-engineering the original release.
|
||||
This repo captures the complete build pipeline for the Stormux Raspberry Pi images: bootstrapping Arch Linux ARM, layering the accessible defaults, installing Fenrir and helper tools, and producing a compressed ready-to-flash image. Running the script yourself lets you reproduce the same system the project ships for Raspberry Pi 4/5-class systems, tweak it, and publish new spins without reverse-engineering the original release.
|
||||
|
||||
## What You Need
|
||||
- A 64-bit Arch Linux host (bare metal or VM) with at least 20 GB free disk space.
|
||||
- Root access on that host. The builder partitions loopback devices and must run as root.
|
||||
- Required packages: `arch-install-scripts`, `dosfstools`, `parted`, `wget`, `qemu-user-static`, `qemu-user-static-binfmt`, plus standard developer tools that Arch already ships.
|
||||
- A Raspberry Pi 4/400 or similar board (a Pi 3 works with the `-v 32` option) and a microSD card (8 GB or larger recommended).
|
||||
- Required packages: `arch-install-scripts`, `dosfstools`, `parted`, `qemu-user-static`, `qemu-user-static-binfmt`, plus standard developer tools that Arch already ships.
|
||||
- A Raspberry Pi 4, Raspberry Pi 400, Raspberry Pi 5, or compatible board, plus a microSD card (8 GB or larger recommended).
|
||||
|
||||
Install dependencies on the build host with:
|
||||
```bash
|
||||
sudo pacman -S arch-install-scripts dosfstools parted wget qemu-user-static qemu-user-static-binfmt
|
||||
sudo pacman -S arch-install-scripts dosfstools parted qemu-user-static qemu-user-static-binfmt
|
||||
```
|
||||
|
||||
## Getting the Source
|
||||
@@ -31,21 +31,21 @@ The script is designed for an x86_64 Arch host so it can emulate ARM binaries wi
|
||||
1. Become root (`sudo -i`) so the script can create loop devices and mount them.
|
||||
2. From `/path/to/stormux`, run:
|
||||
```bash
|
||||
sudo ./pi4/build/build-stormux.sh -v 64 -l en_US -s 6
|
||||
sudo ./pi4/build/build-stormux.sh -l en_US -s 6
|
||||
```
|
||||
- `-v 64` builds the aarch64 image (use `-v 32` for the Pi 3/armv7h).
|
||||
- The builder creates an aarch64 image for Raspberry Pi 4/5-class systems.
|
||||
- `-l en_US` selects the locale (use `es_ES`, `fr_FR`, etc.).
|
||||
- `-s 6` sets the image size in gigabytes.
|
||||
- Add `-n my-stormux.img` to override the default filename.
|
||||
3. Grab coffee. The script downloads the latest Arch Linux ARM tarball, provisions packages (Fenrir, Orca, NetworkManager, PipeWire, etc.), copies the overlay from `pi4/files/`, and cleans up.
|
||||
4. When the `build-stormux.sh` command returns, you should have an `.img` file in your working directory (for example `stormux-pi4-aarch64-YYYY-MM-DD.img`).
|
||||
3. Grab coffee. The script bootstraps Arch Linux ARM, provisions packages (Fenrir, Orca, NetworkManager, PipeWire, etc.), copies the overlay from `pi4/files/`, installs the latest `sas`, cleans up, compresses the image, and writes a SHA-1 checksum.
|
||||
4. When the `build-stormux.sh` command returns, you should have an `.img.xz` file and matching `.sha1sum` in your working directory (for example `stormux-rpi4-5-aarch64-YYYY-MM-DD.img.xz`).
|
||||
|
||||
If the build is interrupted, run `sudo umount -R /mnt && sudo losetup -D` to be sure nothing is still mounted.
|
||||
|
||||
## Customizing the Image
|
||||
- **Overlay files**: Add or edit files in `pi4/files/` using the same paths they should have inside the Pi. Example: to add a custom MOTD, edit `pi4/files/etc/motd`.
|
||||
- **Packages**: Edit the package list inside `pi4/build/build-stormux.sh` (search for `pacman -Su --needed`). Add or remove entries as needed, then rebuild.
|
||||
- **AUR components**: The script already compiles Fenrir (`fenrir-git`), `growpartfs`, `log2ram`, and `yay`. To add more AUR packages, append them to the `aurPackages` array near the middle of the script.
|
||||
- **Package sources**: The build does not fetch or compile packages from the AUR. Packages are installed from the official Arch Linux ARM repositories or the Stormux repository. The finished image includes `yay`, but it is installed as a prebuilt package from the Stormux repository and is not used during the build.
|
||||
- **First-boot behavior**: Modify `pi4/files/usr/local/bin/configure-stormux` to change the guided setup that runs when the image boots the first time.
|
||||
|
||||
Remember to keep permissions sensible (`chmod 755` for scripts, `644` for configs) so rsync copies them correctly.
|
||||
@@ -61,20 +61,22 @@ Rebuild after pulling so your custom image inherits any upstream fixes (new pack
|
||||
## Flashing and Booting
|
||||
1. Verify the image:
|
||||
```bash
|
||||
ls -lh stormux-pi4-*.img
|
||||
ls -lh stormux-rpi4-5-*.img.xz stormux-rpi4-5-*.img.xz.sha1sum
|
||||
sha1sum -c stormux-rpi4-5-*.img.xz.sha1sum
|
||||
```
|
||||
2. Write it to an SD card (replace `/dev/sdX` with your card, not a partition):
|
||||
```bash
|
||||
sudo dd if=stormux-pi4-aarch64-*.img of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
xzcat stormux-rpi4-5-aarch64-*.img.xz | sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
```
|
||||
3. Insert the card into your Pi 4/400 and power it on. You should hear Fenrir start speaking once the user session loads.
|
||||
3. Insert the card into your Raspberry Pi and power it on. You should hear Fenrir start speaking once the user session loads.
|
||||
4. Log in using the default credentials (username `stormux`, password `stormux`). Root uses `root`/`root` until you change it.
|
||||
5. Run `sudo configure-stormux` (or follow the automatic prompt) to finish the guided setup: configure networking via `nmtui`, update the clock, optionally resize the SD card with `growpartfs`, and toggle Fenrir’s layout.
|
||||
|
||||
## Testing Without Hardware
|
||||
You can boot the image in a container to verify services before flashing:
|
||||
```bash
|
||||
sudo systemd-nspawn -i stormux-pi4-aarch64-*.img --boot
|
||||
xz -dk stormux-rpi4-5-aarch64-*.img.xz
|
||||
sudo systemd-nspawn -i stormux-rpi4-5-aarch64-*.img --boot
|
||||
```
|
||||
Use `machinectl` to connect to the console and ensure critical services start. For unit files you edit, run `systemd-analyze verify path/to/unit` inside the container or the Pi.
|
||||
|
||||
@@ -86,9 +88,8 @@ Use `machinectl` to connect to the console and ensure critical services start. F
|
||||
|
||||
## Sharing Your Image
|
||||
When you are happy with your customizations:
|
||||
1. Rebuild so you have a clean `.img` file named clearly (for example, `stormux-pi4-a11y-v1.img`).
|
||||
2. Compress it (`xz -T0 -z stormux-pi4-a11y-v1.img`).
|
||||
3. Publish the image along with the exact commit you built from and any extra scripts you used. The README and `AGENTS.md` in this repo explain the layout so others can reproduce your work.
|
||||
1. Rebuild so you have a clean `.img.xz` file named clearly.
|
||||
2. Publish the compressed image, its `.sha1sum`, the exact commit you built from, and any extra scripts you used. The README and `AGENTS.md` in this repo explain the layout so others can reproduce your work.
|
||||
|
||||
## Huge Thanks to Storm
|
||||
Storm did the heavy lifting—curating packages, wiring up Fenrir/Orca defaults, scripting the first-boot assistant, and sharing the whole build process so the community can remix it. This repo exists because accessibility was treated as a first-class feature from day one, and the documentation lets anyone extend that work. If the image improves your Pi experience, consider contributing improvements or telling Storm thanks.
|
||||
|
||||
+123
-11
@@ -24,18 +24,40 @@ 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 # compress_image is invoked from finish_build
|
||||
compress_image() {
|
||||
local compressedImage="${imageName}.xz"
|
||||
|
||||
# shellcheck disable=SC2329 # cleanup is invoked via trap EXIT
|
||||
cleanup() {
|
||||
status=$? # capture original exit status so failures propagate
|
||||
if [[ ! -s "${imageName}" ]]; then
|
||||
echo "Image file ${imageName} was not created or is empty."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Compressing ${imageName} to ${compressedImage}..."
|
||||
xz -T0 -9 "${imageName}"
|
||||
echo "Creating SHA-1 checksum for ${compressedImage}..."
|
||||
sha1sum "${compressedImage}" > "${compressedImage}.sha1sum"
|
||||
echo "Image build complete: ${compressedImage}"
|
||||
echo "Checksum: ${compressedImage}.sha1sum"
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2329 # cleanup_image is invoked from cleanup and finish_build
|
||||
cleanup_image() {
|
||||
local cleanupStatus=0
|
||||
|
||||
if [[ $mounted -eq 0 ]]; then
|
||||
umount -R /mnt || true
|
||||
if ! umount -R /mnt; then
|
||||
cleanupStatus=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${loopdev:-}" ]]; then
|
||||
partx -d "${loopdev}" || true
|
||||
losetup --detach "${loopdev}" || true
|
||||
if ! partx -d "${loopdev}"; then
|
||||
cleanupStatus=1
|
||||
fi
|
||||
if ! losetup --detach "${loopdev}"; then
|
||||
cleanupStatus=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean up temporary pacman config directory
|
||||
@@ -43,9 +65,75 @@ cleanup() {
|
||||
rm -rf "${tmpDir}"
|
||||
fi
|
||||
|
||||
return "$cleanupStatus"
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2329 # cleanup is invoked via trap EXIT
|
||||
cleanup() {
|
||||
local status=$? # capture original exit status so failures propagate
|
||||
local cleanupStatus=0
|
||||
|
||||
if ! cleanup_image; then
|
||||
cleanupStatus=1
|
||||
fi
|
||||
|
||||
if [[ $status -eq 0 && $cleanupStatus -ne 0 ]]; then
|
||||
echo "Image build commands completed, but cleanup failed."
|
||||
status=1
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
finish_build() {
|
||||
trap - EXIT
|
||||
|
||||
if ! cleanup_image; then
|
||||
echo "Image build commands completed, but cleanup failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! compress_image; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
}
|
||||
|
||||
copy_rhvoice_english_fixes() {
|
||||
local sourceDir="../files/etc/RHVoice/dicts/English"
|
||||
local targetDir="/mnt/etc/RHVoice/dicts/English"
|
||||
|
||||
if [[ -d "$sourceDir" ]]; then
|
||||
mkdir -p "$targetDir"
|
||||
cp -a "$sourceDir/." "$targetDir/"
|
||||
fi
|
||||
}
|
||||
|
||||
# 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."
|
||||
@@ -59,7 +147,7 @@ help() {
|
||||
declare -A command=(
|
||||
[h]="This help screen."
|
||||
[l:]="Language default is en_US."
|
||||
[n:]="Image name, default is stormux-pi4-aarch64-<yyyy-mm-dd>.img"
|
||||
[n:]="Image name, default is stormux-rpi4-5-aarch64-<yyyy-mm-dd>.img"
|
||||
[s:]="image size in GB, default is 6."
|
||||
)
|
||||
|
||||
@@ -91,7 +179,7 @@ done
|
||||
|
||||
# make sure variables are set, or use defaults.
|
||||
export imageSize="${imageSize:-6G}"
|
||||
imageName="${imageName:-stormux-pi4-aarch64-$(date '+%Y-%m-%d').img}"
|
||||
imageName="${imageName:-stormux-rpi4-5-aarch64-$(date '+%Y-%m-%d').img}"
|
||||
export imageName
|
||||
export imageLanguage="${imageLanguage:-en_US.UTF-8}"
|
||||
|
||||
@@ -100,6 +188,12 @@ if [[ -e "$imageName" ]]; then
|
||||
echo "${imageName} exists, please remove or move it for this script to continue."
|
||||
exit 1
|
||||
fi
|
||||
for outputFile in "${imageName}.xz" "${imageName}.xz.sha1sum"; do
|
||||
if [[ -e "$outputFile" ]]; then
|
||||
echo "${outputFile} exists, please remove or move it for this script to continue."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Make sure this script is ran as root.
|
||||
if [ "$(whoami)" != "root" ] ; then
|
||||
@@ -124,6 +218,12 @@ for i in arch-install-scripts dosfstools parted ; do
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
for i in sha1sum xz ; do
|
||||
if ! command -v "$i" &> /dev/null ; then
|
||||
echo "Please install ${i} before continuing."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
fallocate -l "$imageSize" "$imageName"
|
||||
@@ -252,17 +352,23 @@ packages=(
|
||||
bluez-utils
|
||||
brltty
|
||||
cronie
|
||||
curl
|
||||
dialog
|
||||
espeak-ng
|
||||
fake-hwclock
|
||||
fenrir
|
||||
firmware-raspberrypi
|
||||
linux-firmware
|
||||
git
|
||||
gstreamer
|
||||
gst-plugins-base
|
||||
gst-plugins-good
|
||||
ii
|
||||
# Keep Pi onboard firmware plus common USB/network chipset firmware without
|
||||
# pulling in unrelated desktop/server GPU firmware.
|
||||
linux-firmware-atheros
|
||||
linux-firmware-broadcom
|
||||
linux-firmware-mediatek
|
||||
linux-firmware-realtek
|
||||
magic-wormhole
|
||||
man
|
||||
man-pages
|
||||
@@ -287,6 +393,7 @@ packages=(
|
||||
rsync
|
||||
screen
|
||||
sox
|
||||
speech-dispatcher
|
||||
w3m-git
|
||||
wget
|
||||
wireless-regdb
|
||||
@@ -299,6 +406,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
|
||||
@@ -381,11 +491,13 @@ sed -i '/^DisableSandbox/d' /etc/pacman.conf
|
||||
|
||||
EOF
|
||||
|
||||
copy_rhvoice_english_fixes
|
||||
|
||||
# Copy skel files to stormux user home (after user rename in chroot)
|
||||
find ../files/etc/skel/ -mindepth 1 -exec cp -rv "{}" /mnt/home/stormux/ \;
|
||||
|
||||
# Copy boot files again to ensure custom config overrides any package changes
|
||||
cp -rv ../files/boot/* /mnt/boot
|
||||
|
||||
# Exiting calls the cleanup function to unmount.
|
||||
exit 0
|
||||
# Clean up, verify, compress, and create the checksum.
|
||||
finish_build
|
||||
|
||||
@@ -7,19 +7,20 @@ if [[ -x /etc/audibleprompt.sh ]]; then
|
||||
export sudoFlags=("-A")
|
||||
fi
|
||||
|
||||
trap cleanup EXIT
|
||||
# shellcheck disable=SC2329
|
||||
cleanup() {
|
||||
popd &> /dev/null
|
||||
popd &> /dev/null || true
|
||||
if ! [[ -x /opt/configure-stormux/configure-stormux.sh ]]; then
|
||||
echo "Initial setup is not complete."
|
||||
echo "To continue setup, please run:"
|
||||
echo "sudo configure-stormux"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
if [[ -x /opt/configure-stormux/configure-stormux.sh ]]; then
|
||||
pushd /opt/configure-stormux
|
||||
pushd /opt/configure-stormux || exit 1
|
||||
./configure-stormux.sh
|
||||
exit 0
|
||||
fi
|
||||
@@ -33,25 +34,27 @@ set_timezone() {
|
||||
mapfile -t regions < <(timedatectl --no-pager list-timezones | cut -d '/' -f1 | sort -u)
|
||||
|
||||
# Use the same text twice here and just hide the tag field.
|
||||
# shellcheck disable=SC2046
|
||||
region=$(dialog --backtitle "Please select your Region" \
|
||||
--no-tags \
|
||||
--menu "Use up and down arrows or page-up and page-down to navigate the list, and press 'Enter' to make your selection." 0 0 0 \
|
||||
$(for i in ${regions[@]} ; do echo "$i";echo "$i";done) --stdout)
|
||||
$(for i in "${regions[@]}" ; do echo "$i";echo "$i";done) --stdout)
|
||||
|
||||
|
||||
mapfile -t cities < <(timedatectl --no-pager list-timezones | grep "$region" | cut -d '/' -f2 | sort -u)
|
||||
|
||||
# Use the same text twice here and just hide the tag field.
|
||||
# shellcheck disable=SC2046
|
||||
city=$(dialog --backtitle "Please select a city near you" \
|
||||
--no-tags \
|
||||
--menu "Use up and down arrow or page-up and page-down to navigate the list." 0 0 10 \
|
||||
$(for i in ${cities[@]} ; do echo "$i";echo "$i";done) --stdout)
|
||||
$(for i in "${cities[@]}" ; do echo "$i";echo "$i";done) --stdout)
|
||||
|
||||
# Set the timezone
|
||||
if [[ -f /etc/localtime ]]; then
|
||||
rm /etc/localtime
|
||||
fi
|
||||
ln -sf /usr/share/zoneinfo/${region}/${city} /etc/localtime
|
||||
ln -sf /usr/share/zoneinfo/"${region}"/"${city}" /etc/localtime
|
||||
timedatectl set-ntp true
|
||||
}
|
||||
# Offer to switch fenrir layout.
|
||||
@@ -81,6 +84,7 @@ if [[ $diskSize -le 7 ]]; then
|
||||
diskDevice="${BASH_REMATCH[1]}"
|
||||
else
|
||||
# Handle sda2, sdb3 style
|
||||
# shellcheck disable=SC2001
|
||||
diskDevice="$(echo "$diskSource" | sed 's/[0-9]*$//')"
|
||||
fi
|
||||
echo "Yes" | sudo "${sudoFlags[@]}" parted ---pretend-input-tty "$diskDevice" resizepart 2 100%
|
||||
@@ -91,9 +95,9 @@ fi
|
||||
if ! ping -c1 stormux.org &> /dev/null ; then
|
||||
echo "No internet connection detected. Press enter to open NetworkManager."
|
||||
read -r continue
|
||||
echo "setting set focus#highlight=True" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
|
||||
echo "setting set focus#highlight=True" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock
|
||||
nmtui-connect
|
||||
echo "setting set focus#highlight=False" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
|
||||
echo "setting set focus#highlight=False" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock
|
||||
fi
|
||||
# Check for internet connectivity
|
||||
if ping -qc1 -W 1 stormux.org &> /dev/null; then
|
||||
|
||||
@@ -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())
|
||||
@@ -4,7 +4,7 @@
|
||||
# Monitors SSH logins and announces them via Fenrir's speech system
|
||||
|
||||
# Configuration
|
||||
fenrirSocket="/tmp/fenrirscreenreader-deamon.sock"
|
||||
fenrirSocket="/tmp/fenrirscreenreader-daemon.sock"
|
||||
logFile="/var/log/auth.log"
|
||||
stateFile="/tmp/fenrir-ssh-monitor.state"
|
||||
checkInterval=2 # seconds between checks
|
||||
|
||||
Executable
+141
@@ -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 "$@"
|
||||
Executable
+325
@@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repoUrl="https://github.com/X11Libre/pkgbuilds-arch-based.git"
|
||||
rootfsUrl="http://os.archlinuxarm.org/os/ArchLinuxARM-aarch64-latest.tar.gz"
|
||||
|
||||
aarch64Packages=(
|
||||
xlibre-xserver
|
||||
xlibre-input-libinput
|
||||
xlibre-video-fbdev
|
||||
xlibre-video-amdgpu
|
||||
xlibre-video-ati
|
||||
xlibre-video-nouveau
|
||||
)
|
||||
|
||||
outputDir=$(pwd -P)
|
||||
workDir=""
|
||||
chrootDir=""
|
||||
binfmtMounted=false
|
||||
binfmtRegistered=false
|
||||
|
||||
log() {
|
||||
printf '%s %s\n' "$*" "$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'Error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local exitStatus=$?
|
||||
local cleanupFailed=false
|
||||
local mountTarget
|
||||
local chrootMounts=()
|
||||
|
||||
trap - EXIT INT TERM
|
||||
|
||||
if [[ -n "$workDir" && -d "$workDir" ]]; then
|
||||
log "Removing temporary chroot"
|
||||
if [[ -n "$chrootDir" ]]; then
|
||||
mapfile -t chrootMounts < <(
|
||||
findmnt -rn -o TARGET |
|
||||
awk -v root="$chrootDir" '$0 == root || index($0, root "/") == 1' |
|
||||
sort -r
|
||||
)
|
||||
fi
|
||||
for mountTarget in "${chrootMounts[@]}"; do
|
||||
if ! mountpoint -q "$mountTarget"; then
|
||||
continue
|
||||
fi
|
||||
if ! umount --recursive "$mountTarget"; then
|
||||
printf 'Warning: normal chroot unmount failed; detaching it lazily\n' >&2
|
||||
umount --recursive --lazy "$mountTarget" || cleanupFailed=true
|
||||
fi
|
||||
done
|
||||
rm -rf --one-file-system "$workDir" || cleanupFailed=true
|
||||
if [[ -e "$workDir" ]]; then
|
||||
printf 'Error: failed to remove temporary chroot: %s\n' "$workDir" >&2
|
||||
cleanupFailed=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$binfmtRegistered" == true && -e /proc/sys/fs/binfmt_misc/qemu-aarch64 ]]; then
|
||||
printf '%s' -1 > /proc/sys/fs/binfmt_misc/qemu-aarch64 || cleanupFailed=true
|
||||
fi
|
||||
|
||||
if [[ "$binfmtMounted" == true ]]; then
|
||||
umount /proc/sys/fs/binfmt_misc || cleanupFailed=true
|
||||
fi
|
||||
|
||||
if [[ "$cleanupFailed" == true && "$exitStatus" -eq 0 ]]; then
|
||||
exitStatus=1
|
||||
fi
|
||||
|
||||
exit "$exitStatus"
|
||||
}
|
||||
|
||||
require_commands() {
|
||||
local commandName
|
||||
local missingCommands=()
|
||||
|
||||
for commandName in arch-chroot awk bsdtar chown cp curl date findmnt grep install mkdir mktemp mount mountpoint rm sort tr umount; do
|
||||
if ! command -v "$commandName" >/dev/null 2>&1; then
|
||||
missingCommands+=("$commandName")
|
||||
fi
|
||||
done
|
||||
|
||||
if ((${#missingCommands[@]})); then
|
||||
die "Missing required commands: ${missingCommands[*]}"
|
||||
fi
|
||||
|
||||
[[ -x /usr/bin/qemu-aarch64-static ]] || die "/usr/bin/qemu-aarch64-static is required"
|
||||
[[ -r /usr/lib/binfmt.d/qemu-aarch64-static.conf ]] || die "The qemu-user-static-binfmt package is required"
|
||||
}
|
||||
|
||||
configure_binfmt() {
|
||||
if ! mountpoint -q /proc/sys/fs/binfmt_misc; then
|
||||
mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc
|
||||
binfmtMounted=true
|
||||
fi
|
||||
|
||||
if [[ ! -e /proc/sys/fs/binfmt_misc/qemu-aarch64 ]]; then
|
||||
tr -d '\n' < /usr/lib/binfmt.d/qemu-aarch64-static.conf > /proc/sys/fs/binfmt_misc/register
|
||||
binfmtRegistered=true
|
||||
fi
|
||||
|
||||
grep -qx 'enabled' /proc/sys/fs/binfmt_misc/qemu-aarch64 || die "aarch64 binfmt registration is not enabled"
|
||||
}
|
||||
|
||||
create_build_script() {
|
||||
local buildScript=$1
|
||||
|
||||
install -m 0755 /dev/stdin "$buildScript" <<'CHROOT_SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repoUrl=$1
|
||||
shift
|
||||
packages=("$@")
|
||||
repoDir=/build/pkgbuilds
|
||||
|
||||
log() {
|
||||
printf '%s %s\n' "$*" "$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
}
|
||||
|
||||
install_dependencies() {
|
||||
local packageDir=$1
|
||||
local dependency
|
||||
local outputPackage
|
||||
local srcInfo
|
||||
local dependencies=()
|
||||
local filteredDependencies=()
|
||||
local missingDependencies=()
|
||||
local outputPackages=()
|
||||
local -A internalPackages=()
|
||||
|
||||
# $1 is expanded by the nested builder shell, not this root shell.
|
||||
# shellcheck disable=SC2016
|
||||
srcInfo=$(runuser -u builder -- bash -c \
|
||||
'cd "$1" && makepkg --printsrcinfo' _ "$packageDir")
|
||||
|
||||
mapfile -t outputPackages < <(
|
||||
printf '%s\n' "$srcInfo" |
|
||||
awk -F ' = ' '/^[[:space:]]*pkgname = / { print $2 }'
|
||||
)
|
||||
for outputPackage in "${outputPackages[@]}"; do
|
||||
internalPackages["$outputPackage"]=1
|
||||
done
|
||||
|
||||
mapfile -t dependencies < <(
|
||||
printf '%s\n' "$srcInfo" |
|
||||
awk -F ' = ' '
|
||||
/^[[:space:]]*(depends|makedepends|checkdepends)(_aarch64)? = / {
|
||||
dependency=$2
|
||||
sub(/[<>=].*/, "", dependency)
|
||||
print dependency
|
||||
}
|
||||
' |
|
||||
sort -u
|
||||
)
|
||||
for dependency in "${dependencies[@]}"; do
|
||||
if [[ -z ${internalPackages[$dependency]+present} ]]; then
|
||||
filteredDependencies+=("$dependency")
|
||||
fi
|
||||
done
|
||||
((${#filteredDependencies[@]})) || return 0
|
||||
|
||||
mapfile -t missingDependencies < <(pacman -T "${filteredDependencies[@]}" || true)
|
||||
((${#missingDependencies[@]})) || return 0
|
||||
|
||||
log "Installing dependencies for ${packageDir##*/}"
|
||||
pacman -S --asdeps --needed --noconfirm "${missingDependencies[@]}"
|
||||
}
|
||||
|
||||
remove_installed_packages() {
|
||||
local packageName
|
||||
local installedPackages=()
|
||||
|
||||
for packageName in "$@"; do
|
||||
if pacman -Q "$packageName" >/dev/null 2>&1; then
|
||||
installedPackages+=("$packageName")
|
||||
fi
|
||||
done
|
||||
((${#installedPackages[@]})) || return 0
|
||||
|
||||
log "Removing conflicting packages: ${installedPackages[*]}"
|
||||
pacman -Rdd --noconfirm "${installedPackages[@]}"
|
||||
}
|
||||
|
||||
build_package() {
|
||||
local packageName=$1
|
||||
local installAfterBuild=${2:-false}
|
||||
local packageDir="$repoDir/$packageName"
|
||||
local builtPackages=()
|
||||
|
||||
[[ -f "$packageDir/PKGBUILD" ]] || {
|
||||
printf 'Missing PKGBUILD for %s\n' "$packageName" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
log "Building $packageName"
|
||||
install_dependencies "$packageDir"
|
||||
# $1 is expanded by the nested builder shell, not this root shell.
|
||||
# shellcheck disable=SC2016
|
||||
runuser -u builder -- bash -c \
|
||||
'cd "$1" && makepkg --noconfirm --clean --cleanbuild' _ "$packageDir"
|
||||
|
||||
mapfile -d '' builtPackages < <(
|
||||
find "$packageDir" -maxdepth 1 -type f -name '*.pkg.tar.*' ! -name '*.sig' -print0
|
||||
)
|
||||
((${#builtPackages[@]})) || {
|
||||
printf 'No package artifacts were produced for %s\n' "$packageName" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ "$installAfterBuild" == true ]]; then
|
||||
case "$packageName" in
|
||||
xlibre-input-libinput)
|
||||
remove_installed_packages xf86-input-libinput
|
||||
;;
|
||||
xlibre-xserver)
|
||||
remove_installed_packages \
|
||||
xorg-server \
|
||||
xorg-server-common \
|
||||
xorg-server-devel \
|
||||
xorg-server-xephyr \
|
||||
xorg-server-xnest \
|
||||
xorg-server-xvfb \
|
||||
glamor-egl \
|
||||
xf86-video-modesetting
|
||||
;;
|
||||
esac
|
||||
log "Installing $packageName for subsequent builds"
|
||||
pacman -U --noconfirm "${builtPackages[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
log "Initializing Arch Linux ARM"
|
||||
# Pacman's Landlock sandbox is unavailable through qemu-user emulation.
|
||||
sed -i \
|
||||
-e '/^[[:space:]]*DisableSandbox[[:space:]]*$/d' \
|
||||
-e '/^\[options\][[:space:]]*$/a DisableSandbox' \
|
||||
/etc/pacman.conf
|
||||
pacman-key --init
|
||||
pacman-key --populate archlinuxarm
|
||||
pacman -Syu --noconfirm
|
||||
pacman -S --needed --noconfirm base-devel git
|
||||
|
||||
useradd --create-home --shell /bin/bash builder
|
||||
install -d -o builder -g builder /build
|
||||
|
||||
log "Cloning XLibre PKGBUILDs"
|
||||
runuser -u builder -- git clone --depth 1 "$repoUrl" "$repoDir"
|
||||
|
||||
# The input driver must first be built against the stock xorg-server-devel.
|
||||
build_package xlibre-input-libinput true
|
||||
|
||||
# Installing all server split packages replaces the stock Xorg server/devel
|
||||
# packages and provides the ABI dependencies needed by the video drivers.
|
||||
build_package xlibre-xserver true
|
||||
|
||||
for packageName in "${packages[@]}"; do
|
||||
case "$packageName" in
|
||||
xlibre-input-libinput|xlibre-xserver)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
build_package "$packageName"
|
||||
done
|
||||
|
||||
install -d /output
|
||||
find "$repoDir" -type f -name '*.pkg.tar.*' ! -name '*.sig' -exec cp -t /output -- {} +
|
||||
log "All requested aarch64 packages built"
|
||||
CHROOT_SCRIPT
|
||||
}
|
||||
|
||||
main() {
|
||||
local rootfsArchive
|
||||
local packageFile
|
||||
local copiedPackageCount=0
|
||||
|
||||
((EUID == 0)) || die "Run this script as root: sudo $0"
|
||||
require_commands
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
configure_binfmt
|
||||
|
||||
workDir=$(mktemp -d /tmp/xlibre-aarch64.XXXXXX)
|
||||
chrootDir="$workDir/root"
|
||||
rootfsArchive="$workDir/ArchLinuxARM-aarch64-latest.tar.gz"
|
||||
mkdir -p "$chrootDir"
|
||||
|
||||
log "Downloading a fresh Arch Linux ARM aarch64 root filesystem"
|
||||
curl --fail --location --retry 3 --output "$rootfsArchive" "$rootfsUrl"
|
||||
|
||||
log "Creating aarch64 chroot"
|
||||
bsdtar -xpf "$rootfsArchive" -C "$chrootDir"
|
||||
install -m 0755 /usr/bin/qemu-aarch64-static "$chrootDir/usr/bin/qemu-aarch64-static"
|
||||
cp --remove-destination /etc/resolv.conf "$chrootDir/etc/resolv.conf"
|
||||
create_build_script "$chrootDir/root/build-xlibre"
|
||||
|
||||
# arch-chroot expects the chroot root to be a mountpoint.
|
||||
mount --bind "$chrootDir" "$chrootDir"
|
||||
|
||||
log "Starting aarch64 package build"
|
||||
arch-chroot "$chrootDir" /root/build-xlibre "$repoUrl" "${aarch64Packages[@]}"
|
||||
|
||||
shopt -s nullglob
|
||||
for packageFile in "$chrootDir"/output/*.pkg.tar.*; do
|
||||
[[ ${packageFile##*/} == *-aarch64.pkg.tar.* ]] || die "Unexpected non-aarch64 artifact: ${packageFile##*/}"
|
||||
install -m 0644 "$packageFile" "$outputDir/"
|
||||
((copiedPackageCount += 1))
|
||||
if [[ -n ${SUDO_UID:-} && -n ${SUDO_GID:-} ]]; then
|
||||
chown "$SUDO_UID:$SUDO_GID" "$outputDir/${packageFile##*/}"
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
((copiedPackageCount > 0)) || die "No package artifacts were copied to $outputDir"
|
||||
log "Copied $copiedPackageCount completed packages to $outputDir"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+230
@@ -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 "$@"
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
# ---- Configurable Variables ----
|
||||
repoDir="/var/www/packages.stormux.org"
|
||||
keyId="52ADA49000F1FF0456F8AEEFB4CDE1CD56EF8E82"
|
||||
repoName="stormux"
|
||||
dbName="${repoName}.db.tar.gz"
|
||||
filesName="${repoName}.files.tar.gz"
|
||||
rebuildDb="${REBUILD_DB:-false}"
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
echo "❌ Required command not found: $cmd"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- Safety Checks ----
|
||||
if [[ ! -d "$repoDir" ]]; then
|
||||
echo "❌ Repo dir does not exist: $repoDir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_cmd "gpg"
|
||||
require_cmd "repo-add"
|
||||
require_cmd "repo-remove"
|
||||
|
||||
# ---- Create Architecture Directories ----
|
||||
mkdir -p "$repoDir/x86_64" "$repoDir/aarch64"
|
||||
|
||||
# ---- Process Each Architecture ----
|
||||
process_arch() {
|
||||
local arch="$1"
|
||||
local archDir="$repoDir/$arch"
|
||||
local pkgFiles=()
|
||||
local selectedPkgFiles=()
|
||||
local repoAddArgs=()
|
||||
local -A currentPkgNames=()
|
||||
local -A newestPkgByName=()
|
||||
local repoPkgNames=()
|
||||
local dbFile="$dbName"
|
||||
local filesFile="$filesName"
|
||||
local dbSig="${dbFile}.sig"
|
||||
local filesSig="${filesFile}.sig"
|
||||
local dbLink="${repoName}.db"
|
||||
local filesLink="${repoName}.files"
|
||||
|
||||
echo "🏗️ Processing $arch packages..."
|
||||
|
||||
# Enter arch directory (packages should already be sorted by update.sh)
|
||||
cd "$archDir" || return 1
|
||||
|
||||
# Select only the newest archive for each package name. repo-add cannot
|
||||
# safely consume multiple versions of the same package in a single run.
|
||||
pkgFiles=( *.pkg.tar.zst *.pkg.tar.xz )
|
||||
for pkg in "${pkgFiles[@]}"; do
|
||||
local pkgName pkgVersion existingPkg existingVersion
|
||||
pkgName="$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p' | head -n1)"
|
||||
pkgVersion="$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgver = //p' | head -n1)-$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgrel = //p' | head -n1)"
|
||||
|
||||
if [[ -z "$pkgName" || -z "$pkgVersion" ]]; then
|
||||
echo "❌ Unable to determine package metadata for $pkg"
|
||||
cd "$repoDir" || exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
existingPkg="${currentPkgNames[$pkgName]:-}"
|
||||
if [[ -z "$existingPkg" ]]; then
|
||||
currentPkgNames["$pkgName"]="$pkg"
|
||||
else
|
||||
existingVersion="$(bsdtar -xOqf "$existingPkg" .PKGINFO | sed -n 's/^pkgver = //p' | head -n1)-$(bsdtar -xOqf "$existingPkg" .PKGINFO | sed -n 's/^pkgrel = //p' | head -n1)"
|
||||
if (( $(vercmp "$pkgVersion" "$existingVersion") > 0 )); then
|
||||
currentPkgNames["$pkgName"]="$pkg"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
for pkgName in "${!currentPkgNames[@]}"; do
|
||||
newestPkgByName["$pkgName"]="${currentPkgNames[$pkgName]}"
|
||||
selectedPkgFiles+=( "${currentPkgNames[$pkgName]}" )
|
||||
done
|
||||
|
||||
pkgFiles=( "${selectedPkgFiles[@]}" )
|
||||
|
||||
# Sign all unsigned selected packages
|
||||
echo "🔏 Signing $arch packages..."
|
||||
for pkg in "${pkgFiles[@]}"; do
|
||||
if [[ ! -f "$pkg.sig" ]]; then
|
||||
echo " 📝 Signing $pkg"
|
||||
gpg --default-key "$keyId" --detach-sign "$pkg"
|
||||
else
|
||||
echo " ✅ $pkg already signed"
|
||||
fi
|
||||
done
|
||||
|
||||
# Track which package names should remain in the repo after this run.
|
||||
for pkg in "${pkgFiles[@]}"; do
|
||||
local pkgName
|
||||
pkgName="$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p' | head -n1)"
|
||||
if [[ -n "$pkgName" ]]; then
|
||||
currentPkgNames["$pkgName"]=1
|
||||
else
|
||||
echo "❌ Unable to determine package name for $pkg"
|
||||
cd "$repoDir" || exit 1
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Rebuild database for this architecture
|
||||
if [[ "$rebuildDb" == "true" ]]; then
|
||||
echo "🗃️ Rebuilding $arch repo database..."
|
||||
rm -f "$dbFile" "$dbSig" "$filesFile" "$filesSig" "$dbLink" "$filesLink"
|
||||
elif [[ -f "$dbFile" ]]; then
|
||||
echo "🗃️ Updating $arch repo database..."
|
||||
else
|
||||
echo "🆕 Creating new $arch repo database..."
|
||||
fi
|
||||
|
||||
# Remove packages that still exist in the repo database but are no longer
|
||||
# present in the current directory. repo-remove with --remove also deletes
|
||||
# the matching package archive and detached signature from disk.
|
||||
if [[ -f "$dbFile" ]]; then
|
||||
mapfile -t repoPkgNames < <(
|
||||
bsdtar -tf "$dbFile" |
|
||||
awk -F/ 'NF == 2 && $2 == "desc" {print $1}' |
|
||||
while read -r entry; do
|
||||
bsdtar -xOf "$dbFile" "${entry}/desc" |
|
||||
awk 'found {print; exit} /^%NAME%$/ {found=1}'
|
||||
done |
|
||||
sort -u
|
||||
)
|
||||
|
||||
for pkgName in "${repoPkgNames[@]}"; do
|
||||
if [[ -z "${currentPkgNames[$pkgName]:-}" ]]; then
|
||||
echo "🧹 Removing stale repo package $pkgName"
|
||||
repo-remove --sign --key "$keyId" --remove "$dbFile" "$pkgName"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Only run repo-add if there are packages
|
||||
if ((${#pkgFiles[@]} > 0)); then
|
||||
repoAddArgs=(--sign --key "$keyId" --remove)
|
||||
if [[ "$rebuildDb" != "true" && -f "$dbFile" ]]; then
|
||||
repoAddArgs+=(--verify)
|
||||
fi
|
||||
|
||||
repo-add "${repoAddArgs[@]}" "$dbFile" "${pkgFiles[@]}"
|
||||
|
||||
if [[ ! -f "$dbFile" ]]; then
|
||||
echo "❌ repo-add did not create $dbFile"
|
||||
cd "$repoDir" || exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -e "$dbLink" ]]; then
|
||||
ln -s "$dbFile" "$dbLink"
|
||||
fi
|
||||
|
||||
if [[ -f "$filesFile" && ! -e "$filesLink" ]]; then
|
||||
ln -s "$filesFile" "$filesLink"
|
||||
fi
|
||||
|
||||
echo "✅ $arch repo updated successfully"
|
||||
else
|
||||
echo "ℹ️ No $arch packages found"
|
||||
fi
|
||||
|
||||
# Remove orphaned package archives and detached signatures that are not part
|
||||
# of the current package set. This keeps the on-disk repo contents aligned
|
||||
# with the package database after rebuilds and package removals.
|
||||
for pkg in *.pkg.tar.zst *.pkg.tar.xz; do
|
||||
local pkgName
|
||||
pkgName="$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p' | head -n1)"
|
||||
if [[ -z "${newestPkgByName[$pkgName]:-}" || "${newestPkgByName[$pkgName]}" != "$pkg" ]]; then
|
||||
echo "🧹 Removing orphaned package file $pkg"
|
||||
rm -f "$pkg" "$pkg.sig"
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$repoDir" || exit
|
||||
}
|
||||
|
||||
# ---- Process Both Architectures ----
|
||||
process_arch "x86_64"
|
||||
process_arch "aarch64"
|
||||
|
||||
# ---- Done ----
|
||||
echo "✅ All repositories updated and signed successfully."
|
||||
@@ -0,0 +1,23 @@
|
||||
pkgbase = xlibre-video-dummy-with-vt
|
||||
pkgdesc = XLibre dummy video driver with an allocated vt
|
||||
pkgver = 25.0.0
|
||||
pkgrel = 5
|
||||
url = https://github.com/X11Libre/xf86-video-dummy
|
||||
arch = x86_64
|
||||
arch = aarch64
|
||||
groups = xlibre-drivers
|
||||
license = MIT
|
||||
license = X11
|
||||
makedepends = xlibre-xserver-devel>=25.0
|
||||
makedepends = xorgproto
|
||||
depends = xlibre-xserver>=25.0
|
||||
depends = glibc
|
||||
provides = xf86-video-dummy
|
||||
provides = x11win-video-dummy
|
||||
conflicts = xf86-video-dummy
|
||||
source = https://github.com/X11Libre/xf86-video-dummy/archive/refs/tags/xlibre-xf86-video-dummy-25.0.0.tar.gz
|
||||
source = dummy_driver.patch
|
||||
sha256sums = b56e610705cd3d4d86422a11c6b0d93357e4d4749a05178a85fd250301d357b9
|
||||
sha256sums = 68cdcf21e9b54a7fdb8e968292e1ef9ad154ddb1361b141a0a635c2a13c92bfa
|
||||
|
||||
pkgname = xlibre-video-dummy-with-vt
|
||||
@@ -0,0 +1,5 @@
|
||||
*
|
||||
!PKGBUILD
|
||||
!.SRCINFO
|
||||
!.gitignore
|
||||
!.nvchecker.toml
|
||||
@@ -0,0 +1,5 @@
|
||||
[xlibre-video-dummy]
|
||||
source = "git"
|
||||
git = "https://github.com/x11libre/xf86-video-dummy.git"
|
||||
include_regex = "xlibre-xf86-video-dummy-.*"
|
||||
prefix = "xlibre-xf86-video-dummy-"
|
||||
@@ -0,0 +1,59 @@
|
||||
# Maintainer: Storm Dragon <storm_dragon@linux-a11y.org>
|
||||
|
||||
pkgname=xlibre-video-dummy-with-vt
|
||||
pkgver=25.0.0
|
||||
pkgrel=5
|
||||
pkgdesc="XLibre dummy video driver with an allocated vt"
|
||||
arch=(x86_64 aarch64)
|
||||
_pkgname=xf86-video-dummy
|
||||
url="https://github.com/X11Libre/${_pkgname}"
|
||||
license=('MIT' 'X11')
|
||||
depends=("xlibre-xserver>=${pkgver%.*}" 'glibc')
|
||||
makedepends=("xlibre-xserver-devel>=${pkgver%.*}" 'xorgproto')
|
||||
conflicts=("${_pkgname}")
|
||||
provides=("${_pkgname}" 'x11win-video-dummy')
|
||||
source=("${url}/archive/refs/tags/xlibre-${_pkgname}-${pkgver}.tar.gz"
|
||||
"dummy_driver.patch")
|
||||
groups=('xlibre-drivers')
|
||||
sha256sums=('b56e610705cd3d4d86422a11c6b0d93357e4d4749a05178a85fd250301d357b9'
|
||||
'68cdcf21e9b54a7fdb8e968292e1ef9ad154ddb1361b141a0a635c2a13c92bfa')
|
||||
|
||||
prepare() {
|
||||
cd "${srcdir}/${_pkgname}-xlibre-${_pkgname}-${pkgver}/src"
|
||||
patch -i "${srcdir}/dummy_driver.patch"
|
||||
}
|
||||
|
||||
build() {
|
||||
case "$CARCH" in
|
||||
"x86_64")
|
||||
CFLAGS=" -march=x86-64"
|
||||
;;
|
||||
"aarch64")
|
||||
CFLAGS=" -march=armv8-a"
|
||||
;;
|
||||
*)
|
||||
CFLAGS=" -march=native"
|
||||
;;
|
||||
esac
|
||||
CFLAGS+=" -mtune=generic -O2 -pipe -fexceptions -Wp,-D_FORTIFY_SOURCE=3 -Wformat -Werror=format-security"
|
||||
CFLAGS+=" -fstack-clash-protection -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer"
|
||||
LDFLAGS=" -Wl,-O1 -Wl,--sort-common -Wl,--as-needed -Wl,-z,lazy -Wl,-z,relro -Wl,-z,pack-relative-relocs"
|
||||
if [[ $CARCH != 'aarch64' ]]; then
|
||||
CFLAGS+=" -fcf-protection"
|
||||
fi
|
||||
CXXFLAGS="${CFLAGS} -Wp,-D_GLIBCXX_ASSERTIONS"
|
||||
export CFLAGS="${CFLAGS}"
|
||||
export CXXFLAGS="${CXXFLAGS}"
|
||||
export LDFLAGS="${LDFLAGS}"
|
||||
|
||||
cd "${srcdir}/${_pkgname}-xlibre-${_pkgname}-${pkgver}"
|
||||
./autogen.sh
|
||||
./configure --prefix=/usr
|
||||
make
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${_pkgname}-xlibre-${_pkgname}-${pkgver}"
|
||||
make DESTDIR="${pkgdir}" install
|
||||
install -Dm644 COPYING "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
--- a/src/dummy_driver.c
|
||||
+++ b/src/dummy_driver.c
|
||||
@@ -1016,10 +1016,10 @@ dummyDriverFunc(ScrnInfoPtr pScrn, xorgDriverFuncOp op, pointer ptr)
|
||||
CARD32 *flag;
|
||||
|
||||
switch (op) {
|
||||
- case GET_REQUIRED_HW_INTERFACES:
|
||||
- flag = (CARD32*)ptr;
|
||||
- (*flag) = HW_SKIP_CONSOLE;
|
||||
- return TRUE;
|
||||
+ case GET_REQUIRED_HW_INTERFACES:
|
||||
+ flag = (CARD32*)ptr;
|
||||
+ /* Allow the driver to allocate a VT instead of skipping the console. */
|
||||
+ return TRUE;
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
# Stormux x86_64 ISO Build
|
||||
|
||||
This directory contains the archiso profile for building a Stormux x86_64 live/install ISO image.
|
||||
|
||||
## Overview
|
||||
|
||||
The x86_64 build uses archiso (Arch Linux ISO build system) and is configured for accessibility with the Fenrir screen reader. The `airootfs/` tree is maintained as the x86_64 live-system overlay; shared behavior may mirror the Pi image, but files are not copied from `pi4/files/` during the build.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
x86_64/
|
||||
├── build.sh # Main build script
|
||||
├── profiledef.sh # Archiso profile definition
|
||||
├── pacman.conf # Pacman config with Stormux repository
|
||||
├── packages.x86_64 # Package list for installation
|
||||
├── grub/ # UEFI boot configuration
|
||||
│ ├── grub.cfg
|
||||
│ └── loopback.cfg
|
||||
├── syslinux/ # BIOS boot configuration
|
||||
│ ├── archiso_head.cfg
|
||||
│ ├── archiso_pxe.cfg
|
||||
│ ├── archiso_pxe-linux.cfg
|
||||
│ ├── archiso_sys.cfg
|
||||
│ ├── archiso_sys-linux.cfg
|
||||
│ ├── archiso_tail.cfg
|
||||
│ ├── splash.png
|
||||
│ └── syslinux.cfg
|
||||
└── airootfs/ # Live system overlay files
|
||||
├── etc/ # Live-system configuration
|
||||
├── root/ # x86_64-specific scripts
|
||||
├── usr/ # Live-system utilities and installer
|
||||
└── var/ # Live-system variable data
|
||||
```
|
||||
|
||||
## Overlay Maintenance
|
||||
|
||||
Edit files directly under `airootfs/` using the final filesystem path they should have in the live ISO. For example, live-system scripts belong under `airootfs/usr/local/bin/`, and systemd units belong under `airootfs/etc/systemd/system/`.
|
||||
|
||||
Some files intentionally match the Pi overlay, such as shell defaults, Fenrir support scripts, and shared Stormux helper behavior. Keep both trees in sync when a change is meant to affect both image families.
|
||||
|
||||
Pi hardware files remain Pi-only and should not be added to the x86_64 profile unless there is a specific PC use for them:
|
||||
|
||||
- `boot/cmdline.txt` - Pi boot command line
|
||||
- `boot/config.txt` - Pi hardware configuration
|
||||
- `etc/modprobe.d/brcmfmac.conf` - Pi wireless driver config
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Building requires an Arch Linux host system with:
|
||||
|
||||
- `archiso` package installed
|
||||
- Root privileges
|
||||
- Internet connection for package downloads and build-time helper installs
|
||||
|
||||
## Building the ISO
|
||||
|
||||
```bash
|
||||
cd x86_64
|
||||
sudo ./build.sh
|
||||
```
|
||||
|
||||
### Build Options
|
||||
|
||||
- `-o <dir>` - Output directory (default: `./out`)
|
||||
- `-w <dir>` - Work directory (default: `./work`)
|
||||
- `-h` - Show help
|
||||
|
||||
### Build Process
|
||||
|
||||
1. Uses the maintained `airootfs/` overlay as-is
|
||||
2. Adds Stormux repository GPG key to the build host's keyring
|
||||
- Uses the included `stormux_repo.pub` file
|
||||
- Key fingerprint: 52ADA49000F1FF0456F8AEEFB4CDE1CD56EF8E82
|
||||
3. Runs `mkarchiso` to build the ISO
|
||||
- Packages from Stormux repo can be installed during build
|
||||
- The latest `sas` helper is installed into the live environment during image creation
|
||||
4. Renames the ISO to `stormux-x86_64-YYYY-MM-DD.iso` when possible and writes a matching `.sha1sum`
|
||||
|
||||
The Stormux repository key is also embedded in the ISO at `/usr/share/stormux/stormux_repo.pub` and automatically imported on first boot via the `stormux-repo-init.service`, ensuring the live environment can install additional packages from the Stormux repository.
|
||||
|
||||
## Key Features
|
||||
|
||||
### Accessibility
|
||||
|
||||
- **Fenrir screen reader** starts automatically (not speakup)
|
||||
- **Pipewire audio** properly initialized before Fenrir starts
|
||||
- **Speech-dispatcher** integration for speech synthesis
|
||||
- GRUB plays an audible tune on boot for accessibility
|
||||
- Boot menu defaults to accessible entry
|
||||
- Service startup order ensures audio is ready before screen reader
|
||||
- First-login live-environment setup calibrates volume, checks networking and time, then offers to run the installer
|
||||
|
||||
### Package Management
|
||||
|
||||
- Stormux repository configured with priority over Arch repos
|
||||
- Custom packages from Stormux repo: fenrir, w3m-git, yay, etc.
|
||||
|
||||
### Default Configuration
|
||||
|
||||
- Default user: `stormux` / Password: `stormux`
|
||||
- Root password: `root`
|
||||
- NetworkManager for network configuration
|
||||
- Braille terminal support (brltty)
|
||||
- Multiple speech synthesizers (espeak-ng, rhvoice)
|
||||
|
||||
## Audio and Speech Initialization
|
||||
|
||||
The live environment uses a carefully orchestrated startup sequence to ensure Fenrir has working audio:
|
||||
|
||||
1. **stormux-audio-setup.service** - Runs after sound hardware is detected
|
||||
- Enables systemd user linger for the stormux user
|
||||
- Starts pipewire user services
|
||||
- Unmutes audio and sets volume to 70%
|
||||
|
||||
2. **stormux-speech.service** - Runs after audio setup
|
||||
- Waits 2 seconds for pipewire to fully initialize
|
||||
- Starts fenrirscreenreader.service
|
||||
|
||||
3. **fenrirscreenreader.service** - Screen reader with dependencies
|
||||
- Configured to wait for pipewire, speech-dispatcher, and sound.target
|
||||
- Uses speech-dispatcher for TTS output
|
||||
|
||||
This ensures Fenrir never starts without working audio, preventing system freezes or silent boot.
|
||||
|
||||
## Live Setup and Installer Flow
|
||||
|
||||
On first login to tty1, the live environment runs a short setup script. It calibrates speech volume, checks for network access, updates the live environment clock, copies the detected timezone into the installer defaults, and then asks whether to run `install-stormux`.
|
||||
|
||||
The installer gives one last chance to accept or change the timezone before installation. The live setup changes are for the temporary live environment only; the installed system is configured by `install-stormux`.
|
||||
|
||||
## Differences from Pi4 Build
|
||||
|
||||
1. **No ARM-specific packages** - Uses x86_64 standard Linux kernel
|
||||
2. **Fenrir instead of speakup** - More feature-rich screen reader
|
||||
3. **UEFI and BIOS support** - Boots on both modern and legacy systems
|
||||
4. **ISO format** - Live/install medium instead of disk image
|
||||
5. **No Pi hardware configs** - Standard x86_64 PC configuration
|
||||
6. **Different audio startup** - Pipewire user services instead of system-wide
|
||||
|
||||
## Testing
|
||||
|
||||
After building, test the ISO with:
|
||||
|
||||
- QEMU/KVM virtual machine
|
||||
- VirtualBox
|
||||
- Physical hardware (USB/CD)
|
||||
|
||||
For VM testing with audio:
|
||||
```bash
|
||||
./qemu-boot.sh
|
||||
```
|
||||
|
||||
After installing to the test disk, boot the installed system with:
|
||||
```bash
|
||||
./qemu-boot.sh -i
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
### Adding Packages
|
||||
|
||||
Edit `packages.x86_64` and add package names (one per line).
|
||||
|
||||
### Modifying Boot Configuration
|
||||
|
||||
- BIOS: Edit `syslinux/archiso_sys-linux.cfg`
|
||||
- UEFI: Edit `grub/grub.cfg`
|
||||
|
||||
### Adding Overlay Files
|
||||
|
||||
Place files in `airootfs/` following the target filesystem structure.
|
||||
The x86_64 build uses this overlay directly.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build fails with GPG errors
|
||||
|
||||
The build script should automatically add the Stormux repository key. If it fails:
|
||||
|
||||
1. Check that `x86_64/stormux_repo.pub` exists
|
||||
2. Manually add the key to your build host:
|
||||
```bash
|
||||
sudo pacman-key --add x86_64/stormux_repo.pub
|
||||
sudo pacman-key --lsign-key 52ADA49000F1FF0456F8AEEFB4CDE1CD56EF8E82
|
||||
```
|
||||
|
||||
### Missing packages
|
||||
|
||||
Ensure the Stormux repository is accessible:
|
||||
```bash
|
||||
curl -I https://packages.stormux.org/x86_64/
|
||||
```
|
||||
|
||||
### Disk space issues
|
||||
|
||||
The build requires significant space:
|
||||
- Work directory: ~3-4 GB
|
||||
- Output ISO: ~1-2 GB
|
||||
|
||||
Ensure adequate free space in work and output directories.
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,7 @@ set_timezone() {
|
||||
mapfile -t regions < <(timedatectl --no-pager list-timezones | cut -d '/' -f1 | sort -u)
|
||||
|
||||
# Use the same text twice here and just hide the tag field.
|
||||
# shellcheck disable=SC2046
|
||||
region=$(dialog --backtitle "Please select your Region" \
|
||||
--no-tags \
|
||||
--menu "Use up and down arrows or page-up and page-down to navigate the list, and press 'Enter' to make your selection." 0 0 0 \
|
||||
@@ -23,6 +24,7 @@ set_timezone() {
|
||||
mapfile -t cities < <(timedatectl --no-pager list-timezones | grep "$region" | cut -d '/' -f2 | sort -u)
|
||||
|
||||
# Use the same text twice here and just hide the tag field.
|
||||
# shellcheck disable=SC2046
|
||||
city=$(dialog --backtitle "Please select a city near you" \
|
||||
--no-tags \
|
||||
--menu "Use up and down arrow or page-up and page-down to navigate the list." 0 0 10 \
|
||||
@@ -49,9 +51,9 @@ fi
|
||||
if ! ping -c1 stormux.org &> /dev/null ; then
|
||||
echo "No internet connection detected. Press enter to open NetworkManager."
|
||||
read -r continue
|
||||
echo "setting set focus#highlight=True" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
|
||||
echo "setting set focus#highlight=True" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock
|
||||
nmtui-connect
|
||||
echo "setting set focus#highlight=False" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
|
||||
echo "setting set focus#highlight=False" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock
|
||||
fi
|
||||
# Check for internet connectivity
|
||||
if ping -qc1 -W 1 stormux.org &> /dev/null; then
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
sas.sh
|
||||
@@ -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())
|
||||
@@ -4,7 +4,7 @@
|
||||
# Monitors SSH logins and announces them via Fenrir's speech system
|
||||
|
||||
# Configuration
|
||||
fenrirSocket="/tmp/fenrirscreenreader-deamon.sock"
|
||||
fenrirSocket="/tmp/fenrirscreenreader-daemon.sock"
|
||||
logFile="/var/log/auth.log"
|
||||
stateFile="/tmp/fenrir-ssh-monitor.state"
|
||||
checkInterval=2 # seconds between checks
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fix live ISO services for x86_64
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== Fixing Live ISO Services for x86_64 ==="
|
||||
echo
|
||||
|
||||
# 1. Remove Pi-specific first boot script
|
||||
if [ -f airootfs/etc/profile.d/stormux_first_boot.sh ]; then
|
||||
echo "Removing Pi-specific first boot script..."
|
||||
sudo rm airootfs/etc/profile.d/stormux_first_boot.sh
|
||||
fi
|
||||
|
||||
# 2. Mask systemd-firstboot (blocks boot with interactive prompts)
|
||||
echo "Masking systemd-firstboot.service..."
|
||||
sudo ln -sf /dev/null airootfs/etc/systemd/system/systemd-firstboot.service
|
||||
|
||||
# 3. Install Fenrir audio configurations
|
||||
echo "Installing Fenrir audio configurations..."
|
||||
sudo ./setup-fenrir-audio-configs.sh
|
||||
|
||||
# 4. Check/verify enabled services
|
||||
echo
|
||||
echo "=== Enabled Services ==="
|
||||
ls -la airootfs/etc/systemd/system/multi-user.target.wants/ | grep -E "fenrir|speech|audio" || echo "No speech/audio services in multi-user.target.wants"
|
||||
ls -la airootfs/etc/systemd/system/sound.target.wants/ | grep -E "audio|sound" || echo "No services in sound.target.wants"
|
||||
|
||||
echo
|
||||
echo "=== Configuration Complete ==="
|
||||
echo "Next step: sudo ./build.sh -v"
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mask systemd-firstboot for live ISO (it blocks boot)
|
||||
|
||||
cd airootfs/etc/systemd/system
|
||||
|
||||
# Create mask symlink
|
||||
ln -sf /dev/null systemd-firstboot.service
|
||||
|
||||
echo "Masked systemd-firstboot.service"
|
||||
ls -la systemd-firstboot.service
|
||||
@@ -18,6 +18,7 @@ btrfs-progs
|
||||
clonezilla
|
||||
cloud-init
|
||||
cryptsetup
|
||||
curl
|
||||
darkhttpd
|
||||
ddrescue
|
||||
dhcpcd
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -21,6 +21,29 @@
|
||||
|
||||
set -e
|
||||
|
||||
checkDependencies() {
|
||||
local missingCommands=()
|
||||
local command
|
||||
|
||||
for command in qemu-system-x86_64 qemu-img date dirname find sort head cut mkdir; do
|
||||
if ! command -v "$command" &> /dev/null; then
|
||||
missingCommands+=("$command")
|
||||
fi
|
||||
done
|
||||
|
||||
if (( ${#missingCommands[@]} > 0 )); then
|
||||
echo "Error: Missing required commands:"
|
||||
printf ' %s\n' "${missingCommands[@]}"
|
||||
echo
|
||||
echo "On Arch Linux, install the required packages with:"
|
||||
echo " sudo pacman -S qemu-system-x86 qemu-img qemu-ui-gtk qemu-audio-pa coreutils findutils"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# make sure we are in a GUI
|
||||
DISPLAY="${DISPLAY:-:0}"
|
||||
|
||||
# Parse command line arguments
|
||||
bootInstalled=false
|
||||
while getopts "ih" opt; do
|
||||
@@ -44,6 +67,8 @@ while getopts "ih" opt; do
|
||||
esac
|
||||
done
|
||||
|
||||
checkDependencies
|
||||
|
||||
# Get the directory where this script is located
|
||||
scriptDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
outDir="${scriptDir}/out"
|
||||
|
||||
Reference in New Issue
Block a user