Compare commits
15 Commits
1a36316cfd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| eb36d6d976 | |||
| 040eecca09 | |||
| 973b2573c8 | |||
| 9a0a6fef00 | |||
| 9496559875 | |||
| ddab0d827f | |||
| aacdf8eb4a | |||
| a6c65ca973 | |||
| 19fd4b1ed4 | |||
| 185d098bdd | |||
| 140a1c8f88 | |||
| 695b9e2f75 | |||
| acf8327949 | |||
| 08123ea6e9 | |||
| db6a4880b3 |
@@ -0,0 +1,57 @@
|
||||
# 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
|
||||
@@ -24,18 +24,52 @@ 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 # cleanup is invoked via trap EXIT
|
||||
cleanup() {
|
||||
status=$? # capture original exit status so failures propagate
|
||||
# shellcheck disable=SC2329 # compress_image is invoked from finish_build
|
||||
compress_image() {
|
||||
local compressedImage="${imageName}.xz"
|
||||
|
||||
if [[ ! -s "${imageName}" ]]; then
|
||||
echo "Image file ${imageName} was not created or is empty."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Compressing ${imageName} to ${compressedImage}..."
|
||||
xz -T0 -9 "${imageName}"
|
||||
echo "Creating SHA-1 checksum for ${compressedImage}..."
|
||||
sha1sum "${compressedImage}" > "${compressedImage}.sha1sum"
|
||||
echo "Image build complete: ${compressedImage}"
|
||||
echo "Checksum: ${compressedImage}.sha1sum"
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2329 # cleanup_image is invoked from cleanup and finish_build
|
||||
cleanup_image() {
|
||||
local verifyFilesystems="${1:-false}"
|
||||
local cleanupStatus=0
|
||||
|
||||
if [[ $mounted -eq 0 ]]; then
|
||||
umount -R /mnt || true
|
||||
if ! umount -R /mnt; then
|
||||
cleanupStatus=1
|
||||
elif [[ "$verifyFilesystems" == true ]]; then
|
||||
if ! verify_image; then
|
||||
cleanupStatus=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${loopdev:-}" ]]; then
|
||||
partx -d "${loopdev}" || true
|
||||
losetup --detach "${loopdev}" || true
|
||||
if ! partx -d "${loopdev}"; then
|
||||
cleanupStatus=1
|
||||
fi
|
||||
if ! losetup --detach "${loopdev}"; then
|
||||
cleanupStatus=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean up temporary pacman config directory
|
||||
@@ -43,9 +77,41 @@ cleanup() {
|
||||
rm -rf "${tmpDir}"
|
||||
fi
|
||||
|
||||
return "$cleanupStatus"
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2329 # cleanup is invoked via trap EXIT
|
||||
cleanup() {
|
||||
local status=$? # capture original exit status so failures propagate
|
||||
local cleanupStatus=0
|
||||
|
||||
if ! cleanup_image false; then
|
||||
cleanupStatus=1
|
||||
fi
|
||||
|
||||
if [[ $status -eq 0 && $cleanupStatus -ne 0 ]]; then
|
||||
echo "Image build commands completed, but cleanup or filesystem verification failed."
|
||||
status=1
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
finish_build() {
|
||||
trap - EXIT
|
||||
|
||||
if ! cleanup_image true; then
|
||||
echo "Image build commands completed, but cleanup or filesystem verification failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! compress_image; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
}
|
||||
|
||||
help() {
|
||||
echo -e "Usage:\n"
|
||||
echo "With no arguments, build with default parameters."
|
||||
@@ -100,6 +166,12 @@ if [[ -e "$imageName" ]]; then
|
||||
echo "${imageName} exists, please remove or move it for this script to continue."
|
||||
exit 1
|
||||
fi
|
||||
for outputFile in "${imageName}.xz" "${imageName}.xz.sha1sum"; do
|
||||
if [[ -e "$outputFile" ]]; then
|
||||
echo "${outputFile} exists, please remove or move it for this script to continue."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Make sure this script is ran as root.
|
||||
if [ "$(whoami)" != "root" ] ; then
|
||||
@@ -124,6 +196,12 @@ for i in arch-install-scripts dosfstools parted ; do
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
for i in e2fsck fsck.vfat sha1sum xz ; do
|
||||
if ! command -v "$i" &> /dev/null ; then
|
||||
echo "Please install ${i} before continuing."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
fallocate -l "$imageSize" "$imageName"
|
||||
@@ -257,11 +335,17 @@ packages=(
|
||||
fake-hwclock
|
||||
fenrir
|
||||
firmware-raspberrypi
|
||||
linux-firmware
|
||||
git
|
||||
gstreamer
|
||||
gst-plugins-base
|
||||
gst-plugins-good
|
||||
ii
|
||||
# Keep Pi onboard firmware plus common USB/network chipset firmware without
|
||||
# pulling in unrelated desktop/server GPU firmware.
|
||||
linux-firmware-atheros
|
||||
linux-firmware-broadcom
|
||||
linux-firmware-mediatek
|
||||
linux-firmware-realtek
|
||||
magic-wormhole
|
||||
man
|
||||
man-pages
|
||||
@@ -290,6 +374,7 @@ packages=(
|
||||
wget
|
||||
wireless-regdb
|
||||
wireplumber
|
||||
vi
|
||||
xdg-user-dirs
|
||||
xdg-utils
|
||||
yay
|
||||
@@ -385,5 +470,5 @@ find ../files/etc/skel/ -mindepth 1 -exec cp -rv "{}" /mnt/home/stormux/ \;
|
||||
# Copy boot files again to ensure custom config overrides any package changes
|
||||
cp -rv ../files/boot/* /mnt/boot
|
||||
|
||||
# Exiting calls the cleanup function to unmount.
|
||||
exit 0
|
||||
# Clean up, verify, compress, and create the checksum.
|
||||
finish_build
|
||||
|
||||
Executable
+198
@@ -0,0 +1,198 @@
|
||||
#! /bin/bash
|
||||
#
|
||||
# Copyright 2025, Stormux, <storm_dragon@stormux.org>
|
||||
#
|
||||
# This is free software; you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free
|
||||
# Software Foundation; either version 3, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# This software is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this package; see the file COPYING. If not, write to the Free
|
||||
# Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
|
||||
# 02110-1301, USA.
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mountedPoints=()
|
||||
loopDev=""
|
||||
mountRoot=""
|
||||
rootPart=""
|
||||
bootPart=""
|
||||
imagePath=""
|
||||
|
||||
cleanup() {
|
||||
statusCode=$?
|
||||
set +e
|
||||
|
||||
if [[ ${#mountedPoints[@]} -gt 0 ]]; then
|
||||
for ((index=${#mountedPoints[@]}-1; index>=0; index--)); do
|
||||
if mountpoint -q "${mountedPoints[$index]}"; then
|
||||
umount "${mountedPoints[$index]}" || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "${mountRoot}" && -d "${mountRoot}" && "${mountRoot}" != "/mnt" ]]; then
|
||||
rmdir "${mountRoot}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ -n "${loopDev}" ]]; then
|
||||
partx -d "${loopDev}" 2>/dev/null || true
|
||||
losetup --detach "${loopDev}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exit "$statusCode"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
die() {
|
||||
echo "Error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
help() {
|
||||
echo "Usage: $0 <image-file>"
|
||||
echo "Mounts a Stormux image, chroots into it, and cleans up on exit."
|
||||
exit 0
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
||||
}
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
help
|
||||
fi
|
||||
|
||||
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||||
help
|
||||
fi
|
||||
|
||||
imagePath="$(readlink -f "$1" 2>/dev/null || true)"
|
||||
if [[ -z "${imagePath}" ]]; then
|
||||
die "Unable to resolve image path: $1"
|
||||
fi
|
||||
if [[ ! -f "${imagePath}" ]]; then
|
||||
die "Image file not found: ${imagePath}"
|
||||
fi
|
||||
|
||||
if [[ "$(whoami)" != "root" ]]; then
|
||||
die "This script must be run as root."
|
||||
fi
|
||||
|
||||
if command -v pacman >/dev/null 2>&1; then
|
||||
for packageName in arch-install-scripts util-linux; do
|
||||
if ! pacman -Q "$packageName" &>/dev/null; then
|
||||
die "Please install ${packageName} before continuing."
|
||||
fi
|
||||
done
|
||||
else
|
||||
require_cmd arch-chroot
|
||||
require_cmd losetup
|
||||
require_cmd partx
|
||||
require_cmd lsblk
|
||||
require_cmd mount
|
||||
require_cmd umount
|
||||
require_cmd findmnt
|
||||
require_cmd mountpoint
|
||||
fi
|
||||
|
||||
if [[ "$(uname -m)" == "x86_64" ]]; then
|
||||
if command -v pacman >/dev/null 2>&1; then
|
||||
if ! pacman -Q qemu-user-static &>/dev/null; then
|
||||
die "Please install qemu-user-static and qemu-user-static-binfmt before continuing."
|
||||
fi
|
||||
if ! pacman -Q qemu-user-static-binfmt &>/dev/null; then
|
||||
die "Please install qemu-user-static and qemu-user-static-binfmt before continuing."
|
||||
fi
|
||||
else
|
||||
require_cmd qemu-aarch64-static
|
||||
fi
|
||||
fi
|
||||
|
||||
require_cmd arch-chroot
|
||||
require_cmd losetup
|
||||
require_cmd partx
|
||||
require_cmd lsblk
|
||||
require_cmd mount
|
||||
require_cmd umount
|
||||
require_cmd findmnt
|
||||
require_cmd mountpoint
|
||||
|
||||
baseMountDir="/mnt"
|
||||
if [[ ! -d "${baseMountDir}" || ! -w "${baseMountDir}" ]]; then
|
||||
die "/mnt is missing or not writable. Please create or fix permissions."
|
||||
fi
|
||||
|
||||
if mountpoint -q "${baseMountDir}"; then
|
||||
if [[ -t 0 ]]; then
|
||||
echo "/mnt is already mounted. Unmount it to continue? [y/N]"
|
||||
read -r answerText
|
||||
case "${answerText}" in
|
||||
y|Y|yes|YES)
|
||||
umount "${baseMountDir}" || die "Failed to unmount /mnt"
|
||||
;;
|
||||
*)
|
||||
die "Refusing to proceed while /mnt is mounted."
|
||||
;;
|
||||
esac
|
||||
else
|
||||
die "/mnt is already mounted. Refusing to proceed in non-interactive mode."
|
||||
fi
|
||||
fi
|
||||
|
||||
mountRoot="${baseMountDir}"
|
||||
|
||||
loopDev="$(losetup --find --show --partscan "${imagePath}")"
|
||||
partx -u "${loopDev}" >/dev/null 2>&1 || true
|
||||
|
||||
declare -a rootCandidates=()
|
||||
declare -a bootCandidates=()
|
||||
|
||||
while read -r deviceName fsType nodeType; do
|
||||
if [[ "${nodeType}" != "part" ]]; then
|
||||
continue
|
||||
fi
|
||||
case "${fsType}" in
|
||||
ext4|ext3|ext2)
|
||||
rootCandidates+=("${deviceName}")
|
||||
;;
|
||||
vfat|fat|fat32|fat16)
|
||||
bootCandidates+=("${deviceName}")
|
||||
;;
|
||||
esac
|
||||
done < <(lsblk -nrpo NAME,FSTYPE,TYPE "${loopDev}")
|
||||
|
||||
if [[ ${#rootCandidates[@]} -gt 0 ]]; then
|
||||
rootPart="${rootCandidates[0]}"
|
||||
elif [[ -b "${loopDev}p2" ]]; then
|
||||
rootPart="${loopDev}p2"
|
||||
else
|
||||
die "Unable to locate root partition inside ${imagePath}"
|
||||
fi
|
||||
|
||||
if [[ ${#bootCandidates[@]} -gt 0 ]]; then
|
||||
bootPart="${bootCandidates[0]}"
|
||||
elif [[ -b "${loopDev}p1" ]]; then
|
||||
bootPart="${loopDev}p1"
|
||||
fi
|
||||
|
||||
mount "${rootPart}" "${mountRoot}"
|
||||
mountedPoints+=("${mountRoot}")
|
||||
|
||||
if [[ -n "${bootPart}" ]]; then
|
||||
mkdir -p "${mountRoot}/boot"
|
||||
mount "${bootPart}" "${mountRoot}/boot"
|
||||
mountedPoints+=("${mountRoot}/boot")
|
||||
fi
|
||||
|
||||
echo "Mounted image at ${mountRoot}. Entering chroot..."
|
||||
PS1="(Chroot) [\\u@\\h \\W] \\$ " arch-chroot "${mountRoot}"
|
||||
@@ -0,0 +1 @@
|
||||
SUBSYSTEM=="tty", KERNEL=="tty[0-9]*|hvc[0-9]*|sclp_line[0-9]*|ttysclp[0-9]*|3270/tty[0-9]*", GROUP="tty", MODE="0620"
|
||||
Executable
+582
@@ -0,0 +1,582 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import signal
|
||||
import string
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import pwd
|
||||
import threading
|
||||
|
||||
|
||||
stormuxAdmin = ("storm",)
|
||||
ircServer = "irc.stormux.org"
|
||||
ircPort = 6667
|
||||
ircChannel = "#stormux"
|
||||
remoteHost = "billysballoons.com"
|
||||
sasUser = "sas"
|
||||
pingIntervalSeconds = 180
|
||||
pingCount = 5
|
||||
maxWormholeFailures = 3
|
||||
|
||||
sudoKeepaliveThread = None
|
||||
sudoKeepaliveStop = threading.Event()
|
||||
|
||||
|
||||
def speak_message(message):
|
||||
try:
|
||||
subprocess.run(["spd-say", message], check=False)
|
||||
except FileNotFoundError:
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def say_or_print(message, useSpeech):
|
||||
if useSpeech:
|
||||
speak_message(message)
|
||||
else:
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def run_command(command, inputText=None, check=False, env=None):
|
||||
return subprocess.run(
|
||||
command,
|
||||
input=inputText,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=check,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def ensure_sudo(useSpeech):
|
||||
if os.geteuid() == 0:
|
||||
return True
|
||||
if useSpeech:
|
||||
speak_message("Sudo password required. Please enter your password now.")
|
||||
result = run_command(["sudo", "-v"])
|
||||
if result.returncode == 0:
|
||||
start_sudo_keepalive()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def start_sudo_keepalive():
|
||||
global sudoKeepaliveThread
|
||||
if sudoKeepaliveThread and sudoKeepaliveThread.is_alive():
|
||||
return
|
||||
|
||||
def keepalive_loop():
|
||||
while not sudoKeepaliveStop.wait(240):
|
||||
run_command(["sudo", "-n", "-v"])
|
||||
|
||||
sudoKeepaliveThread = threading.Thread(target=keepalive_loop, daemon=True)
|
||||
sudoKeepaliveThread.start()
|
||||
|
||||
|
||||
def run_privileged(command, useSpeech, inputText=None, check=True):
|
||||
if os.geteuid() == 0:
|
||||
fullCommand = command
|
||||
else:
|
||||
if not ensure_sudo(useSpeech):
|
||||
raise RuntimeError("sudo authentication failed")
|
||||
fullCommand = ["sudo"] + command
|
||||
return run_command(fullCommand, inputText=inputText, check=check)
|
||||
|
||||
|
||||
def run_as_user(userName, command, useSpeech, check=True):
|
||||
if os.geteuid() == 0:
|
||||
fullCommand = ["sudo", "-u", userName, "-H"] + command
|
||||
else:
|
||||
if not ensure_sudo(useSpeech):
|
||||
raise RuntimeError("sudo authentication failed")
|
||||
fullCommand = ["sudo", "-u", userName, "-H"] + command
|
||||
return run_command(fullCommand, check=check)
|
||||
|
||||
|
||||
def user_exists(userName):
|
||||
result = run_command(["getent", "passwd", userName])
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def ensure_wheel(userName, useSpeech):
|
||||
result = run_command(["id", "-nG", userName])
|
||||
groups = result.stdout.strip().split()
|
||||
if "wheel" not in groups:
|
||||
run_privileged(["usermod", "-a", "-G", "wheel", userName], useSpeech)
|
||||
|
||||
|
||||
def generate_password():
|
||||
allowedChars = string.ascii_letters + string.digits
|
||||
length = secrets.randbelow(5) + 6
|
||||
return "".join(secrets.choice(allowedChars) for _ in range(length))
|
||||
|
||||
|
||||
def get_user_home(userName):
|
||||
return pwd.getpwnam(userName).pw_dir
|
||||
|
||||
|
||||
def set_password(userName, password, useSpeech):
|
||||
run_privileged(["chpasswd"], useSpeech, inputText=f"{userName}:{password}\n")
|
||||
|
||||
|
||||
def generate_ssh_key(userName, useSpeech):
|
||||
userHome = get_user_home(userName)
|
||||
sshDir = os.path.join(userHome, ".ssh")
|
||||
privateKeyPath = os.path.join(sshDir, "id_ed25519")
|
||||
publicKeyPath = f"{privateKeyPath}.pub"
|
||||
|
||||
run_privileged(["mkdir", "-p", sshDir], useSpeech)
|
||||
run_privileged(["chown", f"{userName}:{userName}", sshDir], useSpeech)
|
||||
run_privileged(["chmod", "700", sshDir], useSpeech)
|
||||
for entry in list_subdirs(sshDir):
|
||||
if os.path.isfile(entry) or os.path.islink(entry):
|
||||
run_privileged(["rm", "-f", entry], useSpeech, check=False)
|
||||
|
||||
run_as_user(
|
||||
userName,
|
||||
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", privateKeyPath],
|
||||
useSpeech,
|
||||
)
|
||||
|
||||
run_privileged(["chmod", "600", privateKeyPath], useSpeech)
|
||||
run_privileged(["chmod", "644", publicKeyPath], useSpeech)
|
||||
run_privileged(["chown", f"{userName}:{userName}", privateKeyPath, publicKeyPath], useSpeech)
|
||||
|
||||
return privateKeyPath, publicKeyPath
|
||||
|
||||
|
||||
def path_exists_for_user(path, userName, useSpeech):
|
||||
result = run_as_user(userName, ["stat", path], useSpeech, check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def extract_message_text(line):
|
||||
if "> " in line:
|
||||
return line.split("> ", 1)[1].strip()
|
||||
if ": " in line:
|
||||
return line.split(": ", 1)[1].strip()
|
||||
return line.strip()
|
||||
|
||||
|
||||
def parse_sender(line):
|
||||
match = re.search(r"<([^>]+)>", line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def find_wormhole_code(message):
|
||||
lowered = message.strip().lower()
|
||||
if lowered in ("yes", "accept"):
|
||||
return None
|
||||
match = re.search(r"\b\d+-[A-Za-z0-9-]+\b", message)
|
||||
if match:
|
||||
return match.group(0)
|
||||
return None
|
||||
|
||||
|
||||
class IrcSession:
|
||||
def __init__(self, server, port, nick, channel, baseDir):
|
||||
self.server = server
|
||||
self.port = port
|
||||
self.nick = nick
|
||||
self.channel = channel
|
||||
self.baseDir = baseDir
|
||||
self.serverDir = None
|
||||
self.serverInPath = None
|
||||
self.channelInPath = None
|
||||
self.iiProcess = None
|
||||
self.pmOffsets = {}
|
||||
|
||||
def start(self):
|
||||
if not shutil_which("ii"):
|
||||
raise RuntimeError("ii is not installed")
|
||||
supportsI = ii_supports_i()
|
||||
processEnv = os.environ.copy()
|
||||
iiCommand = ["ii", "-s", self.server, "-p", str(self.port), "-n", self.nick]
|
||||
if supportsI:
|
||||
iiCommand += ["-i", self.baseDir]
|
||||
else:
|
||||
processEnv["HOME"] = self.baseDir
|
||||
self.iiProcess = subprocess.Popen(
|
||||
iiCommand,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=processEnv,
|
||||
)
|
||||
self.serverDir = self.wait_for_server_dir()
|
||||
self.serverInPath = os.path.join(self.serverDir, "in")
|
||||
|
||||
def stop(self):
|
||||
if self.channelInPath and os.path.exists(self.channelInPath):
|
||||
try:
|
||||
self.write_line(self.channelInPath, f"/part {self.channel}")
|
||||
except OSError:
|
||||
pass
|
||||
if self.serverInPath and os.path.exists(self.serverInPath):
|
||||
try:
|
||||
self.write_line(self.serverInPath, "/quit")
|
||||
except OSError:
|
||||
pass
|
||||
if self.iiProcess and self.iiProcess.poll() is None:
|
||||
self.iiProcess.terminate()
|
||||
try:
|
||||
self.iiProcess.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.iiProcess.kill()
|
||||
|
||||
def join_channel(self):
|
||||
joinMessage = f"/join {self.channel}"
|
||||
channelDir = os.path.join(self.serverDir, self.channel)
|
||||
channelAltDir = os.path.join(self.serverDir, self.channel.lstrip("#"))
|
||||
startTime = time.monotonic()
|
||||
nextJoinTime = startTime
|
||||
while time.monotonic() - startTime < 60:
|
||||
if time.monotonic() >= nextJoinTime:
|
||||
self.write_line(self.serverInPath, joinMessage)
|
||||
nextJoinTime = time.monotonic() + 5
|
||||
for candidate in (channelDir, channelAltDir):
|
||||
inPath = os.path.join(candidate, "in")
|
||||
if os.path.exists(inPath):
|
||||
self.channelInPath = inPath
|
||||
return
|
||||
time.sleep(0.5)
|
||||
self.channelInPath = None
|
||||
|
||||
def send_channel_message(self, message):
|
||||
if not self.channelInPath:
|
||||
self.refresh_channel_in_path()
|
||||
if self.channelInPath and os.path.exists(self.channelInPath):
|
||||
self.write_line(self.channelInPath, message)
|
||||
else:
|
||||
self.write_line(self.serverInPath, f"/msg {self.channel} {message}")
|
||||
|
||||
def send_private_message(self, nick, message):
|
||||
nickDir = os.path.join(self.serverDir, nick)
|
||||
inPath = os.path.join(nickDir, "in")
|
||||
if os.path.exists(inPath):
|
||||
self.write_line(inPath, message)
|
||||
else:
|
||||
self.write_line(self.serverInPath, f"/msg {nick} {message}")
|
||||
|
||||
def get_private_messages(self, allowedUsers):
|
||||
messages = []
|
||||
for nick in allowedUsers:
|
||||
nickDir = os.path.join(self.serverDir, nick)
|
||||
outPath = os.path.join(nickDir, "out")
|
||||
if not os.path.exists(outPath):
|
||||
continue
|
||||
lastPos = self.pmOffsets.get(outPath, 0)
|
||||
with open(outPath, "r", encoding="utf-8", errors="ignore") as fileHandle:
|
||||
fileHandle.seek(lastPos)
|
||||
for line in fileHandle:
|
||||
sender = parse_sender(line)
|
||||
if sender and sender == self.nick:
|
||||
continue
|
||||
if sender and sender != nick:
|
||||
continue
|
||||
messageText = extract_message_text(line)
|
||||
if messageText:
|
||||
messages.append((nick, messageText))
|
||||
self.pmOffsets[outPath] = fileHandle.tell()
|
||||
return messages
|
||||
|
||||
def refresh_channel_in_path(self):
|
||||
channelDir = os.path.join(self.serverDir, self.channel)
|
||||
channelAltDir = os.path.join(self.serverDir, self.channel.lstrip("#"))
|
||||
for candidate in (channelDir, channelAltDir):
|
||||
inPath = os.path.join(candidate, "in")
|
||||
if os.path.exists(inPath):
|
||||
self.channelInPath = inPath
|
||||
return
|
||||
|
||||
def wait_for_server_dir(self):
|
||||
for _ in range(120):
|
||||
for rootDir in [self.baseDir] + list_subdirs(self.baseDir):
|
||||
if not os.path.isdir(rootDir):
|
||||
continue
|
||||
for entry in os.listdir(rootDir):
|
||||
path = os.path.join(rootDir, entry)
|
||||
if os.path.isdir(path) and self.server in entry:
|
||||
inPath = os.path.join(path, "in")
|
||||
if os.path.exists(inPath):
|
||||
return path
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("ii server directory not found")
|
||||
|
||||
@staticmethod
|
||||
def write_line(path, message):
|
||||
with open(path, "w", encoding="utf-8", errors="ignore") as fileHandle:
|
||||
fileHandle.write(message + "\n")
|
||||
fileHandle.flush()
|
||||
|
||||
|
||||
def ii_supports_i():
|
||||
result = run_command(["ii", "-h"])
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
return "-i" in output
|
||||
|
||||
|
||||
def list_subdirs(path):
|
||||
try:
|
||||
return [os.path.join(path, entry) for entry in os.listdir(path)]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def shutil_which(command):
|
||||
for path in os.environ.get("PATH", "").split(os.pathsep):
|
||||
candidate = os.path.join(path, command)
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def build_nick():
|
||||
baseUser = os.environ.get("SUDO_USER") or os.environ.get("USER") or "sas"
|
||||
return f"{baseUser}-{int(time.time())}"
|
||||
|
||||
|
||||
def main():
|
||||
say_or_print("Checking accessibility. Is your screen reader working? (y/n)", True)
|
||||
answer = input().strip().lower()
|
||||
useSpeech = answer in ("n", "no")
|
||||
|
||||
shouldRemoveUser = False
|
||||
cleanupDone = False
|
||||
tempDir = tempfile.mkdtemp(prefix="sas-ii-")
|
||||
ircSession = None
|
||||
sshProcess = None
|
||||
|
||||
def cleanup(exitMessage=None):
|
||||
nonlocal cleanupDone
|
||||
if cleanupDone:
|
||||
return
|
||||
cleanupDone = True
|
||||
nonlocal sshProcess
|
||||
if exitMessage:
|
||||
say_or_print(exitMessage, useSpeech)
|
||||
|
||||
if sshProcess and sshProcess.poll() is None:
|
||||
sshProcess.terminate()
|
||||
try:
|
||||
sshProcess.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
sshProcess.kill()
|
||||
|
||||
if ircSession:
|
||||
ircSession.stop()
|
||||
|
||||
if shouldRemoveUser:
|
||||
try:
|
||||
run_privileged(["pkill", "-u", sasUser], useSpeech, check=False)
|
||||
time.sleep(1)
|
||||
result = run_privileged(["userdel", "-r", sasUser], useSpeech, check=False)
|
||||
run_privileged(["rm", "-rf", f"/home/{sasUser}"], useSpeech, check=False)
|
||||
if result.returncode != 0 and user_exists(sasUser):
|
||||
say_or_print(
|
||||
"Cleanup warning: failed to remove sas user. Please remove it manually.",
|
||||
useSpeech,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sudoKeepaliveStop.set()
|
||||
if sudoKeepaliveThread:
|
||||
sudoKeepaliveThread.join(timeout=2)
|
||||
|
||||
try:
|
||||
remove_tree(tempDir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def handle_signal(signum, frame):
|
||||
cleanup("Interrupted. Cleaning up.")
|
||||
sys.exit(1)
|
||||
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
|
||||
try:
|
||||
if not user_exists(sasUser):
|
||||
run_privileged(
|
||||
["useradd", "-m", "-d", f"/home/{sasUser}", "-s", "/bin/bash", "-G", "wheel", sasUser],
|
||||
useSpeech,
|
||||
)
|
||||
shouldRemoveUser = True
|
||||
else:
|
||||
say_or_print(
|
||||
"User 'sas' exists. Remove and recreate it? This will delete /home/sas. (y/n)",
|
||||
useSpeech,
|
||||
)
|
||||
response = input().strip().lower()
|
||||
if response not in ("y", "yes"):
|
||||
cleanup("The sas user is unavailable. Remove it manually and try again.")
|
||||
return 1
|
||||
run_privileged(["pkill", "-u", sasUser], useSpeech, check=False)
|
||||
run_privileged(["userdel", "-r", sasUser], useSpeech, check=False)
|
||||
run_privileged(["rm", "-rf", f"/home/{sasUser}"], useSpeech, check=False)
|
||||
run_privileged(
|
||||
["useradd", "-m", "-d", f"/home/{sasUser}", "-s", "/bin/bash", "-G", "wheel", sasUser],
|
||||
useSpeech,
|
||||
)
|
||||
shouldRemoveUser = True
|
||||
|
||||
ensure_wheel(sasUser, useSpeech)
|
||||
|
||||
password = generate_password()
|
||||
set_password(sasUser, password, useSpeech)
|
||||
|
||||
privateKeyPath, publicKeyPath = generate_ssh_key(sasUser, useSpeech)
|
||||
sasHome = get_user_home(sasUser)
|
||||
knownHostsPath = os.path.join(sasHome, ".ssh", "known_hosts_sas")
|
||||
run_privileged(["touch", knownHostsPath], useSpeech)
|
||||
run_privileged(["chmod", "600", knownHostsPath], useSpeech)
|
||||
run_privileged(["chown", f"{sasUser}:{sasUser}", knownHostsPath], useSpeech)
|
||||
|
||||
nick = build_nick()
|
||||
ircSession = IrcSession(ircServer, ircPort, nick, ircChannel, tempDir)
|
||||
ircSession.start()
|
||||
ircSession.join_channel()
|
||||
|
||||
say_or_print("Waiting for assistance on IRC.", useSpeech)
|
||||
startTime = time.monotonic()
|
||||
nextPingTime = startTime
|
||||
pingsSent = 0
|
||||
confirmedAdmin = None
|
||||
|
||||
while time.monotonic() - startTime < pingIntervalSeconds * pingCount:
|
||||
now = time.monotonic()
|
||||
if pingsSent < pingCount and now >= nextPingTime:
|
||||
ircSession.send_channel_message(f"{nick} is requesting assistance.")
|
||||
pingsSent += 1
|
||||
nextPingTime = startTime + (pingsSent * pingIntervalSeconds)
|
||||
|
||||
for adminNick, messageText in ircSession.get_private_messages(stormuxAdmin):
|
||||
if messageText.strip().lower() in ("yes", "accept"):
|
||||
confirmedAdmin = adminNick
|
||||
break
|
||||
if confirmedAdmin:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
if not confirmedAdmin:
|
||||
cleanup("No one was available to help, please try again later.")
|
||||
return 1
|
||||
|
||||
ircSession.send_private_message(
|
||||
confirmedAdmin,
|
||||
f'password: "{password}" please send wormhole ssh invite code',
|
||||
)
|
||||
|
||||
failures = 0
|
||||
while failures < maxWormholeFailures:
|
||||
inviteCode = None
|
||||
while inviteCode is None:
|
||||
for adminNick, messageText in ircSession.get_private_messages(stormuxAdmin):
|
||||
inviteCode = find_wormhole_code(messageText)
|
||||
if inviteCode:
|
||||
break
|
||||
if inviteCode:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
if not path_exists_for_user(publicKeyPath, sasUser, useSpeech):
|
||||
raise RuntimeError(f"Public key missing: {publicKeyPath}")
|
||||
|
||||
wormholeCommand = [
|
||||
"wormhole",
|
||||
"ssh",
|
||||
"accept",
|
||||
"--yes",
|
||||
inviteCode,
|
||||
]
|
||||
result = run_as_user(sasUser, wormholeCommand, useSpeech, check=False)
|
||||
if result.returncode == 0:
|
||||
say_or_print("Wormhole key transfer succeeded.", useSpeech)
|
||||
break
|
||||
|
||||
failures += 1
|
||||
errorTextFull = (result.stderr or result.stdout or "").strip()
|
||||
if errorTextFull and not useSpeech:
|
||||
print("Wormhole ssh accept error:", flush=True)
|
||||
print(errorTextFull, flush=True)
|
||||
errorText = errorTextFull
|
||||
if errorText:
|
||||
errorText = " ".join(errorText.split())
|
||||
if len(errorText) > 400:
|
||||
errorText = errorText[:400] + "..."
|
||||
ircSession.send_private_message(
|
||||
confirmedAdmin,
|
||||
f"Wormhole ssh accept failed: {errorText}",
|
||||
)
|
||||
ircSession.send_private_message(
|
||||
confirmedAdmin,
|
||||
"Wormhole ssh accept failed. Please send a new invite code.",
|
||||
)
|
||||
|
||||
if failures >= maxWormholeFailures:
|
||||
cleanup("Wormhole failed too many times. Exiting.")
|
||||
return 1
|
||||
|
||||
say_or_print("Starting reverse SSH tunnel. Press Ctrl+C to stop.", useSpeech)
|
||||
sshCommand = [
|
||||
"ssh",
|
||||
"-N",
|
||||
"-R",
|
||||
"localhost:2232:localhost:22",
|
||||
"-o",
|
||||
"ExitOnForwardFailure=yes",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ServerAliveInterval=30",
|
||||
"-o",
|
||||
"ServerAliveCountMax=3",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"-o",
|
||||
f"UserKnownHostsFile={knownHostsPath}",
|
||||
"-i",
|
||||
privateKeyPath,
|
||||
f"{sasUser}@{remoteHost}",
|
||||
]
|
||||
sshCommand = ["sudo", "-u", sasUser, "-H"] + sshCommand
|
||||
sshProcess = subprocess.Popen(sshCommand)
|
||||
sshProcess.wait()
|
||||
|
||||
except Exception as exc:
|
||||
cleanup(f"Error: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
cleanup()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def remove_tree(path):
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
for rootDir, dirNames, fileNames in os.walk(path, topdown=False):
|
||||
for fileName in fileNames:
|
||||
try:
|
||||
os.unlink(os.path.join(rootDir, fileName))
|
||||
except OSError:
|
||||
pass
|
||||
for dirName in dirNames:
|
||||
try:
|
||||
os.rmdir(os.path.join(rootDir, dirName))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
# ---- Configurable Variables ----
|
||||
repoDir="/var/www/packages.stormux.org"
|
||||
keyId="52ADA49000F1FF0456F8AEEFB4CDE1CD56EF8E82"
|
||||
repoName="stormux"
|
||||
dbName="${repoName}.db.tar.gz"
|
||||
filesName="${repoName}.files.tar.gz"
|
||||
rebuildDb="${REBUILD_DB:-false}"
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
echo "❌ Required command not found: $cmd"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- Safety Checks ----
|
||||
if [[ ! -d "$repoDir" ]]; then
|
||||
echo "❌ Repo dir does not exist: $repoDir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_cmd "gpg"
|
||||
require_cmd "repo-add"
|
||||
require_cmd "repo-remove"
|
||||
|
||||
# ---- Create Architecture Directories ----
|
||||
mkdir -p "$repoDir/x86_64" "$repoDir/aarch64"
|
||||
|
||||
# ---- Process Each Architecture ----
|
||||
process_arch() {
|
||||
local arch="$1"
|
||||
local archDir="$repoDir/$arch"
|
||||
local pkgFiles=()
|
||||
local selectedPkgFiles=()
|
||||
local repoAddArgs=()
|
||||
local -A currentPkgNames=()
|
||||
local -A newestPkgByName=()
|
||||
local repoPkgNames=()
|
||||
local dbFile="$dbName"
|
||||
local filesFile="$filesName"
|
||||
local dbSig="${dbFile}.sig"
|
||||
local filesSig="${filesFile}.sig"
|
||||
local dbLink="${repoName}.db"
|
||||
local filesLink="${repoName}.files"
|
||||
|
||||
echo "🏗️ Processing $arch packages..."
|
||||
|
||||
# Enter arch directory (packages should already be sorted by update.sh)
|
||||
cd "$archDir" || return 1
|
||||
|
||||
# Select only the newest archive for each package name. repo-add cannot
|
||||
# safely consume multiple versions of the same package in a single run.
|
||||
pkgFiles=( *.pkg.tar.zst *.pkg.tar.xz )
|
||||
for pkg in "${pkgFiles[@]}"; do
|
||||
local pkgName pkgVersion existingPkg existingVersion
|
||||
pkgName="$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p' | head -n1)"
|
||||
pkgVersion="$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgver = //p' | head -n1)-$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgrel = //p' | head -n1)"
|
||||
|
||||
if [[ -z "$pkgName" || -z "$pkgVersion" ]]; then
|
||||
echo "❌ Unable to determine package metadata for $pkg"
|
||||
cd "$repoDir" || exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
existingPkg="${currentPkgNames[$pkgName]:-}"
|
||||
if [[ -z "$existingPkg" ]]; then
|
||||
currentPkgNames["$pkgName"]="$pkg"
|
||||
else
|
||||
existingVersion="$(bsdtar -xOqf "$existingPkg" .PKGINFO | sed -n 's/^pkgver = //p' | head -n1)-$(bsdtar -xOqf "$existingPkg" .PKGINFO | sed -n 's/^pkgrel = //p' | head -n1)"
|
||||
if (( $(vercmp "$pkgVersion" "$existingVersion") > 0 )); then
|
||||
currentPkgNames["$pkgName"]="$pkg"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
for pkgName in "${!currentPkgNames[@]}"; do
|
||||
newestPkgByName["$pkgName"]="${currentPkgNames[$pkgName]}"
|
||||
selectedPkgFiles+=( "${currentPkgNames[$pkgName]}" )
|
||||
done
|
||||
|
||||
pkgFiles=( "${selectedPkgFiles[@]}" )
|
||||
|
||||
# Sign all unsigned selected packages
|
||||
echo "🔏 Signing $arch packages..."
|
||||
for pkg in "${pkgFiles[@]}"; do
|
||||
if [[ ! -f "$pkg.sig" ]]; then
|
||||
echo " 📝 Signing $pkg"
|
||||
gpg --default-key "$keyId" --detach-sign "$pkg"
|
||||
else
|
||||
echo " ✅ $pkg already signed"
|
||||
fi
|
||||
done
|
||||
|
||||
# Track which package names should remain in the repo after this run.
|
||||
for pkg in "${pkgFiles[@]}"; do
|
||||
local pkgName
|
||||
pkgName="$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p' | head -n1)"
|
||||
if [[ -n "$pkgName" ]]; then
|
||||
currentPkgNames["$pkgName"]=1
|
||||
else
|
||||
echo "❌ Unable to determine package name for $pkg"
|
||||
cd "$repoDir" || exit 1
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Rebuild database for this architecture
|
||||
if [[ "$rebuildDb" == "true" ]]; then
|
||||
echo "🗃️ Rebuilding $arch repo database..."
|
||||
rm -f "$dbFile" "$dbSig" "$filesFile" "$filesSig" "$dbLink" "$filesLink"
|
||||
elif [[ -f "$dbFile" ]]; then
|
||||
echo "🗃️ Updating $arch repo database..."
|
||||
else
|
||||
echo "🆕 Creating new $arch repo database..."
|
||||
fi
|
||||
|
||||
# Remove packages that still exist in the repo database but are no longer
|
||||
# present in the current directory. repo-remove with --remove also deletes
|
||||
# the matching package archive and detached signature from disk.
|
||||
if [[ -f "$dbFile" ]]; then
|
||||
mapfile -t repoPkgNames < <(
|
||||
bsdtar -tf "$dbFile" |
|
||||
awk -F/ 'NF == 2 && $2 == "desc" {print $1}' |
|
||||
while read -r entry; do
|
||||
bsdtar -xOf "$dbFile" "${entry}/desc" |
|
||||
awk 'found {print; exit} /^%NAME%$/ {found=1}'
|
||||
done |
|
||||
sort -u
|
||||
)
|
||||
|
||||
for pkgName in "${repoPkgNames[@]}"; do
|
||||
if [[ -z "${currentPkgNames[$pkgName]:-}" ]]; then
|
||||
echo "🧹 Removing stale repo package $pkgName"
|
||||
repo-remove --sign --key "$keyId" --remove "$dbFile" "$pkgName"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Only run repo-add if there are packages
|
||||
if ((${#pkgFiles[@]} > 0)); then
|
||||
repoAddArgs=(--sign --key "$keyId" --remove)
|
||||
if [[ "$rebuildDb" != "true" && -f "$dbFile" ]]; then
|
||||
repoAddArgs+=(--verify)
|
||||
fi
|
||||
|
||||
repo-add "${repoAddArgs[@]}" "$dbFile" "${pkgFiles[@]}"
|
||||
|
||||
if [[ ! -f "$dbFile" ]]; then
|
||||
echo "❌ repo-add did not create $dbFile"
|
||||
cd "$repoDir" || exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -e "$dbLink" ]]; then
|
||||
ln -s "$dbFile" "$dbLink"
|
||||
fi
|
||||
|
||||
if [[ -f "$filesFile" && ! -e "$filesLink" ]]; then
|
||||
ln -s "$filesFile" "$filesLink"
|
||||
fi
|
||||
|
||||
echo "✅ $arch repo updated successfully"
|
||||
else
|
||||
echo "ℹ️ No $arch packages found"
|
||||
fi
|
||||
|
||||
# Remove orphaned package archives and detached signatures that are not part
|
||||
# of the current package set. This keeps the on-disk repo contents aligned
|
||||
# with the package database after rebuilds and package removals.
|
||||
for pkg in *.pkg.tar.zst *.pkg.tar.xz; do
|
||||
local pkgName
|
||||
pkgName="$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p' | head -n1)"
|
||||
if [[ -z "${newestPkgByName[$pkgName]:-}" || "${newestPkgByName[$pkgName]}" != "$pkg" ]]; then
|
||||
echo "🧹 Removing orphaned package file $pkg"
|
||||
rm -f "$pkg" "$pkg.sig"
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$repoDir" || exit
|
||||
}
|
||||
|
||||
# ---- Process Both Architectures ----
|
||||
process_arch "x86_64"
|
||||
process_arch "aarch64"
|
||||
|
||||
# ---- Done ----
|
||||
echo "✅ All repositories updated and signed successfully."
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,23 @@
|
||||
pkgbase = xlibre-video-dummy-with-vt
|
||||
pkgdesc = XLibre dummy video driver with an allocated vt
|
||||
pkgver = 25.0.0
|
||||
pkgrel = 5
|
||||
url = https://github.com/X11Libre/xf86-video-dummy
|
||||
arch = x86_64
|
||||
arch = aarch64
|
||||
groups = xlibre-drivers
|
||||
license = MIT
|
||||
license = X11
|
||||
makedepends = xlibre-xserver-devel>=25.0
|
||||
makedepends = xorgproto
|
||||
depends = xlibre-xserver>=25.0
|
||||
depends = glibc
|
||||
provides = xf86-video-dummy
|
||||
provides = x11win-video-dummy
|
||||
conflicts = xf86-video-dummy
|
||||
source = https://github.com/X11Libre/xf86-video-dummy/archive/refs/tags/xlibre-xf86-video-dummy-25.0.0.tar.gz
|
||||
source = dummy_driver.patch
|
||||
sha256sums = b56e610705cd3d4d86422a11c6b0d93357e4d4749a05178a85fd250301d357b9
|
||||
sha256sums = 68cdcf21e9b54a7fdb8e968292e1ef9ad154ddb1361b141a0a635c2a13c92bfa
|
||||
|
||||
pkgname = xlibre-video-dummy-with-vt
|
||||
@@ -0,0 +1,5 @@
|
||||
*
|
||||
!PKGBUILD
|
||||
!.SRCINFO
|
||||
!.gitignore
|
||||
!.nvchecker.toml
|
||||
@@ -0,0 +1,5 @@
|
||||
[xlibre-video-dummy]
|
||||
source = "git"
|
||||
git = "https://github.com/x11libre/xf86-video-dummy.git"
|
||||
include_regex = "xlibre-xf86-video-dummy-.*"
|
||||
prefix = "xlibre-xf86-video-dummy-"
|
||||
@@ -0,0 +1,59 @@
|
||||
# Maintainer: Storm Dragon <storm_dragon@linux-a11y.org>
|
||||
|
||||
pkgname=xlibre-video-dummy-with-vt
|
||||
pkgver=25.0.0
|
||||
pkgrel=5
|
||||
pkgdesc="XLibre dummy video driver with an allocated vt"
|
||||
arch=(x86_64 aarch64)
|
||||
_pkgname=xf86-video-dummy
|
||||
url="https://github.com/X11Libre/${_pkgname}"
|
||||
license=('MIT' 'X11')
|
||||
depends=("xlibre-xserver>=${pkgver%.*}" 'glibc')
|
||||
makedepends=("xlibre-xserver-devel>=${pkgver%.*}" 'xorgproto')
|
||||
conflicts=("${_pkgname}")
|
||||
provides=("${_pkgname}" 'x11win-video-dummy')
|
||||
source=("${url}/archive/refs/tags/xlibre-${_pkgname}-${pkgver}.tar.gz"
|
||||
"dummy_driver.patch")
|
||||
groups=('xlibre-drivers')
|
||||
sha256sums=('b56e610705cd3d4d86422a11c6b0d93357e4d4749a05178a85fd250301d357b9'
|
||||
'68cdcf21e9b54a7fdb8e968292e1ef9ad154ddb1361b141a0a635c2a13c92bfa')
|
||||
|
||||
prepare() {
|
||||
cd "${srcdir}/${_pkgname}-xlibre-${_pkgname}-${pkgver}/src"
|
||||
patch -i "${srcdir}/dummy_driver.patch"
|
||||
}
|
||||
|
||||
build() {
|
||||
case "$CARCH" in
|
||||
"x86_64")
|
||||
CFLAGS=" -march=x86-64"
|
||||
;;
|
||||
"aarch64")
|
||||
CFLAGS=" -march=armv8-a"
|
||||
;;
|
||||
*)
|
||||
CFLAGS=" -march=native"
|
||||
;;
|
||||
esac
|
||||
CFLAGS+=" -mtune=generic -O2 -pipe -fexceptions -Wp,-D_FORTIFY_SOURCE=3 -Wformat -Werror=format-security"
|
||||
CFLAGS+=" -fstack-clash-protection -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer"
|
||||
LDFLAGS=" -Wl,-O1 -Wl,--sort-common -Wl,--as-needed -Wl,-z,lazy -Wl,-z,relro -Wl,-z,pack-relative-relocs"
|
||||
if [[ $CARCH != 'aarch64' ]]; then
|
||||
CFLAGS+=" -fcf-protection"
|
||||
fi
|
||||
CXXFLAGS="${CFLAGS} -Wp,-D_GLIBCXX_ASSERTIONS"
|
||||
export CFLAGS="${CFLAGS}"
|
||||
export CXXFLAGS="${CXXFLAGS}"
|
||||
export LDFLAGS="${LDFLAGS}"
|
||||
|
||||
cd "${srcdir}/${_pkgname}-xlibre-${_pkgname}-${pkgver}"
|
||||
./autogen.sh
|
||||
./configure --prefix=/usr
|
||||
make
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${_pkgname}-xlibre-${_pkgname}-${pkgver}"
|
||||
make DESTDIR="${pkgdir}" install
|
||||
install -Dm644 COPYING "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
--- a/src/dummy_driver.c
|
||||
+++ b/src/dummy_driver.c
|
||||
@@ -1016,10 +1016,10 @@ dummyDriverFunc(ScrnInfoPtr pScrn, xorgDriverFuncOp op, pointer ptr)
|
||||
CARD32 *flag;
|
||||
|
||||
switch (op) {
|
||||
- case GET_REQUIRED_HW_INTERFACES:
|
||||
- flag = (CARD32*)ptr;
|
||||
- (*flag) = HW_SKIP_CONSOLE;
|
||||
- return TRUE;
|
||||
+ case GET_REQUIRED_HW_INTERFACES:
|
||||
+ flag = (CARD32*)ptr;
|
||||
+ /* Allow the driver to allocate a VT instead of skipping the console. */
|
||||
+ return TRUE;
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
SUBSYSTEM=="tty", KERNEL=="tty[0-9]*|hvc[0-9]*|sclp_line[0-9]*|ttysclp[0-9]*|3270/tty[0-9]*", GROUP="tty", MODE="0620"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,258 +1,582 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stormux Assistance System (SAS) - Client
|
||||
# Simple command for users to request remote assistance
|
||||
# Usage: sas
|
||||
#!/usr/bin/env python3
|
||||
|
||||
set -euo pipefail
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import signal
|
||||
import string
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import pwd
|
||||
import threading
|
||||
|
||||
# Configuration
|
||||
serverHost="assistance.stormux.org"
|
||||
serverPort=22
|
||||
serverUser="stormux-assist"
|
||||
tunnelPort=2222
|
||||
configFile="/etc/stormux-assist/client.conf"
|
||||
logFile="/var/log/sas.log"
|
||||
logDir="${HOME}/stormux-assist-logs"
|
||||
sessionTimeout=14400 # 4 hours
|
||||
|
||||
# Session variables
|
||||
sessionId=""
|
||||
tunnelPid=""
|
||||
stormuxAdmin = ("storm",)
|
||||
ircServer = "irc.stormux.org"
|
||||
ircPort = 6667
|
||||
ircChannel = "#stormux"
|
||||
remoteHost = "billysballoons.com"
|
||||
sasUser = "sas"
|
||||
pingIntervalSeconds = 180
|
||||
pingCount = 5
|
||||
maxWormholeFailures = 3
|
||||
|
||||
# Speech feedback function
|
||||
speak() {
|
||||
spd-say -w "$1" 2>/dev/null || true
|
||||
}
|
||||
sudoKeepaliveThread = None
|
||||
sudoKeepaliveStop = threading.Event()
|
||||
|
||||
# Logging function (format: "Message [timestamp]")
|
||||
logMessage() {
|
||||
local message="$1"
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "${message} [${timestamp}]" >> "${logFile}"
|
||||
}
|
||||
|
||||
# Error handler with speech
|
||||
errorExit() {
|
||||
local message="$1"
|
||||
speak "Error: ${message}"
|
||||
logMessage "ERROR: ${message}"
|
||||
cleanup
|
||||
exit 1
|
||||
}
|
||||
def speak_message(message):
|
||||
try:
|
||||
subprocess.run(["spd-say", message], check=False)
|
||||
except FileNotFoundError:
|
||||
print(message, flush=True)
|
||||
|
||||
# Load configuration if it exists
|
||||
loadConfig() {
|
||||
if [[ -f "${configFile}" ]]; then
|
||||
# Source config file values (simple INI parsing)
|
||||
while IFS='=' read -r key value; do
|
||||
# Skip comments and empty lines
|
||||
[[ "${key}" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ -z "${key}" ]] && continue
|
||||
|
||||
# Trim whitespace
|
||||
key=$(echo "${key}" | xargs)
|
||||
value=$(echo "${value}" | xargs)
|
||||
def say_or_print(message, useSpeech):
|
||||
if useSpeech:
|
||||
speak_message(message)
|
||||
else:
|
||||
print(message, flush=True)
|
||||
|
||||
case "${key}" in
|
||||
host) serverHost="${value}" ;;
|
||||
ssh_port) serverPort="${value}" ;;
|
||||
ssh_user) serverUser="${value}" ;;
|
||||
tunnel_port) tunnelPort="${value}" ;;
|
||||
timeout) sessionTimeout="${value}" ;;
|
||||
log_file) logFile="${value}" ;;
|
||||
log_dir) logDir="${value}" ;;
|
||||
esac
|
||||
done < "${configFile}"
|
||||
logMessage "Configuration loaded from ${configFile}"
|
||||
else
|
||||
logMessage "No config file found, using defaults"
|
||||
fi
|
||||
}
|
||||
|
||||
# Check network connectivity
|
||||
checkNetwork() {
|
||||
speak "Checking network connection"
|
||||
logMessage "Checking network connectivity to ${serverHost}"
|
||||
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,
|
||||
)
|
||||
|
||||
if ! ping -c 1 -W 5 "${serverHost}" &>/dev/null; then
|
||||
errorExit "No network connection detected. Please connect to the internet and try again."
|
||||
fi
|
||||
|
||||
logMessage "Network connectivity verified"
|
||||
}
|
||||
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
|
||||
|
||||
# Check SSH client is installed
|
||||
checkSsh() {
|
||||
if ! command -v ssh &>/dev/null; then
|
||||
errorExit "SSH client not found. Please install openssh."
|
||||
fi
|
||||
|
||||
if ! command -v autossh &>/dev/null; then
|
||||
errorExit "autossh not found. Please install autossh package."
|
||||
fi
|
||||
def start_sudo_keepalive():
|
||||
global sudoKeepaliveThread
|
||||
if sudoKeepaliveThread and sudoKeepaliveThread.is_alive():
|
||||
return
|
||||
|
||||
logMessage "SSH and autossh verified"
|
||||
}
|
||||
def keepalive_loop():
|
||||
while not sudoKeepaliveStop.wait(240):
|
||||
run_command(["sudo", "-n", "-v"])
|
||||
|
||||
# Create log directory
|
||||
createLogDir() {
|
||||
if [[ ! -d "${logDir}" ]]; then
|
||||
mkdir -p "${logDir}" || errorExit "Failed to create log directory ${logDir}"
|
||||
logMessage "Created log directory ${logDir}"
|
||||
fi
|
||||
}
|
||||
sudoKeepaliveThread = threading.Thread(target=keepalive_loop, daemon=True)
|
||||
sudoKeepaliveThread.start()
|
||||
|
||||
# Generate session ID
|
||||
generateSessionId() {
|
||||
sessionId=$(date '+%Y%m%d-%H%M%S')-$(hostname -s)
|
||||
logMessage "Generated session ID: ${sessionId}"
|
||||
}
|
||||
|
||||
# Establish SSH reverse tunnel
|
||||
establishTunnel() {
|
||||
speak "Establishing connection to assistance server"
|
||||
logMessage "Establishing SSH reverse tunnel to ${serverHost}:${serverPort}"
|
||||
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)
|
||||
|
||||
# Use autossh for auto-reconnection
|
||||
# -M 0: disable autossh monitoring port (use ServerAliveInterval instead)
|
||||
# -N: no remote command
|
||||
# -R 2222:localhost:22: reverse tunnel from server port 2222 to local port 22
|
||||
# ServerAliveInterval: keep connection alive
|
||||
# ServerAliveCountMax: max failed keepalives before disconnect
|
||||
# ExitOnForwardFailure: exit if tunnel cannot be established
|
||||
|
||||
autossh -M 0 \
|
||||
-o "ServerAliveInterval=30" \
|
||||
-o "ServerAliveCountMax=3" \
|
||||
-o "ExitOnForwardFailure=yes" \
|
||||
-o "StrictHostKeyChecking=accept-new" \
|
||||
-N \
|
||||
-R "${tunnelPort}:localhost:22" \
|
||||
-p "${serverPort}" \
|
||||
"${serverUser}@${serverHost}" &
|
||||
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)
|
||||
|
||||
tunnelPid=$!
|
||||
|
||||
# Wait a moment for tunnel to establish
|
||||
sleep 3
|
||||
def user_exists(userName):
|
||||
result = run_command(["getent", "passwd", userName])
|
||||
return result.returncode == 0
|
||||
|
||||
# Check if tunnel is still running
|
||||
if ! kill -0 "${tunnelPid}" 2>/dev/null; then
|
||||
errorExit "Failed to establish SSH tunnel. Please check your SSH keys and server accessibility."
|
||||
fi
|
||||
|
||||
logMessage "SSH tunnel established (PID: ${tunnelPid})"
|
||||
}
|
||||
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)
|
||||
|
||||
# Wait for user to quit or timeout
|
||||
waitForQuit() {
|
||||
speak "Connection established. Support staff have been notified via IRC. Press Q to quit or wait for support to connect."
|
||||
logMessage "Session active, waiting for Q keypress or timeout"
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo " Stormux Assistance System - Session Active"
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "Support staff have been notified via IRC."
|
||||
echo "They will connect shortly to help you."
|
||||
echo ""
|
||||
echo "Session ID: ${sessionId}"
|
||||
echo "Session timeout: 4 hours"
|
||||
echo ""
|
||||
echo "Press 'Q' to end the session early"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo ""
|
||||
def generate_password():
|
||||
allowedChars = string.ascii_letters + string.digits
|
||||
length = secrets.randbelow(5) + 6
|
||||
return "".join(secrets.choice(allowedChars) for _ in range(length))
|
||||
|
||||
local startTime
|
||||
startTime=$(date +%s)
|
||||
|
||||
while true; do
|
||||
# Check for timeout
|
||||
local currentTime
|
||||
currentTime=$(date +%s)
|
||||
local elapsed=$((currentTime - startTime))
|
||||
def get_user_home(userName):
|
||||
return pwd.getpwnam(userName).pw_dir
|
||||
|
||||
if [[ ${elapsed} -ge ${sessionTimeout} ]]; then
|
||||
speak "Session has timed out after 4 hours"
|
||||
logMessage "Session timed out after ${sessionTimeout} seconds"
|
||||
break
|
||||
fi
|
||||
|
||||
# Check for Q keypress (with timeout)
|
||||
if read -r -t 1 -n 1 key; then
|
||||
if [[ "${key}" == "q" ]] || [[ "${key}" == "Q" ]]; then
|
||||
speak "Ending assistance session"
|
||||
logMessage "User requested session termination"
|
||||
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
|
||||
fi
|
||||
fi
|
||||
time.sleep(1)
|
||||
|
||||
# Check if tunnel is still alive
|
||||
if ! kill -0 "${tunnelPid}" 2>/dev/null; then
|
||||
speak "Connection lost. Session ended."
|
||||
logMessage "Tunnel process died unexpectedly"
|
||||
break
|
||||
fi
|
||||
done
|
||||
}
|
||||
if not confirmedAdmin:
|
||||
cleanup("No one was available to help, please try again later.")
|
||||
return 1
|
||||
|
||||
# Cleanup and exit
|
||||
cleanup() {
|
||||
logMessage "Starting cleanup"
|
||||
ircSession.send_private_message(
|
||||
confirmedAdmin,
|
||||
f'password: "{password}" please send wormhole ssh invite code',
|
||||
)
|
||||
|
||||
# Kill tunnel if running
|
||||
if [[ -n "${tunnelPid}" ]] && kill -0 "${tunnelPid}" 2>/dev/null; then
|
||||
kill "${tunnelPid}" 2>/dev/null || true
|
||||
logMessage "Tunnel process terminated"
|
||||
fi
|
||||
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)
|
||||
|
||||
logMessage "Cleanup complete"
|
||||
}
|
||||
if not path_exists_for_user(publicKeyPath, sasUser, useSpeech):
|
||||
raise RuntimeError(f"Public key missing: {publicKeyPath}")
|
||||
|
||||
# End-of-session patronage message
|
||||
patronageMessage() {
|
||||
speak "Assistance session ended. Thank you for using Stormux Live Assistance."
|
||||
sleep 2
|
||||
speak "This service is made possible by supporters like you. Consider becoming a patron at patreon dot com slash stormux to help keep this service running."
|
||||
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
|
||||
|
||||
logMessage "Session ended, patronage message delivered"
|
||||
}
|
||||
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.",
|
||||
)
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
speak "Starting Stormux assistance request"
|
||||
logMessage "=== SAS Client Started ==="
|
||||
if failures >= maxWormholeFailures:
|
||||
cleanup("Wormhole failed too many times. Exiting.")
|
||||
return 1
|
||||
|
||||
# Ensure we clean up on exit
|
||||
trap cleanup EXIT INT TERM
|
||||
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()
|
||||
|
||||
# Load configuration
|
||||
loadConfig
|
||||
except Exception as exc:
|
||||
cleanup(f"Error: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
cleanup()
|
||||
|
||||
# Create log directory
|
||||
createLogDir
|
||||
return 0
|
||||
|
||||
# Pre-flight checks
|
||||
checkNetwork
|
||||
checkSsh
|
||||
|
||||
# Generate session ID
|
||||
generateSessionId
|
||||
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
|
||||
|
||||
# Establish tunnel
|
||||
establishTunnel
|
||||
|
||||
# Wait for quit or timeout
|
||||
waitForQuit
|
||||
|
||||
# End session
|
||||
patronageMessage
|
||||
|
||||
logMessage "=== SAS Client Ended ==="
|
||||
}
|
||||
|
||||
# Run main
|
||||
main "$@"
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
+22
-1
@@ -67,7 +67,6 @@ done
|
||||
|
||||
# Get the directory where this script is located
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
pi4_files_dir="$script_dir/../pi4/files"
|
||||
|
||||
echo "Building Stormux x86_64 ISO..."
|
||||
echo "Profile directory: $script_dir"
|
||||
@@ -108,6 +107,28 @@ fi
|
||||
# Build the ISO
|
||||
mkarchiso -v -w "$work_dir" -o "$output_dir" "$script_dir"
|
||||
|
||||
# Rename ISO to stormux-x86_64-YYYY-MM-DD.iso
|
||||
dateStamp="$(date --date="@${SOURCE_DATE_EPOCH:-$(date +%s)}" +%Y-%m-%d)"
|
||||
pushd "$output_dir" > /dev/null
|
||||
shopt -s nullglob
|
||||
isoFiles=( *.iso )
|
||||
shopt -u nullglob
|
||||
|
||||
if [[ ${#isoFiles[@]} -eq 1 && -f "${isoFiles[0]}" ]]; then
|
||||
desiredIso="stormux-x86_64-${dateStamp}.iso"
|
||||
if [[ "${isoFiles[0]}" != "$desiredIso" ]]; then
|
||||
if [[ -e "$desiredIso" ]]; then
|
||||
echo "Warning: $desiredIso already exists; skipping rename."
|
||||
else
|
||||
mv "${isoFiles[0]}" "$desiredIso"
|
||||
echo "Renamed ISO to: $desiredIso"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Warning: expected one ISO in output, found ${#isoFiles[@]}; skipping rename."
|
||||
fi
|
||||
popd > /dev/null
|
||||
|
||||
# Generate sha1sum for the ISO
|
||||
echo
|
||||
echo "Generating sha1sum..."
|
||||
|
||||
@@ -45,6 +45,7 @@ gptfdisk
|
||||
grub
|
||||
hdparm
|
||||
hyperv
|
||||
ii
|
||||
intel-ucode
|
||||
irssi
|
||||
iw
|
||||
|
||||
@@ -19,6 +19,7 @@ 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"
|
||||
@@ -34,5 +35,6 @@ file_permissions=(
|
||||
["/usr/share/fenrirscreenreader/scripts/ssh-login-monitor.sh"]="0:0:755"
|
||||
["/etc/stormux-assist"]="0:0:755"
|
||||
["/etc/stormux-assist/client.conf"]="0:0:644"
|
||||
["/etc/udev/rules.d/99-brltty.rules"]="0:0:644"
|
||||
["/home/stormux"]="1000:1000:755"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user