Compare commits

9 Commits

Author SHA1 Message Date
Storm Dragon d67ca0f3e0 Ship I38 in Pi images by default
Add the I38 desktop stack to the Raspberry Pi image build, including Cthulhu, XLibre, nodm, xorg-xinit, and I38 runtime Python dependencies.

Generate the default stormux user's I38 configuration during image creation with the non-interactive I38 mode, configure nodm for that generated xinitrc, and set Speech Dispatcher to use RHVoice by default.

Harden the Pi build for larger GUI images by raising the default image size to 8 GB, skipping mkinitcpio autodetect for the target image initramfs, and validating the root filesystem before compression.

Rework first boot so volume calibration runs before Fenrir starts, temporary tty1 autologin and passwordless sudo are removed during setup, I38/nodm startup is selected explicitly, and physical versus headless X configuration is written before nodm starts.

Document the Pi GUI rollout in gui.md and refresh README size and desktop references.
2026-06-29 15:59:33 -04:00
Storm Dragon 5ec1d7727d Steam now offered in x86_64 installer. Fix up the rhvoice dictionary copy. A few minor cleanups. Rename pi images to more clearly show they support both Raspberry Pi 4 and 5. 2026-06-23 19:40:06 -04:00
Storm Dragon 426069f36a Xlibre build script updated. 2026-06-21 16:21:37 -04:00
Storm Dragon 4aeff109f7 Latest fixes for x86_64 installer. Updated README for Pi images. 2026-06-21 12:56:38 -04:00
Storm Dragon 46e3f0d084 Make sure GUI DISPLAY is set. 2026-06-06 14:22:07 -04:00
Storm Dragon 13e3ce64fd Fix bug in select loop so multiple options do not appear on the same line. 2026-06-05 13:16:48 -04:00
Storm Dragon f6e2e2f4c8 added sas to both images. A few minor updates to image generation for pi images. 2026-06-04 23:52:44 -04:00
Storm Dragon c94a71e0d6 Missed some deamon to daemon updates. This shoulf make Fenrir work with the installer automatically again, switching modes, etc. 2026-06-03 00:34:19 -04:00
Storm Dragon f1dfe1737b qemu-boot.sh now checks for required packages. 2026-06-01 19:37:53 -04:00
26 changed files with 1339 additions and 1449 deletions
+1
View File
@@ -5,3 +5,4 @@ CLAUDE.md
*.sha1sum
*.xz
*.zst
scripts/xlibre-video-dummy-with-vt/
@@ -1,57 +0,0 @@
# XLibre Updater Design
## Goal
Add a repository-managed workflow for updating and building the Stormux XLibre package set in the correct dependency order, while keeping `xlibre-video-dummy-with-vt` manually maintained and reviewed against upstream `xlibre-video-dummy`.
## Scope
This design covers:
- correcting the package build order in `scripts/upgrade-xlibre.sh`
- reviewing and updating `scripts/xlibre-video-dummy-with-vt/PKGBUILD`
- regenerating `.SRCINFO` after PKGBUILD changes
This design does not automate AUR publication or rewrite the downstream package from upstream sources.
## Current State
The updater script currently builds packages in an order that does not match the XLibre dependency chain. `xlibre-video-dummy-with-vt` is also version-skewed relative to the current AUR `xlibre-video-dummy` package and needs a manual rebase of relevant packaging changes.
## Dependency Order
The package build order will be:
1. `xlibre-xserver-common`
2. `xlibre-xserver-devel`
3. `xlibre-input-libinput`
4. `xlibre-xserver`
5. `xlibre-video-fbdev`
6. `xlibre-video-dummy-with-vt`
This order reflects current AUR dependencies: `xlibre-xserver` requires `xlibre-xserver-common` and `xlibre-input-libinput`, while the input and video driver packages require `xlibre-xserver-devel` to build.
## Downstream Package Policy
`xlibre-video-dummy-with-vt` remains a separate, manually maintained package because it has a different maintainer and should not be auto-derived from the upstream AUR package.
The maintenance rule is:
- treat AUR `xlibre-video-dummy` as the packaging baseline
- manually port relevant upstream PKGBUILD changes into `xlibre-video-dummy-with-vt`
- keep only the intentional downstream delta needed for VT behavior
## Implementation Shape
`scripts/upgrade-xlibre.sh` will continue cloning packages from AUR and building them locally, but with the corrected order.
`scripts/xlibre-video-dummy-with-vt/PKGBUILD` will be updated to match the current upstream package structure where appropriate:
- current version and release
- current `depends` and `makedepends`
- current build flags and source layout
- regenerated checksums
The VT-specific patch remains the only behavioral divergence.
## Error Handling
The script should fail fast on clone or build errors and stop rather than continuing with a broken package chain. Because package order is intentional, partial success should be considered incomplete.
## Verification
Verification will be task-focused:
- confirm the updater script contains the corrected package order
- compare the downstream PKGBUILD against current upstream `xlibre-video-dummy`
- regenerate and verify `.SRCINFO`
- run `shellcheck` on the updater script
- run `makepkg --printsrcinfo` in the downstream package directory
+19 -18
View File
@@ -1,19 +1,19 @@
# Stormux Pi Image Builder
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.
Stormux provides an Arch-based Raspberry Pi image that already includes Fenrir, Cthulhu, I38, 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 20GB 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 (8GB 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 (16 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 8
```
- `-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.
- `-s 8` 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, Cthulhu, I38, XLibre, 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 Fenrirs 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.
+113 -26
View File
@@ -24,13 +24,6 @@ mounted=1
set -e # Don't want to destroy stuff if this goes majorly wrong.
trap cleanup EXIT # make sure the script cleans up after itself before closing.
# shellcheck disable=SC2329 # verify_image is invoked from cleanup, which is invoked via trap EXIT
verify_image() {
echo "Checking completed image filesystems..."
fsck.vfat -n "${loopdev}p1"
e2fsck -fn "${loopdev}p2"
}
# shellcheck disable=SC2329 # compress_image is invoked from finish_build
compress_image() {
local compressedImage="${imageName}.xz"
@@ -50,16 +43,11 @@ compress_image() {
# shellcheck disable=SC2329 # cleanup_image is invoked from cleanup and finish_build
cleanup_image() {
local verifyFilesystems="${1:-false}"
local cleanupStatus=0
if [[ $mounted -eq 0 ]]; then
if ! umount -R /mnt; then
cleanupStatus=1
elif [[ "$verifyFilesystems" == true ]]; then
if ! verify_image; then
cleanupStatus=1
fi
fi
fi
@@ -80,17 +68,25 @@ cleanup_image() {
return "$cleanupStatus"
}
# shellcheck disable=SC2329 # validate_image_filesystems is invoked from finish_build
validate_image_filesystems() {
local rootPartition="${loopdev}p2"
echo "Checking root filesystem..."
e2fsck -f -n "$rootPartition"
}
# 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 false; then
if ! cleanup_image; then
cleanupStatus=1
fi
if [[ $status -eq 0 && $cleanupStatus -ne 0 ]]; then
echo "Image build commands completed, but cleanup or filesystem verification failed."
echo "Image build commands completed, but cleanup failed."
status=1
fi
@@ -98,10 +94,23 @@ cleanup() {
}
finish_build() {
if [[ $mounted -eq 0 ]]; then
if ! umount -R /mnt; then
echo "Image build commands completed, but unmounting failed."
exit 1
fi
mounted=1
fi
if ! validate_image_filesystems; then
echo "Image filesystem validation failed."
exit 1
fi
trap - EXIT
if ! cleanup_image true; then
echo "Image build commands completed, but cleanup or filesystem verification failed."
if ! cleanup_image; then
echo "Image build commands completed, but cleanup failed."
exit 1
fi
@@ -112,6 +121,40 @@ finish_build() {
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."
@@ -125,8 +168,8 @@ 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"
[s:]="image size in GB, default is 6."
[n:]="Image name, default is stormux-rpi4-5-aarch64-<yyyy-mm-dd>.img"
[s:]="image size in GB, default is 8."
)
# Convert the keys of the associative array to a format usable by getopts
@@ -156,8 +199,8 @@ while getopts "${args}" i ; do
done
# make sure variables are set, or use defaults.
export imageSize="${imageSize:-6G}"
imageName="${imageName:-stormux-pi4-aarch64-$(date '+%Y-%m-%d').img}"
export imageSize="${imageSize:-8G}"
imageName="${imageName:-stormux-rpi4-5-aarch64-$(date '+%Y-%m-%d').img}"
export imageName
export imageLanguage="${imageLanguage:-en_US.UTF-8}"
@@ -196,7 +239,7 @@ for i in arch-install-scripts dosfstools parted ; do
exit 1
fi
done
for i in e2fsck fsck.vfat sha1sum xz ; do
for i in sha1sum xz ; do
if ! command -v "$i" &> /dev/null ; then
echo "Please install ${i} before continuing."
exit 1
@@ -330,6 +373,7 @@ packages=(
bluez-utils
brltty
cronie
curl
dialog
espeak-ng
fake-hwclock
@@ -339,6 +383,7 @@ packages=(
gstreamer
gst-plugins-base
gst-plugins-good
i38
ii
# Keep Pi onboard firmware plus common USB/network chipset firmware without
# pulling in unrelated desktop/server GPU firmware.
@@ -350,6 +395,7 @@ packages=(
man
man-pages
networkmanager
nodm-dgw
openssh
parted
pipewire
@@ -359,10 +405,13 @@ packages=(
poppler
python-dbus
python-gobject
python-msgpack
python-pyenchant
python-pyte
python-pyperclip
python-tornado
raspberrypi-utils
cthulhu
socat
realtime-privileges
rhvoice-voice-bdl
@@ -370,6 +419,7 @@ packages=(
rsync
screen
sox
speech-dispatcher
w3m-git
wget
wireless-regdb
@@ -377,16 +427,24 @@ packages=(
vi
xdg-user-dirs
xdg-utils
xlibre-input-libinput
xlibre-video-dummy-with-vt
xlibre-video-fbdev
xlibre-xserver
xorg-xinit
yay
)
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
echo "Configuring mkinitcpio preset to skip kms hook..."
sed -i "s/^default_options=.*/default_options=\"--skiphook kms\"/" /etc/mkinitcpio.d/linux-rpi.preset
sed -i "s/^default_options=.*/default_options=\"-S autodetect --skiphook kms\"/" /etc/mkinitcpio.d/linux-rpi.preset
sed -i "s/^fallback_options=.*/fallback_options=\"-S autodetect --skiphook kms\"/" /etc/mkinitcpio.d/linux-rpi.preset
fi
@@ -408,8 +466,10 @@ echo 'Stormux \r (\l)' > /etc/issue
echo >> /etc/issue
# Create the stormux user
useradd -m -g users -G wheel,realtime,audio,video,network,brlapi -s /bin/bash stormux
# Grant sudo privileges to the stormux user for package installation.
echo 'stormux ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers.d/wheel
# Grant normal sudo privileges plus temporary first-boot passwordless sudo.
echo '%wheel ALL=(ALL) ALL' > /etc/sudoers.d/wheel
echo 'stormux ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/zz-stormux-first-boot
chmod 440 /etc/sudoers.d/wheel /etc/sudoers.d/zz-stormux-first-boot
# Set the password for the root user
echo -e "root\nroot" | passwd "root"
# Set the password for the stormux user
@@ -422,13 +482,39 @@ git config --global init.defaultBranch master
# Create desktop, downloads, music, and other directories.
xdg-user-dirs-update
exit
mkdir -p /home/stormux/git
chown stormux:users /home/stormux/git
if ! sudo -u stormux git clone --depth 1 https://git.stormux.org/storm/I38 /home/stormux/git/I38; then
echo "Failed to clone I38."
exit 1
fi
cd /home/stormux/git/I38
sudo -u stormux ./i38.sh -D
cd /
if [[ -f /etc/nodm.conf ]]; then
sed -i \
-e "s/{user}/stormux/g" \
-e "s#^NODM_USER=.*#NODM_USER='stormux'#" \
-e "s#^NODM_XSESSION=.*#NODM_XSESSION='/home/stormux/.xinitrc'#" \
/etc/nodm.conf
fi
if command -v spd-conf > /dev/null 2>&1; then
spd-conf -n -C || echo "Warning: Speech Dispatcher system configuration failed."
fi
if [[ -f /etc/speech-dispatcher/speechd.conf ]]; then
if grep -qE '^[[:space:]]*#?[[:space:]]*DefaultModule[[:space:]]+' /etc/speech-dispatcher/speechd.conf; then
sed -i -E 's/^[[:space:]]*#?[[:space:]]*DefaultModule[[:space:]]+.*/DefaultModule rhvoice/' /etc/speech-dispatcher/speechd.conf
else
printf '\nDefaultModule rhvoice\n' >> /etc/speech-dispatcher/speechd.conf
fi
fi
# Enable linger so that hopefully sound will start at login.
mkdir -p /var/lib/systemd/linger
touch /var/lib/systemd/linger/stormux
systemctl --global enable pipewire.service pipewire-pulse.service
/usr/share/fenrirscreenreader/tools/configure_pipewire.sh
sudo -u stormux /usr/share/fenrirscreenreader/tools/configure_pipewire.sh
# Configure sudo for group wheel, remove nopasswd for the stormux user
# Ensure normal sudo remains configured after first-boot cleanup removes NOPASSWD.
echo '%wheel ALL=(ALL) ALL' > /etc/sudoers.d/wheel
# Set the hostname
echo stormux > /etc/hostname
@@ -439,7 +525,6 @@ services=(
brltty.path
cronie.service
fake-hwclock.service
fenrirscreenreader.service
log-to-ram-setup.service
log-to-ram-sync.timer
log-to-ram-shutdown.service
@@ -464,6 +549,8 @@ 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/ \;
+19 -7
View File
@@ -12,6 +12,19 @@ if ! [[ -x /usr/local/bin/configure-stormux ]]; then
return
fi
firstBootSudoersFile="/etc/sudoers.d/zz-stormux-first-boot"
firstBootAutologinFile="/etc/systemd/system/getty@tty1.service.d/stormux-first-boot-autologin.conf"
firstBootAutologinDir="${firstBootAutologinFile%/*}"
cleanup_first_boot_access() {
trap - RETURN HUP INT TERM
if [[ -e "$firstBootSudoersFile" || -e "$firstBootAutologinFile" ]]; then
sudo -n /bin/sh -c "rm -f '$firstBootSudoersFile' '$firstBootAutologinFile'; rmdir --ignore-fail-on-non-empty '$firstBootAutologinDir'; systemctl daemon-reload" 2>/dev/null || true
fi
}
trap cleanup_first_boot_access RETURN HUP INT TERM
# Volume calibration FIRST
echo "Setting up audio volume..."
volume=50
@@ -32,7 +45,7 @@ while [[ $volume -le 130 ]]; do
clear
pactl set-sink-volume @DEFAULT_SINK@ "${volume}%" 2>/dev/null
spd-say "If this is loud enough, press enter."
if read -t4 ; then
if read -r -t4 ; then
echo "Volume set to ${volume}%"
break
else
@@ -47,16 +60,15 @@ if [[ -x /etc/audibleprompt.sh ]]; then
export sudoFlags=("-A")
fi
echo
echo "Starting Fenrir..."
spd-say "Starting Fenrir."
sudo "${sudoFlags[@]}" systemctl --quiet enable --now fenrirscreenreader.service
clear
cat << "EOF"
Hello, and welcome to Stormux!
Let's get you set up. After you press enter, you will be prompted for the sudo password.
When that happens, type the word stormux and press enter.
You will not receive any speech feedback for this process.
That is completely normal, and speech will return after you have typed the password.
Once again, the password is stormux in all lower case letters.
Please press enter to continue.
EOF
read -r
@@ -0,0 +1,3 @@
[Service]
ExecStart=
ExecStart=-/usr/bin/agetty --autologin stormux --noclear %I $TERM
+131 -23
View File
@@ -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
@@ -27,73 +28,170 @@ fi
# Volume calibration is now handled in stormux_first_boot.sh
export DIALOGOPTS='--insecure --no-lines --visit-items'
i38StartAtBoot=0
firstBootSudoersFile="/etc/sudoers.d/zz-stormux-first-boot"
firstBootAutologinFile="/etc/systemd/system/getty@tty1.service.d/stormux-first-boot-autologin.conf"
firstBootAutologinDir="${firstBootAutologinFile%/*}"
read_yes_no() {
local answer
while true; do
echo "$1"
echo "Press y for yes or n for no followed by enter."
read -r answer
answer="${answer::1}"
case "${answer,,}" in
y)
return 0
;;
n)
return 1
;;
esac
echo "Please answer y or n."
done
}
cleanup_first_boot_access() {
rm -f "$firstBootSudoersFile" "$firstBootAutologinFile"
rmdir --ignore-fail-on-non-empty "$firstBootAutologinDir"
systemctl daemon-reload
}
configure_physical_screen() {
sudo "${sudoFlags[@]}" mkdir -p /etc/X11/xorg.conf.d
sudo "${sudoFlags[@]}" tee /etc/X11/xorg.conf.d/10-screendriver.conf > /dev/null << 'EOF'
Section "OutputClass"
Identifier "vc4"
MatchDriver "vc4"
Driver "modesetting"
Option "PrimaryGPU" "true"
EndSection
EOF
}
configure_headless_screen() {
sudo "${sudoFlags[@]}" mkdir -p /etc/X11/xorg.conf.d
sudo "${sudoFlags[@]}" tee /etc/X11/xorg.conf.d/10-screendriver.conf > /dev/null << 'EOF'
Section "Monitor"
Identifier "dummy_monitor"
HorizSync 28.0-80.0
VertRefresh 48.0-75.0
Modeline "1920x1080" 172.80 1920 2040 2248 2576 1080 1081 1084 1118
EndSection
Section "Device"
Identifier "dummy_card"
Driver "dummy"
VideoRam 256000
Option "UseFBDev" "false"
EndSection
Section "Screen"
Identifier "dummy_screen"
Device "dummy_card"
Monitor "dummy_monitor"
SubSection "Display"
Depth 24
Modes "1920x1080"
EndSubSection
EndSection
Section "ServerLayout"
Identifier "dummy_layout"
Screen 0 "dummy_screen"
EndSection
EOF
}
set_timezone() {
# Get the list of timezones
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.
echo "Would you like to switch Fenrir to laptop layout?"
echo "Press y for yes or n for no followed by enter."
read -r continue
continue="${continue::1}"
if [[ "${continue,}" == "y" ]];then
if read_yes_no "Would you like to switch Fenrir to laptop layout?"; then
sed -i 's/=desktop/=laptop/' /etc/fenrirscreenreader/settings/settings.conf
clear
systemctl restart fenrirscreenreader.service
fi
if command -v i3 > /dev/null 2>&1 && systemctl list-unit-files nodm.service > /dev/null 2>&1; then
if read_yes_no "Will this Pi be connected to a physical screen?"; then
configure_physical_screen
else
configure_headless_screen
fi
if read_yes_no "Should I38 start at boot?"; then
sudo "${sudoFlags[@]}" systemctl --quiet enable nodm.service
i38StartAtBoot=1
if read_yes_no "Should Fenrir run in the console at boot?"; then
sudo "${sudoFlags[@]}" systemctl --quiet enable fenrirscreenreader.service
else
sudo "${sudoFlags[@]}" systemctl --quiet disable fenrirscreenreader.service
fi
else
sudo "${sudoFlags[@]}" systemctl --quiet disable nodm.service
sudo "${sudoFlags[@]}" systemctl --quiet enable fenrirscreenreader.service
fi
fi
# Check for possible resize
diskSource="$(df --output='source' / | tail -1)"
diskSize="$(df -h --output='size' / | tail -1 | tr -cd '[:digit:].')"
diskSize=${diskSize%.*}
if [[ $diskSize -le 7 ]]; then
echo "$diskSource is only $diskSize gigs, which means it probably needs to be resized. Would you like to do this now?"
echo "Press y for yes or n for no followed by enter."
read -r continue
continue="${continue::1}"
if [[ "${continue,}" == "y" ]];then
resizeThreshold=9
if [[ $diskSize -le $resizeThreshold ]]; then
if read_yes_no "$diskSource is only $diskSize gigs, which means it probably needs to be resized. Would you like to do this now?"; then
resizeLog="/tmp/stormux-resize.log"
# Extract base device name (handles mmcblk0p2, nvme0n1p2, sda2, etc.)
if [[ "$diskSource" =~ (.*[0-9]+)p[0-9]+$ ]]; then
# Handle mmcblk0p2, nvme0n1p2 style
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%
sudo "${sudoFlags[@]}" resize2fs -f "$diskSource"
echo "Resizing the filesystem. This may take a few minutes."
if { echo "Yes" | sudo "${sudoFlags[@]}" parted ---pretend-input-tty "$diskDevice" resizepart 2 100%; } > "$resizeLog" 2>&1 \
&& { sudo "${sudoFlags[@]}" resize2fs -f "$diskSource"; } >> "$resizeLog" 2>&1; then
echo "Resize complete."
else
echo "Resize failed. Details are in $resizeLog."
fi
fi
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
read -r _
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
@@ -108,7 +206,7 @@ if ping -qc1 -W 1 stormux.org &> /dev/null; then
read -rp "Would you like the time to sync as soon as the Pi connects to the internet? " answer
answer="${answer:0:1}"
if [[ "${answer,,}" == "y" ]]; then
systemctl enable time_sync_at_boot.service
systemctl --quiet enable time_sync_at_boot.service
else
echo "Time sync at boot skipped."
echo "If you change your mind later, simply type:"
@@ -137,5 +235,15 @@ echo "or you may configure your system manually."
echo
echo "Thank you for choosing Stormux."
echo
read -rp "Press enter to continue."
cleanup_first_boot_access
if [[ $i38StartAtBoot -eq 1 ]]; then
echo
echo "Starting I38..."
sudo "${sudoFlags[@]}" systemctl --quiet start nodm.service
fi
exit 0
-582
View File
@@ -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
+26
View File
@@ -0,0 +1,26 @@
# Ship I38 By Default On Pi Stormux
## Planned
- [ ] Build and boot-test a Pi image with the new GUI defaults.
- [ ] Port the proven Pi behavior to the x86_64 installer/profile after the Pi image is verified.
## In Progress
- No active implementation items.
## Done
- [x] Bump the default Pi image size from 6 GB to 8 GB.
- [x] Raise the first-boot resize prompt threshold to 9 GB.
- [x] Configure Speech Dispatcher during image creation when `spd-conf` is available.
- [x] Add I38, Cthulhu, XLibre, nodm, Speech Dispatcher, and startx support to the Pi image.
- [x] Generate the default user's I38 config during image creation with `i38.sh -D`.
- [x] Ask during first boot whether I38 should start at boot, and whether Fenrir should stay enabled for the console.
- [x] Convert the external `configure-stormux` I38 action into a removal/purge action.
## Deferred x86_64 Follow-up
- [ ] Replace interactive I38 setup in the x86_64 installer with the same non-interactive `i38.sh -D` path where appropriate.
- [ ] Review x86_64 package lists against the Pi package set after the Pi build is proven.
- [ ] Re-check nodm and Fenrir service policy for installed x86_64 systems.
+141
View File
@@ -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 "$@"
+325
View File
@@ -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 "$@"
+230
View File
@@ -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 "$@"
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
startDir="$(pwd)"
buildDir="${startDir}/xlibre-build"
packageList=(
xlibre-input-libinput
xlibre-xserver
xlibre-video-amdgpu
xlibre-video-ati
xlibre-video-fbdev
xlibre-video-intel
xlibre-video-nouveau
xlibre-video-vesa
xlibre-video-dummy-with-vt
)
mkdir -p "${buildDir}"
for i in "${packageList[@]}" ; do
yay -Ga "$i"
pushd "$i"
makepkg -Acrsf
cp -v ./*.pkg.tar.* "${buildDir}/"
popd
done
+201
View File
@@ -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
+73 -75
View File
@@ -18,12 +18,13 @@ rootPassword=""
declare -a userNames=()
declare -a userPasswords=()
declare -a userIsAdmin=()
desktopEnvironment="" # "none", "i3", "mate"
desktopEnvironment="" # "none", "i3"
hasDesktop=false # true if a desktop environment is selected
timezone=""
enableSsh="no" # "yes" or "no"
installLinuxGameManager="no" # "yes" or "no"
installAudiogameManager="no" # "yes" or "no"
installSteam="no" # "yes" or "no"
autoLoginUser="" # User to auto-login for desktop environments
minHomePartitionMiB=1024
errorCount=0
@@ -54,6 +55,7 @@ log_info() {
echo "$*" | tee -a "$logFile"
}
# shellcheck disable=SC2329 # Invoked by the ERR trap after pre-install prompts finish.
handle_unexpected_failure() {
local exitCode="$1"
@@ -192,6 +194,7 @@ select_disk() {
lsblk -dno NAME,SIZE,TYPE,MODEL | grep -E "^($(IFS='|'; echo "${disks[*]}")).*disk" || true
PS3="Enter disk number: "
COLUMNS=1
select diskChoice in "${disks[@]}" "Cancel"; do
if [[ "$diskChoice" == "Cancel" ]]; then
log_info "Disk selection cancelled by user"
@@ -268,6 +271,7 @@ select_install_target() {
local manualOption="Use existing /mnt (already mounted root/home/boot)"
PS3="Enter target number: "
COLUMNS=1
select targetChoice in "${disks[@]}" "$manualOption" "Cancel"; do
if [[ "$targetChoice" == "Cancel" ]]; then
log_info "Target selection cancelled by user"
@@ -326,6 +330,7 @@ select_partition_layout() {
)
PS3="Enter layout number: "
COLUMNS=1
select layout in "${layouts[@]}"; do
case "$layout" in
"Single partition"*)
@@ -1085,9 +1090,10 @@ gather_system_info() {
# Desktop environment
echo "Select desktop environment:"
local desktops=("Console only (Fenrir screen reader)" "i3 (tiling window manager)" "MATE (traditional desktop)")
local desktops=("Console only (Fenrir screen reader)" "I38 (accessible i3 desktop)")
PS3="Enter desktop number: "
COLUMNS=1
select desktop in "${desktops[@]}"; do
case "$desktop" in
"Console only"*)
@@ -1096,16 +1102,10 @@ gather_system_info() {
log_info "Desktop environment: none"
break
;;
"i3"*)
"I38"*)
desktopEnvironment="i3"
hasDesktop=true
log_info "Desktop environment: i3"
break
;;
"MATE"*)
desktopEnvironment="mate"
hasDesktop=true
log_info "Desktop environment: mate"
log_info "Desktop environment: I38"
break
;;
esac
@@ -1122,6 +1122,7 @@ gather_system_info() {
else
echo "Select which user should automatically login to the graphical session:"
PS3="Enter user number: "
COLUMNS=1
select selectedUser in "${userNames[@]}"; do
if [[ -n "$selectedUser" ]]; then
autoLoginUser="$selectedUser"
@@ -1158,6 +1159,17 @@ gather_system_info() {
installAudiogameManager="no"
log_info "Audiogame Manager will not be installed"
fi
echo ""
echo "Install Steam? [y/N]:"
read -r steamChoice
if [[ "$steamChoice" =~ ^[Yy]$ ]]; then
installSteam="yes"
log_info "Steam will be installed"
else
installSteam="no"
log_info "Steam will not be installed"
fi
fi
# SSH configuration
@@ -1204,8 +1216,17 @@ gather_system_info() {
echo "Configuration summary:"
echo " Hostname: $hostname"
echo " Users: ${userNames[*]}"
echo " Desktop: $desktopEnvironment"
if [[ "$desktopEnvironment" == "i3" ]]; then
echo " Desktop: I38"
else
echo " Desktop: $desktopEnvironment"
fi
echo " SSH enabled: $enableSsh"
if [[ "$hasDesktop" == true ]]; then
echo " linux-game-manager: $installLinuxGameManager"
echo " audiogame-manager: $installAudiogameManager"
echo " Steam: $installSteam"
fi
echo " Timezone: $timezone"
echo ""
echo "Press Enter to continue with installation..."
@@ -1250,6 +1271,34 @@ refresh_package_sources() {
return 0
}
enable_multilib_repo() {
local pacmanConf="$1"
if [[ ! -f "$pacmanConf" ]]; then
log_error "Pacman config not found: $pacmanConf"
return 1
fi
log_info "Enabling multilib repository in $pacmanConf"
sed -i \
-e 's/^#\[multilib\]$/[multilib]/' \
-e '/^\[multilib\]$/,/^\[/ s/^#Include = \/etc\/pacman.d\/mirrorlist$/Include = \/etc\/pacman.d\/mirrorlist/' \
"$pacmanConf"
}
copy_rhvoice_english_fixes() {
local sourceDir="/etc/RHVoice/dicts/English"
local targetDir="$mountPoint/etc/RHVoice/dicts/English"
if [[ -d "$sourceDir" ]]; then
log_info "Installing RHVoice English dictionary fixes"
mkdir -p "$targetDir"
cp -a "$sourceDir/." "$targetDir/"
else
log_warning "RHVoice English dictionary fixes not found at $sourceDir"
fi
}
install_base_system() {
log_info "=== Installing Base System ==="
@@ -1291,6 +1340,11 @@ install_base_system() {
shopt -u nullglob
fi
if [[ "$installSteam" == "yes" ]]; then
enable_multilib_repo /etc/pacman.conf
enable_multilib_repo "$mountPoint/etc/pacman.conf"
fi
# Define package groups
local basePackages=(
base base-devel linux linux-firmware
@@ -1332,6 +1386,9 @@ install_base_system() {
case "$desktopEnvironment" in
*)
allPackages+=(xlibre-xserver xlibre-input-libinput nodm-dgw brave-bin)
if [[ "$installSteam" == "yes" ]]; then
allPackages+=(steam)
fi
;;&
i3)
allPackages+=(i3-wm orca python-psutil lxterminal pluma)
@@ -1341,9 +1398,6 @@ install_base_system() {
allPackages+=(python-gobject python-pillow python-pytesseract scrot tesseract)
allPackages+=(tesseract-data-eng udiskie xorg-setxkbmap xdotool)
;;
mate)
allPackages+=(mate mate-extra orca python-psutil)
;;
esac
fi
@@ -1367,6 +1421,8 @@ install_base_system() {
return 1
fi
copy_rhvoice_english_fixes
# Generate or preserve fstab
if [[ "$useExistingMount" == true ]]; then
if [[ -s "$mountPoint/etc/fstab" ]]; then
@@ -1737,75 +1793,17 @@ fi
# Run I38 setup scripts as the user
# -x generates xinitrc and xprofile
# Main script generates i3 config with accessibility features
play -qnV0 synth 3 pluck D3 pluck A3 pluck D4 pluck F4 pluck A4 delay 0 .1 .2 .3 .4 remix - chorus 0.9 0.9 38 0.75 0.3 0.5 -t 2>/dev/null || true
if ! sudo -u \$firstUser ./i38.sh -x; then
echo "WARNING: I38 xinit/xprofile setup reported an error"
fi
play -qnV0 synth 3 pluck D3 pluck A3 pluck D4 pluck F4 pluck A4 delay 0 .1 .2 .3 .4 remix - chorus 0.9 0.9 38 0.75 0.3 0.5 -t 2>/dev/null || true
if ! sudo -u \$firstUser ./i38.sh; then
echo "WARNING: I38 main setup reported an error"
fi
cd - > /dev/null || exit 1
fi
elif [[ "${desktopEnvironment}" == "mate" ]]; then
firstUser="${userNames[0]}"
# Create .xinitrc for MATE
cat > /home/\$firstUser/.xinitrc <<'XINITRC_MATE_EOF'
#!/bin/sh
#
# ~/.xinitrc
#
# Executed by startx (run your window manager from here)
[[ -f ~/.Xresources ]] && xrdb -merge -I\$HOME ~/.Xresources
if [ -d /etc/X11/xinit/xinitrc.d ]; then
for f in /etc/X11/xinit/xinitrc.d/*; do
[ -x "\$f" ] && . "\$f"
done
unset f
fi
[ -f /etc/xprofile ] && . /etc/xprofile
[ -f ~/.xprofile ] && . ~/.xprofile
exec dbus-run-session -- mate-session
XINITRC_MATE_EOF
chmod 755 /home/\$firstUser/.xinitrc
chown \$firstUser:users /home/\$firstUser/.xinitrc
# Create .xprofile for MATE with accessibility variables
cat > /home/\$firstUser/.xprofile <<'XPROFILE_MATE_EOF'
# Accessibility variables
export ACCESSIBILITY_ENABLED=1
export CHROME_FLAGS="--force-renderer-accessibility"
export GTK_MODULES=gail:atk-bridge
export GNOME_ACCESSIBILITY=1
export QT_ACCESSIBILITY=1
export QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1
export SAL_USE_VCLPLUGIN=gtk3
# Enable Orca screen reader
if ! gsettings set org.gnome.desktop.a11y.applications screen-reader-enabled true; then
echo "WARNING: Could not set GNOME screen-reader-enabled flag"
fi
if ! gsettings set org.mate.interface accessibility true; then
echo "WARNING: Could not set MATE accessibility flag"
fi
if ! gsettings set org.mate.applications-at-visual startup true; then
echo "WARNING: Could not set MATE startup applications accessibility flag"
fi
XPROFILE_MATE_EOF
chmod 755 /home/\$firstUser/.xprofile
chown \$firstUser:users /home/\$firstUser/.xprofile
# Configure speech-dispatcher for MATE
if [[ ! -d /home/\$firstUser/.config/speech-dispatcher ]]; then
if ! sudo -u \$firstUser spd-conf -n; then
echo "WARNING: Failed to initialize speech-dispatcher configuration for \$firstUser"
fi
fi
fi
# Initialize pacman keyring
pacman-key --init
@@ -1841,7 +1839,7 @@ fi
$pipewireConfigCmd
# Configure nodm for automatic login (only after nodm package is installed)
if [[ "${desktopEnvironment}" == "i3" ]] || [[ "${desktopEnvironment}" == "mate" ]]; then
if [[ "${desktopEnvironment}" == "i3" ]]; then
if [[ -f /etc/nodm.conf ]]; then
sed -i "s/{user}/${autoLoginUser}/g" /etc/nodm.conf
systemctl enable nodm.service
-1
View File
@@ -1 +0,0 @@
sas.sh
-582
View File
@@ -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
-33
View File
@@ -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"
-10
View File
@@ -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
+1
View File
@@ -18,6 +18,7 @@ btrfs-progs
clonezilla
cloud-init
cryptsetup
curl
darkhttpd
ddrescue
dhcpcd
-3
View File
@@ -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"
+25
View File
@@ -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"