added sas to both images. A few minor updates to image generation for pi images.
This commit is contained in:
@@ -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
|
|
||||||
+34
-17
@@ -24,13 +24,6 @@ mounted=1
|
|||||||
set -e # Don't want to destroy stuff if this goes majorly wrong.
|
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.
|
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
|
# shellcheck disable=SC2329 # compress_image is invoked from finish_build
|
||||||
compress_image() {
|
compress_image() {
|
||||||
local compressedImage="${imageName}.xz"
|
local compressedImage="${imageName}.xz"
|
||||||
@@ -50,16 +43,11 @@ compress_image() {
|
|||||||
|
|
||||||
# shellcheck disable=SC2329 # cleanup_image is invoked from cleanup and finish_build
|
# shellcheck disable=SC2329 # cleanup_image is invoked from cleanup and finish_build
|
||||||
cleanup_image() {
|
cleanup_image() {
|
||||||
local verifyFilesystems="${1:-false}"
|
|
||||||
local cleanupStatus=0
|
local cleanupStatus=0
|
||||||
|
|
||||||
if [[ $mounted -eq 0 ]]; then
|
if [[ $mounted -eq 0 ]]; then
|
||||||
if ! umount -R /mnt; then
|
if ! umount -R /mnt; then
|
||||||
cleanupStatus=1
|
cleanupStatus=1
|
||||||
elif [[ "$verifyFilesystems" == true ]]; then
|
|
||||||
if ! verify_image; then
|
|
||||||
cleanupStatus=1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -85,12 +73,12 @@ cleanup() {
|
|||||||
local status=$? # capture original exit status so failures propagate
|
local status=$? # capture original exit status so failures propagate
|
||||||
local cleanupStatus=0
|
local cleanupStatus=0
|
||||||
|
|
||||||
if ! cleanup_image false; then
|
if ! cleanup_image; then
|
||||||
cleanupStatus=1
|
cleanupStatus=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $status -eq 0 && $cleanupStatus -ne 0 ]]; then
|
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
|
status=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -100,8 +88,8 @@ cleanup() {
|
|||||||
finish_build() {
|
finish_build() {
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
|
|
||||||
if ! cleanup_image true; then
|
if ! cleanup_image; then
|
||||||
echo "Image build commands completed, but cleanup or filesystem verification failed."
|
echo "Image build commands completed, but cleanup failed."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -112,6 +100,30 @@ finish_build() {
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# shellcheck disable=SC2329 # install_sas is emitted into the chroot heredoc with declare -f
|
||||||
|
install_sas() {
|
||||||
|
local sasRepo="https://git.stormux.org/storm/sas"
|
||||||
|
local sasPath="/usr/local/bin/sas"
|
||||||
|
local tempDir
|
||||||
|
local installStatus=0
|
||||||
|
|
||||||
|
tempDir="$(mktemp -d)"
|
||||||
|
|
||||||
|
echo "Installing latest sas..."
|
||||||
|
if ! git clone --depth 1 "$sasRepo" "$tempDir"; then
|
||||||
|
rm -rf "$tempDir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$sasPath"
|
||||||
|
if ! install -m 755 "$tempDir/sas.py" "$sasPath"; then
|
||||||
|
installStatus=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$tempDir"
|
||||||
|
return "$installStatus"
|
||||||
|
}
|
||||||
|
|
||||||
help() {
|
help() {
|
||||||
echo -e "Usage:\n"
|
echo -e "Usage:\n"
|
||||||
echo "With no arguments, build with default parameters."
|
echo "With no arguments, build with default parameters."
|
||||||
@@ -196,7 +208,7 @@ for i in arch-install-scripts dosfstools parted ; do
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
for i in e2fsck fsck.vfat sha1sum xz ; do
|
for i in sha1sum xz ; do
|
||||||
if ! command -v "$i" &> /dev/null ; then
|
if ! command -v "$i" &> /dev/null ; then
|
||||||
echo "Please install ${i} before continuing."
|
echo "Please install ${i} before continuing."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -330,6 +342,7 @@ packages=(
|
|||||||
bluez-utils
|
bluez-utils
|
||||||
brltty
|
brltty
|
||||||
cronie
|
cronie
|
||||||
|
curl
|
||||||
dialog
|
dialog
|
||||||
espeak-ng
|
espeak-ng
|
||||||
fake-hwclock
|
fake-hwclock
|
||||||
@@ -370,6 +383,7 @@ packages=(
|
|||||||
rsync
|
rsync
|
||||||
screen
|
screen
|
||||||
sox
|
sox
|
||||||
|
speech-dispatcher
|
||||||
w3m-git
|
w3m-git
|
||||||
wget
|
wget
|
||||||
wireless-regdb
|
wireless-regdb
|
||||||
@@ -382,6 +396,9 @@ packages=(
|
|||||||
|
|
||||||
pacman -Su --needed --noconfirm "\${packages[@]}"
|
pacman -Su --needed --noconfirm "\${packages[@]}"
|
||||||
|
|
||||||
|
$(declare -f install_sas)
|
||||||
|
install_sas
|
||||||
|
|
||||||
# Fix mkinitcpio preset for linux-rpi - kms hook fails on aarch64
|
# Fix mkinitcpio preset for linux-rpi - kms hook fails on aarch64
|
||||||
# See: https://archlinuxarm.org/forum/viewtopic.php?t=16672
|
# See: https://archlinuxarm.org/forum/viewtopic.php?t=16672
|
||||||
if [[ -f /etc/mkinitcpio.d/linux-rpi.preset ]]; then
|
if [[ -f /etc/mkinitcpio.d/linux-rpi.preset ]]; then
|
||||||
|
|||||||
@@ -1,582 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
import signal
|
|
||||||
import string
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import pwd
|
|
||||||
import threading
|
|
||||||
|
|
||||||
|
|
||||||
stormuxAdmin = ("storm",)
|
|
||||||
ircServer = "irc.stormux.org"
|
|
||||||
ircPort = 6667
|
|
||||||
ircChannel = "#stormux"
|
|
||||||
remoteHost = "billysballoons.com"
|
|
||||||
sasUser = "sas"
|
|
||||||
pingIntervalSeconds = 180
|
|
||||||
pingCount = 5
|
|
||||||
maxWormholeFailures = 3
|
|
||||||
|
|
||||||
sudoKeepaliveThread = None
|
|
||||||
sudoKeepaliveStop = threading.Event()
|
|
||||||
|
|
||||||
|
|
||||||
def speak_message(message):
|
|
||||||
try:
|
|
||||||
subprocess.run(["spd-say", message], check=False)
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(message, flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def say_or_print(message, useSpeech):
|
|
||||||
if useSpeech:
|
|
||||||
speak_message(message)
|
|
||||||
else:
|
|
||||||
print(message, flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def run_command(command, inputText=None, check=False, env=None):
|
|
||||||
return subprocess.run(
|
|
||||||
command,
|
|
||||||
input=inputText,
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=check,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_sudo(useSpeech):
|
|
||||||
if os.geteuid() == 0:
|
|
||||||
return True
|
|
||||||
if useSpeech:
|
|
||||||
speak_message("Sudo password required. Please enter your password now.")
|
|
||||||
result = run_command(["sudo", "-v"])
|
|
||||||
if result.returncode == 0:
|
|
||||||
start_sudo_keepalive()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def start_sudo_keepalive():
|
|
||||||
global sudoKeepaliveThread
|
|
||||||
if sudoKeepaliveThread and sudoKeepaliveThread.is_alive():
|
|
||||||
return
|
|
||||||
|
|
||||||
def keepalive_loop():
|
|
||||||
while not sudoKeepaliveStop.wait(240):
|
|
||||||
run_command(["sudo", "-n", "-v"])
|
|
||||||
|
|
||||||
sudoKeepaliveThread = threading.Thread(target=keepalive_loop, daemon=True)
|
|
||||||
sudoKeepaliveThread.start()
|
|
||||||
|
|
||||||
|
|
||||||
def run_privileged(command, useSpeech, inputText=None, check=True):
|
|
||||||
if os.geteuid() == 0:
|
|
||||||
fullCommand = command
|
|
||||||
else:
|
|
||||||
if not ensure_sudo(useSpeech):
|
|
||||||
raise RuntimeError("sudo authentication failed")
|
|
||||||
fullCommand = ["sudo"] + command
|
|
||||||
return run_command(fullCommand, inputText=inputText, check=check)
|
|
||||||
|
|
||||||
|
|
||||||
def run_as_user(userName, command, useSpeech, check=True):
|
|
||||||
if os.geteuid() == 0:
|
|
||||||
fullCommand = ["sudo", "-u", userName, "-H"] + command
|
|
||||||
else:
|
|
||||||
if not ensure_sudo(useSpeech):
|
|
||||||
raise RuntimeError("sudo authentication failed")
|
|
||||||
fullCommand = ["sudo", "-u", userName, "-H"] + command
|
|
||||||
return run_command(fullCommand, check=check)
|
|
||||||
|
|
||||||
|
|
||||||
def user_exists(userName):
|
|
||||||
result = run_command(["getent", "passwd", userName])
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_wheel(userName, useSpeech):
|
|
||||||
result = run_command(["id", "-nG", userName])
|
|
||||||
groups = result.stdout.strip().split()
|
|
||||||
if "wheel" not in groups:
|
|
||||||
run_privileged(["usermod", "-a", "-G", "wheel", userName], useSpeech)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_password():
|
|
||||||
allowedChars = string.ascii_letters + string.digits
|
|
||||||
length = secrets.randbelow(5) + 6
|
|
||||||
return "".join(secrets.choice(allowedChars) for _ in range(length))
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_home(userName):
|
|
||||||
return pwd.getpwnam(userName).pw_dir
|
|
||||||
|
|
||||||
|
|
||||||
def set_password(userName, password, useSpeech):
|
|
||||||
run_privileged(["chpasswd"], useSpeech, inputText=f"{userName}:{password}\n")
|
|
||||||
|
|
||||||
|
|
||||||
def generate_ssh_key(userName, useSpeech):
|
|
||||||
userHome = get_user_home(userName)
|
|
||||||
sshDir = os.path.join(userHome, ".ssh")
|
|
||||||
privateKeyPath = os.path.join(sshDir, "id_ed25519")
|
|
||||||
publicKeyPath = f"{privateKeyPath}.pub"
|
|
||||||
|
|
||||||
run_privileged(["mkdir", "-p", sshDir], useSpeech)
|
|
||||||
run_privileged(["chown", f"{userName}:{userName}", sshDir], useSpeech)
|
|
||||||
run_privileged(["chmod", "700", sshDir], useSpeech)
|
|
||||||
for entry in list_subdirs(sshDir):
|
|
||||||
if os.path.isfile(entry) or os.path.islink(entry):
|
|
||||||
run_privileged(["rm", "-f", entry], useSpeech, check=False)
|
|
||||||
|
|
||||||
run_as_user(
|
|
||||||
userName,
|
|
||||||
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", privateKeyPath],
|
|
||||||
useSpeech,
|
|
||||||
)
|
|
||||||
|
|
||||||
run_privileged(["chmod", "600", privateKeyPath], useSpeech)
|
|
||||||
run_privileged(["chmod", "644", publicKeyPath], useSpeech)
|
|
||||||
run_privileged(["chown", f"{userName}:{userName}", privateKeyPath, publicKeyPath], useSpeech)
|
|
||||||
|
|
||||||
return privateKeyPath, publicKeyPath
|
|
||||||
|
|
||||||
|
|
||||||
def path_exists_for_user(path, userName, useSpeech):
|
|
||||||
result = run_as_user(userName, ["stat", path], useSpeech, check=False)
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def extract_message_text(line):
|
|
||||||
if "> " in line:
|
|
||||||
return line.split("> ", 1)[1].strip()
|
|
||||||
if ": " in line:
|
|
||||||
return line.split(": ", 1)[1].strip()
|
|
||||||
return line.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_sender(line):
|
|
||||||
match = re.search(r"<([^>]+)>", line)
|
|
||||||
if match:
|
|
||||||
return match.group(1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def find_wormhole_code(message):
|
|
||||||
lowered = message.strip().lower()
|
|
||||||
if lowered in ("yes", "accept"):
|
|
||||||
return None
|
|
||||||
match = re.search(r"\b\d+-[A-Za-z0-9-]+\b", message)
|
|
||||||
if match:
|
|
||||||
return match.group(0)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class IrcSession:
|
|
||||||
def __init__(self, server, port, nick, channel, baseDir):
|
|
||||||
self.server = server
|
|
||||||
self.port = port
|
|
||||||
self.nick = nick
|
|
||||||
self.channel = channel
|
|
||||||
self.baseDir = baseDir
|
|
||||||
self.serverDir = None
|
|
||||||
self.serverInPath = None
|
|
||||||
self.channelInPath = None
|
|
||||||
self.iiProcess = None
|
|
||||||
self.pmOffsets = {}
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
if not shutil_which("ii"):
|
|
||||||
raise RuntimeError("ii is not installed")
|
|
||||||
supportsI = ii_supports_i()
|
|
||||||
processEnv = os.environ.copy()
|
|
||||||
iiCommand = ["ii", "-s", self.server, "-p", str(self.port), "-n", self.nick]
|
|
||||||
if supportsI:
|
|
||||||
iiCommand += ["-i", self.baseDir]
|
|
||||||
else:
|
|
||||||
processEnv["HOME"] = self.baseDir
|
|
||||||
self.iiProcess = subprocess.Popen(
|
|
||||||
iiCommand,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
env=processEnv,
|
|
||||||
)
|
|
||||||
self.serverDir = self.wait_for_server_dir()
|
|
||||||
self.serverInPath = os.path.join(self.serverDir, "in")
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
if self.channelInPath and os.path.exists(self.channelInPath):
|
|
||||||
try:
|
|
||||||
self.write_line(self.channelInPath, f"/part {self.channel}")
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if self.serverInPath and os.path.exists(self.serverInPath):
|
|
||||||
try:
|
|
||||||
self.write_line(self.serverInPath, "/quit")
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if self.iiProcess and self.iiProcess.poll() is None:
|
|
||||||
self.iiProcess.terminate()
|
|
||||||
try:
|
|
||||||
self.iiProcess.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
self.iiProcess.kill()
|
|
||||||
|
|
||||||
def join_channel(self):
|
|
||||||
joinMessage = f"/join {self.channel}"
|
|
||||||
channelDir = os.path.join(self.serverDir, self.channel)
|
|
||||||
channelAltDir = os.path.join(self.serverDir, self.channel.lstrip("#"))
|
|
||||||
startTime = time.monotonic()
|
|
||||||
nextJoinTime = startTime
|
|
||||||
while time.monotonic() - startTime < 60:
|
|
||||||
if time.monotonic() >= nextJoinTime:
|
|
||||||
self.write_line(self.serverInPath, joinMessage)
|
|
||||||
nextJoinTime = time.monotonic() + 5
|
|
||||||
for candidate in (channelDir, channelAltDir):
|
|
||||||
inPath = os.path.join(candidate, "in")
|
|
||||||
if os.path.exists(inPath):
|
|
||||||
self.channelInPath = inPath
|
|
||||||
return
|
|
||||||
time.sleep(0.5)
|
|
||||||
self.channelInPath = None
|
|
||||||
|
|
||||||
def send_channel_message(self, message):
|
|
||||||
if not self.channelInPath:
|
|
||||||
self.refresh_channel_in_path()
|
|
||||||
if self.channelInPath and os.path.exists(self.channelInPath):
|
|
||||||
self.write_line(self.channelInPath, message)
|
|
||||||
else:
|
|
||||||
self.write_line(self.serverInPath, f"/msg {self.channel} {message}")
|
|
||||||
|
|
||||||
def send_private_message(self, nick, message):
|
|
||||||
nickDir = os.path.join(self.serverDir, nick)
|
|
||||||
inPath = os.path.join(nickDir, "in")
|
|
||||||
if os.path.exists(inPath):
|
|
||||||
self.write_line(inPath, message)
|
|
||||||
else:
|
|
||||||
self.write_line(self.serverInPath, f"/msg {nick} {message}")
|
|
||||||
|
|
||||||
def get_private_messages(self, allowedUsers):
|
|
||||||
messages = []
|
|
||||||
for nick in allowedUsers:
|
|
||||||
nickDir = os.path.join(self.serverDir, nick)
|
|
||||||
outPath = os.path.join(nickDir, "out")
|
|
||||||
if not os.path.exists(outPath):
|
|
||||||
continue
|
|
||||||
lastPos = self.pmOffsets.get(outPath, 0)
|
|
||||||
with open(outPath, "r", encoding="utf-8", errors="ignore") as fileHandle:
|
|
||||||
fileHandle.seek(lastPos)
|
|
||||||
for line in fileHandle:
|
|
||||||
sender = parse_sender(line)
|
|
||||||
if sender and sender == self.nick:
|
|
||||||
continue
|
|
||||||
if sender and sender != nick:
|
|
||||||
continue
|
|
||||||
messageText = extract_message_text(line)
|
|
||||||
if messageText:
|
|
||||||
messages.append((nick, messageText))
|
|
||||||
self.pmOffsets[outPath] = fileHandle.tell()
|
|
||||||
return messages
|
|
||||||
|
|
||||||
def refresh_channel_in_path(self):
|
|
||||||
channelDir = os.path.join(self.serverDir, self.channel)
|
|
||||||
channelAltDir = os.path.join(self.serverDir, self.channel.lstrip("#"))
|
|
||||||
for candidate in (channelDir, channelAltDir):
|
|
||||||
inPath = os.path.join(candidate, "in")
|
|
||||||
if os.path.exists(inPath):
|
|
||||||
self.channelInPath = inPath
|
|
||||||
return
|
|
||||||
|
|
||||||
def wait_for_server_dir(self):
|
|
||||||
for _ in range(120):
|
|
||||||
for rootDir in [self.baseDir] + list_subdirs(self.baseDir):
|
|
||||||
if not os.path.isdir(rootDir):
|
|
||||||
continue
|
|
||||||
for entry in os.listdir(rootDir):
|
|
||||||
path = os.path.join(rootDir, entry)
|
|
||||||
if os.path.isdir(path) and self.server in entry:
|
|
||||||
inPath = os.path.join(path, "in")
|
|
||||||
if os.path.exists(inPath):
|
|
||||||
return path
|
|
||||||
time.sleep(0.5)
|
|
||||||
raise RuntimeError("ii server directory not found")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def write_line(path, message):
|
|
||||||
with open(path, "w", encoding="utf-8", errors="ignore") as fileHandle:
|
|
||||||
fileHandle.write(message + "\n")
|
|
||||||
fileHandle.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def ii_supports_i():
|
|
||||||
result = run_command(["ii", "-h"])
|
|
||||||
output = (result.stdout or "") + (result.stderr or "")
|
|
||||||
return "-i" in output
|
|
||||||
|
|
||||||
|
|
||||||
def list_subdirs(path):
|
|
||||||
try:
|
|
||||||
return [os.path.join(path, entry) for entry in os.listdir(path)]
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def shutil_which(command):
|
|
||||||
for path in os.environ.get("PATH", "").split(os.pathsep):
|
|
||||||
candidate = os.path.join(path, command)
|
|
||||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def build_nick():
|
|
||||||
baseUser = os.environ.get("SUDO_USER") or os.environ.get("USER") or "sas"
|
|
||||||
return f"{baseUser}-{int(time.time())}"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
say_or_print("Checking accessibility. Is your screen reader working? (y/n)", True)
|
|
||||||
answer = input().strip().lower()
|
|
||||||
useSpeech = answer in ("n", "no")
|
|
||||||
|
|
||||||
shouldRemoveUser = False
|
|
||||||
cleanupDone = False
|
|
||||||
tempDir = tempfile.mkdtemp(prefix="sas-ii-")
|
|
||||||
ircSession = None
|
|
||||||
sshProcess = None
|
|
||||||
|
|
||||||
def cleanup(exitMessage=None):
|
|
||||||
nonlocal cleanupDone
|
|
||||||
if cleanupDone:
|
|
||||||
return
|
|
||||||
cleanupDone = True
|
|
||||||
nonlocal sshProcess
|
|
||||||
if exitMessage:
|
|
||||||
say_or_print(exitMessage, useSpeech)
|
|
||||||
|
|
||||||
if sshProcess and sshProcess.poll() is None:
|
|
||||||
sshProcess.terminate()
|
|
||||||
try:
|
|
||||||
sshProcess.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
sshProcess.kill()
|
|
||||||
|
|
||||||
if ircSession:
|
|
||||||
ircSession.stop()
|
|
||||||
|
|
||||||
if shouldRemoveUser:
|
|
||||||
try:
|
|
||||||
run_privileged(["pkill", "-u", sasUser], useSpeech, check=False)
|
|
||||||
time.sleep(1)
|
|
||||||
result = run_privileged(["userdel", "-r", sasUser], useSpeech, check=False)
|
|
||||||
run_privileged(["rm", "-rf", f"/home/{sasUser}"], useSpeech, check=False)
|
|
||||||
if result.returncode != 0 and user_exists(sasUser):
|
|
||||||
say_or_print(
|
|
||||||
"Cleanup warning: failed to remove sas user. Please remove it manually.",
|
|
||||||
useSpeech,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sudoKeepaliveStop.set()
|
|
||||||
if sudoKeepaliveThread:
|
|
||||||
sudoKeepaliveThread.join(timeout=2)
|
|
||||||
|
|
||||||
try:
|
|
||||||
remove_tree(tempDir)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def handle_signal(signum, frame):
|
|
||||||
cleanup("Interrupted. Cleaning up.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, handle_signal)
|
|
||||||
signal.signal(signal.SIGTERM, handle_signal)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not user_exists(sasUser):
|
|
||||||
run_privileged(
|
|
||||||
["useradd", "-m", "-d", f"/home/{sasUser}", "-s", "/bin/bash", "-G", "wheel", sasUser],
|
|
||||||
useSpeech,
|
|
||||||
)
|
|
||||||
shouldRemoveUser = True
|
|
||||||
else:
|
|
||||||
say_or_print(
|
|
||||||
"User 'sas' exists. Remove and recreate it? This will delete /home/sas. (y/n)",
|
|
||||||
useSpeech,
|
|
||||||
)
|
|
||||||
response = input().strip().lower()
|
|
||||||
if response not in ("y", "yes"):
|
|
||||||
cleanup("The sas user is unavailable. Remove it manually and try again.")
|
|
||||||
return 1
|
|
||||||
run_privileged(["pkill", "-u", sasUser], useSpeech, check=False)
|
|
||||||
run_privileged(["userdel", "-r", sasUser], useSpeech, check=False)
|
|
||||||
run_privileged(["rm", "-rf", f"/home/{sasUser}"], useSpeech, check=False)
|
|
||||||
run_privileged(
|
|
||||||
["useradd", "-m", "-d", f"/home/{sasUser}", "-s", "/bin/bash", "-G", "wheel", sasUser],
|
|
||||||
useSpeech,
|
|
||||||
)
|
|
||||||
shouldRemoveUser = True
|
|
||||||
|
|
||||||
ensure_wheel(sasUser, useSpeech)
|
|
||||||
|
|
||||||
password = generate_password()
|
|
||||||
set_password(sasUser, password, useSpeech)
|
|
||||||
|
|
||||||
privateKeyPath, publicKeyPath = generate_ssh_key(sasUser, useSpeech)
|
|
||||||
sasHome = get_user_home(sasUser)
|
|
||||||
knownHostsPath = os.path.join(sasHome, ".ssh", "known_hosts_sas")
|
|
||||||
run_privileged(["touch", knownHostsPath], useSpeech)
|
|
||||||
run_privileged(["chmod", "600", knownHostsPath], useSpeech)
|
|
||||||
run_privileged(["chown", f"{sasUser}:{sasUser}", knownHostsPath], useSpeech)
|
|
||||||
|
|
||||||
nick = build_nick()
|
|
||||||
ircSession = IrcSession(ircServer, ircPort, nick, ircChannel, tempDir)
|
|
||||||
ircSession.start()
|
|
||||||
ircSession.join_channel()
|
|
||||||
|
|
||||||
say_or_print("Waiting for assistance on IRC.", useSpeech)
|
|
||||||
startTime = time.monotonic()
|
|
||||||
nextPingTime = startTime
|
|
||||||
pingsSent = 0
|
|
||||||
confirmedAdmin = None
|
|
||||||
|
|
||||||
while time.monotonic() - startTime < pingIntervalSeconds * pingCount:
|
|
||||||
now = time.monotonic()
|
|
||||||
if pingsSent < pingCount and now >= nextPingTime:
|
|
||||||
ircSession.send_channel_message(f"{nick} is requesting assistance.")
|
|
||||||
pingsSent += 1
|
|
||||||
nextPingTime = startTime + (pingsSent * pingIntervalSeconds)
|
|
||||||
|
|
||||||
for adminNick, messageText in ircSession.get_private_messages(stormuxAdmin):
|
|
||||||
if messageText.strip().lower() in ("yes", "accept"):
|
|
||||||
confirmedAdmin = adminNick
|
|
||||||
break
|
|
||||||
if confirmedAdmin:
|
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
if not confirmedAdmin:
|
|
||||||
cleanup("No one was available to help, please try again later.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
ircSession.send_private_message(
|
|
||||||
confirmedAdmin,
|
|
||||||
f'password: "{password}" please send wormhole ssh invite code',
|
|
||||||
)
|
|
||||||
|
|
||||||
failures = 0
|
|
||||||
while failures < maxWormholeFailures:
|
|
||||||
inviteCode = None
|
|
||||||
while inviteCode is None:
|
|
||||||
for adminNick, messageText in ircSession.get_private_messages(stormuxAdmin):
|
|
||||||
inviteCode = find_wormhole_code(messageText)
|
|
||||||
if inviteCode:
|
|
||||||
break
|
|
||||||
if inviteCode:
|
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
if not path_exists_for_user(publicKeyPath, sasUser, useSpeech):
|
|
||||||
raise RuntimeError(f"Public key missing: {publicKeyPath}")
|
|
||||||
|
|
||||||
wormholeCommand = [
|
|
||||||
"wormhole",
|
|
||||||
"ssh",
|
|
||||||
"accept",
|
|
||||||
"--yes",
|
|
||||||
inviteCode,
|
|
||||||
]
|
|
||||||
result = run_as_user(sasUser, wormholeCommand, useSpeech, check=False)
|
|
||||||
if result.returncode == 0:
|
|
||||||
say_or_print("Wormhole key transfer succeeded.", useSpeech)
|
|
||||||
break
|
|
||||||
|
|
||||||
failures += 1
|
|
||||||
errorTextFull = (result.stderr or result.stdout or "").strip()
|
|
||||||
if errorTextFull and not useSpeech:
|
|
||||||
print("Wormhole ssh accept error:", flush=True)
|
|
||||||
print(errorTextFull, flush=True)
|
|
||||||
errorText = errorTextFull
|
|
||||||
if errorText:
|
|
||||||
errorText = " ".join(errorText.split())
|
|
||||||
if len(errorText) > 400:
|
|
||||||
errorText = errorText[:400] + "..."
|
|
||||||
ircSession.send_private_message(
|
|
||||||
confirmedAdmin,
|
|
||||||
f"Wormhole ssh accept failed: {errorText}",
|
|
||||||
)
|
|
||||||
ircSession.send_private_message(
|
|
||||||
confirmedAdmin,
|
|
||||||
"Wormhole ssh accept failed. Please send a new invite code.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if failures >= maxWormholeFailures:
|
|
||||||
cleanup("Wormhole failed too many times. Exiting.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
say_or_print("Starting reverse SSH tunnel. Press Ctrl+C to stop.", useSpeech)
|
|
||||||
sshCommand = [
|
|
||||||
"ssh",
|
|
||||||
"-N",
|
|
||||||
"-R",
|
|
||||||
"localhost:2232:localhost:22",
|
|
||||||
"-o",
|
|
||||||
"ExitOnForwardFailure=yes",
|
|
||||||
"-o",
|
|
||||||
"BatchMode=yes",
|
|
||||||
"-o",
|
|
||||||
"ServerAliveInterval=30",
|
|
||||||
"-o",
|
|
||||||
"ServerAliveCountMax=3",
|
|
||||||
"-o",
|
|
||||||
"StrictHostKeyChecking=accept-new",
|
|
||||||
"-o",
|
|
||||||
f"UserKnownHostsFile={knownHostsPath}",
|
|
||||||
"-i",
|
|
||||||
privateKeyPath,
|
|
||||||
f"{sasUser}@{remoteHost}",
|
|
||||||
]
|
|
||||||
sshCommand = ["sudo", "-u", sasUser, "-H"] + sshCommand
|
|
||||||
sshProcess = subprocess.Popen(sshCommand)
|
|
||||||
sshProcess.wait()
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
cleanup(f"Error: {exc}")
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def remove_tree(path):
|
|
||||||
if not os.path.exists(path):
|
|
||||||
return
|
|
||||||
for rootDir, dirNames, fileNames in os.walk(path, topdown=False):
|
|
||||||
for fileName in fileNames:
|
|
||||||
try:
|
|
||||||
os.unlink(os.path.join(rootDir, fileName))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
for dirName in dirNames:
|
|
||||||
try:
|
|
||||||
os.rmdir(os.path.join(rootDir, dirName))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
os.rmdir(path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
Executable
+141
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly repoName="stormux"
|
||||||
|
readonly repoKeyUrl="https://packages.stormux.org/stormux_repo.pub"
|
||||||
|
readonly repoKeyId="52ADA49000F1FF0456F8AEEFB4CDE1CD56EF8E82"
|
||||||
|
readonly pacmanConf="${STORMUX_PACMAN_CONF:-/etc/pacman.conf}"
|
||||||
|
readonly osRelease="${STORMUX_OS_RELEASE:-/etc/os-release}"
|
||||||
|
readonly effectiveUid="${STORMUX_TEST_EUID:-${EUID}}"
|
||||||
|
|
||||||
|
keyFile=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$keyFile" && -f "$keyFile" ]]; then
|
||||||
|
rm -f "$keyFile"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf 'Error: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_root() {
|
||||||
|
[[ "$effectiveUid" == "0" ]] || die "This script must be run as root."
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
local commandName="$1"
|
||||||
|
|
||||||
|
command -v "$commandName" >/dev/null 2>&1 || die "Required command not found: ${commandName}"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_arch_based_system() {
|
||||||
|
local osId=""
|
||||||
|
local osIdLike=""
|
||||||
|
|
||||||
|
[[ -f "$osRelease" ]] || return 1
|
||||||
|
|
||||||
|
while IFS='=' read -r keyName keyValue; do
|
||||||
|
keyValue="${keyValue%\"}"
|
||||||
|
keyValue="${keyValue#\"}"
|
||||||
|
|
||||||
|
case "$keyName" in
|
||||||
|
ID)
|
||||||
|
osId="$keyValue"
|
||||||
|
;;
|
||||||
|
ID_LIKE)
|
||||||
|
osIdLike="$keyValue"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < "$osRelease"
|
||||||
|
|
||||||
|
[[ "$osId" == "arch" || "$osId" == "archarm" || " ${osIdLike} " == *" arch "* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
check_prerequisites() {
|
||||||
|
require_root
|
||||||
|
require_command curl
|
||||||
|
require_command pacman
|
||||||
|
require_command pacman-key
|
||||||
|
|
||||||
|
is_arch_based_system || die "This script supports Arch-based pacman systems only."
|
||||||
|
[[ -f "$pacmanConf" ]] || die "pacman.conf not found: ${pacmanConf}"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_and_trust_key() {
|
||||||
|
keyFile="$(mktemp)"
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
printf 'Downloading Stormux repository key...\n'
|
||||||
|
curl -fsSL "$repoKeyUrl" > "$keyFile"
|
||||||
|
|
||||||
|
printf 'Adding Stormux repository key to pacman keyring...\n'
|
||||||
|
pacman-key --add "$keyFile"
|
||||||
|
|
||||||
|
printf 'Locally signing Stormux repository key...\n'
|
||||||
|
pacman-key --lsign-key "$repoKeyId"
|
||||||
|
}
|
||||||
|
|
||||||
|
update_pacman_conf() {
|
||||||
|
local tempConf
|
||||||
|
|
||||||
|
tempConf="$(mktemp)"
|
||||||
|
awk -v repoName="$repoName" '
|
||||||
|
function print_repo_block() {
|
||||||
|
print "[" repoName "]"
|
||||||
|
print "SigLevel = Required DatabaseOptional"
|
||||||
|
print "Server = https://packages.stormux.org/$arch"
|
||||||
|
}
|
||||||
|
|
||||||
|
/^\[stormux\][[:space:]]*$/ {
|
||||||
|
inStormux = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
inStormux && /^\[[^]]+\][[:space:]]*$/ {
|
||||||
|
inStormux = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
inStormux {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
!inserted && /^\[core\][[:space:]]*$/ {
|
||||||
|
print_repo_block()
|
||||||
|
print ""
|
||||||
|
inserted = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
print
|
||||||
|
}
|
||||||
|
|
||||||
|
END {
|
||||||
|
if (!inserted) {
|
||||||
|
print ""
|
||||||
|
print_repo_block()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$pacmanConf" > "$tempConf"
|
||||||
|
|
||||||
|
cat "$tempConf" > "$pacmanConf"
|
||||||
|
rm -f "$tempConf"
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh_databases() {
|
||||||
|
printf 'Refreshing package databases...\n'
|
||||||
|
pacman -Sy
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
check_prerequisites
|
||||||
|
download_and_trust_key
|
||||||
|
update_pacman_conf
|
||||||
|
refresh_databases
|
||||||
|
printf 'Stormux repository is configured.\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+230
@@ -0,0 +1,230 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
repoDir="/var/www/packages.stormux.org"
|
||||||
|
aurRpcUrl="https://aur.archlinux.org/rpc/v5/info"
|
||||||
|
exclude=("gzdoom")
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
local cmd="$1"
|
||||||
|
|
||||||
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
|
printf 'Required command not found: %s\n' "$cmd" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
is_excluded() {
|
||||||
|
local packageName="$1"
|
||||||
|
local excludedPackage
|
||||||
|
|
||||||
|
for excludedPackage in "${exclude[@]}"; do
|
||||||
|
if [[ "$excludedPackage" == "$packageName" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
record_local_package_version() {
|
||||||
|
local packageName="$1"
|
||||||
|
local packageVersion="$2"
|
||||||
|
local packageMapName="$3"
|
||||||
|
local -n packageMapRef="$packageMapName"
|
||||||
|
local existingVersion="${packageMapRef[$packageName]:-}"
|
||||||
|
|
||||||
|
if [[ -z "$existingVersion" ]] || (( $(vercmp "$packageVersion" "$existingVersion") > 0 )); then
|
||||||
|
packageMapRef["$packageName"]="$packageVersion"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
read_package_metadata() {
|
||||||
|
local packageFile="$1"
|
||||||
|
|
||||||
|
pacman -Qip "$packageFile" | parse_pacman_info
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_pacman_info() {
|
||||||
|
awk '
|
||||||
|
/^Name[[:space:]]*:/ {
|
||||||
|
packageName=$0
|
||||||
|
sub(/^Name[[:space:]]*:[[:space:]]*/, "", packageName)
|
||||||
|
}
|
||||||
|
/^Version[[:space:]]*:/ {
|
||||||
|
packageVersion=$0
|
||||||
|
sub(/^Version[[:space:]]*:[[:space:]]*/, "", packageVersion)
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (packageName == "" || packageVersion == "") {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
printf "%s\t%s\n", packageName, packageVersion
|
||||||
|
}
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_local_packages() {
|
||||||
|
local packageMapName="$1"
|
||||||
|
# shellcheck disable=SC2178
|
||||||
|
local -n packageMapRef="$packageMapName"
|
||||||
|
local archDir packageFile metadata packageName packageVersion
|
||||||
|
local -a archDirs=("$repoDir/x86_64" "$repoDir/aarch64")
|
||||||
|
|
||||||
|
for archDir in "${archDirs[@]}"; do
|
||||||
|
if [[ ! -d "$archDir" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
for packageFile in "$archDir"/*.pkg.tar.zst "$archDir"/*.pkg.tar.xz; do
|
||||||
|
metadata="$(read_package_metadata "$packageFile")" || {
|
||||||
|
printf 'Unable to read package metadata: %s\n' "$packageFile" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
packageName="${metadata%%$'\t'*}"
|
||||||
|
packageVersion="${metadata#*$'\t'}"
|
||||||
|
record_local_package_version "$packageName" "$packageVersion" "$packageMapName"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_aur_version_from_json() {
|
||||||
|
local packageName="$1"
|
||||||
|
|
||||||
|
jq -r --arg packageName "$packageName" '
|
||||||
|
.results[]
|
||||||
|
| select(.Name == $packageName)
|
||||||
|
| .Version
|
||||||
|
' | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_aur_version() {
|
||||||
|
local packageName="$1"
|
||||||
|
|
||||||
|
curl -fsS --get \
|
||||||
|
--data-urlencode "arg[]=${packageName}" \
|
||||||
|
"$aurRpcUrl" |
|
||||||
|
extract_aur_version_from_json "$packageName"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_outdated_packages() {
|
||||||
|
local packageMapName="$1"
|
||||||
|
# shellcheck disable=SC2178
|
||||||
|
local -n packageMapRef="$packageMapName"
|
||||||
|
local packageName localVersion aurVersion
|
||||||
|
local -a packageNames=()
|
||||||
|
|
||||||
|
mapfile -t packageNames < <(printf '%s\n' "${!packageMapRef[@]}" | sort)
|
||||||
|
|
||||||
|
for packageName in "${packageNames[@]}"; do
|
||||||
|
if is_excluded "$packageName"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
localVersion="${packageMapRef[$packageName]}"
|
||||||
|
aurVersion="$(fetch_aur_version "$packageName" || true)"
|
||||||
|
|
||||||
|
if [[ -z "$aurVersion" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( $(vercmp "$localVersion" "$aurVersion") < 0 )); then
|
||||||
|
printf '%s %s\n' "$packageName" "$aurVersion"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_equals() {
|
||||||
|
local expected="$1"
|
||||||
|
local actual="$2"
|
||||||
|
local message="$3"
|
||||||
|
|
||||||
|
if [[ "$expected" != "$actual" ]]; then
|
||||||
|
printf 'FAIL: %s\nExpected: %s\nActual: %s\n' "$message" "$expected" "$actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_success() {
|
||||||
|
local message="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
if ! "$@"; then
|
||||||
|
printf 'FAIL: %s\n' "$message" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_failure() {
|
||||||
|
local message="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
if "$@"; then
|
||||||
|
printf 'FAIL: %s\n' "$message" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
if [[ "${1:-}" == "--self-test" ]]; then
|
||||||
|
run_self_tests
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_cmd "curl"
|
||||||
|
require_cmd "jq"
|
||||||
|
require_cmd "pacman"
|
||||||
|
require_cmd "vercmp"
|
||||||
|
|
||||||
|
if [[ ! -d "$repoDir" ]]; then
|
||||||
|
printf 'Repo dir does not exist: %s\n' "$repoDir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
declare -A localPackages=()
|
||||||
|
collect_local_packages localPackages
|
||||||
|
print_outdated_packages localPackages
|
||||||
|
}
|
||||||
|
|
||||||
|
run_self_tests() {
|
||||||
|
declare -A packageMap=()
|
||||||
|
local exactMatchJson noMatchJson
|
||||||
|
local extractedVersion=""
|
||||||
|
local pacmanInfo=""
|
||||||
|
|
||||||
|
assert_success "excluded package should match" is_excluded "gzdoom"
|
||||||
|
assert_failure "non-excluded package should not match" is_excluded "fenrir"
|
||||||
|
|
||||||
|
record_local_package_version "fenrir" "1:2026.01.20-1" packageMap
|
||||||
|
record_local_package_version "fenrir" "1:2026.01.28-1" packageMap
|
||||||
|
record_local_package_version "fenrir" "1:2026.01.10-1" packageMap
|
||||||
|
assert_equals "1:2026.01.28-1" "${packageMap[fenrir]}" "newest local version should win"
|
||||||
|
|
||||||
|
exactMatchJson='{"results":[{"Name":"fenrir-git","Version":"1:r3322.4672592d-1"},{"Name":"fenrir","Version":"1:2026.01.28-1"}]}'
|
||||||
|
extractedVersion="$(printf '%s\n' "$exactMatchJson" | extract_aur_version_from_json "fenrir")"
|
||||||
|
assert_equals "1:2026.01.28-1" "$extractedVersion" "exact package name should be selected from AUR JSON"
|
||||||
|
|
||||||
|
noMatchJson='{"results":[{"Name":"fenrir-git","Version":"1:r3322.4672592d-1"}]}'
|
||||||
|
extractedVersion="$(printf '%s\n' "$noMatchJson" | extract_aur_version_from_json "fenrir")"
|
||||||
|
assert_equals "" "$extractedVersion" "missing exact AUR match should stay empty"
|
||||||
|
|
||||||
|
pacmanInfo='Name : fenrir
|
||||||
|
Version : 1:2026.01.28-1
|
||||||
|
Description : A user space console screen reader written in python3'
|
||||||
|
extractedVersion="$(printf '%s\n' "$pacmanInfo" | parse_pacman_info)"
|
||||||
|
assert_equals $'fenrir\t1:2026.01.28-1' "$extractedVersion" "pacman metadata parsing should return name and version"
|
||||||
|
|
||||||
|
if (( $(vercmp "1:2026.01.20-1" "1:2026.01.28-1") >= 0 )); then
|
||||||
|
printf 'FAIL: older local version should compare lower than AUR version\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Self-test passed\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -5,6 +5,29 @@
|
|||||||
|
|
||||||
set -e -u
|
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
|
# Initialize pacman keyring
|
||||||
echo "Initializing pacman keyring..."
|
echo "Initializing pacman keyring..."
|
||||||
pacman-key --init
|
pacman-key --init
|
||||||
@@ -25,6 +48,8 @@ fi
|
|||||||
echo "en_US.UTF-8 UTF-8" > /etc/locale.gen
|
echo "en_US.UTF-8 UTF-8" > /etc/locale.gen
|
||||||
locale-gen
|
locale-gen
|
||||||
|
|
||||||
|
install_sas
|
||||||
|
|
||||||
# Enable system services
|
# Enable system services
|
||||||
systemctl enable NetworkManager.service
|
systemctl enable NetworkManager.service
|
||||||
systemctl enable fenrirscreenreader.service
|
systemctl enable fenrirscreenreader.service
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
sas.sh
|
|
||||||
@@ -1,582 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
import signal
|
|
||||||
import string
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import pwd
|
|
||||||
import threading
|
|
||||||
|
|
||||||
|
|
||||||
stormuxAdmin = ("storm",)
|
|
||||||
ircServer = "irc.stormux.org"
|
|
||||||
ircPort = 6667
|
|
||||||
ircChannel = "#stormux"
|
|
||||||
remoteHost = "billysballoons.com"
|
|
||||||
sasUser = "sas"
|
|
||||||
pingIntervalSeconds = 180
|
|
||||||
pingCount = 5
|
|
||||||
maxWormholeFailures = 3
|
|
||||||
|
|
||||||
sudoKeepaliveThread = None
|
|
||||||
sudoKeepaliveStop = threading.Event()
|
|
||||||
|
|
||||||
|
|
||||||
def speak_message(message):
|
|
||||||
try:
|
|
||||||
subprocess.run(["spd-say", message], check=False)
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(message, flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def say_or_print(message, useSpeech):
|
|
||||||
if useSpeech:
|
|
||||||
speak_message(message)
|
|
||||||
else:
|
|
||||||
print(message, flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def run_command(command, inputText=None, check=False, env=None):
|
|
||||||
return subprocess.run(
|
|
||||||
command,
|
|
||||||
input=inputText,
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=check,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_sudo(useSpeech):
|
|
||||||
if os.geteuid() == 0:
|
|
||||||
return True
|
|
||||||
if useSpeech:
|
|
||||||
speak_message("Sudo password required. Please enter your password now.")
|
|
||||||
result = run_command(["sudo", "-v"])
|
|
||||||
if result.returncode == 0:
|
|
||||||
start_sudo_keepalive()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def start_sudo_keepalive():
|
|
||||||
global sudoKeepaliveThread
|
|
||||||
if sudoKeepaliveThread and sudoKeepaliveThread.is_alive():
|
|
||||||
return
|
|
||||||
|
|
||||||
def keepalive_loop():
|
|
||||||
while not sudoKeepaliveStop.wait(240):
|
|
||||||
run_command(["sudo", "-n", "-v"])
|
|
||||||
|
|
||||||
sudoKeepaliveThread = threading.Thread(target=keepalive_loop, daemon=True)
|
|
||||||
sudoKeepaliveThread.start()
|
|
||||||
|
|
||||||
|
|
||||||
def run_privileged(command, useSpeech, inputText=None, check=True):
|
|
||||||
if os.geteuid() == 0:
|
|
||||||
fullCommand = command
|
|
||||||
else:
|
|
||||||
if not ensure_sudo(useSpeech):
|
|
||||||
raise RuntimeError("sudo authentication failed")
|
|
||||||
fullCommand = ["sudo"] + command
|
|
||||||
return run_command(fullCommand, inputText=inputText, check=check)
|
|
||||||
|
|
||||||
|
|
||||||
def run_as_user(userName, command, useSpeech, check=True):
|
|
||||||
if os.geteuid() == 0:
|
|
||||||
fullCommand = ["sudo", "-u", userName, "-H"] + command
|
|
||||||
else:
|
|
||||||
if not ensure_sudo(useSpeech):
|
|
||||||
raise RuntimeError("sudo authentication failed")
|
|
||||||
fullCommand = ["sudo", "-u", userName, "-H"] + command
|
|
||||||
return run_command(fullCommand, check=check)
|
|
||||||
|
|
||||||
|
|
||||||
def user_exists(userName):
|
|
||||||
result = run_command(["getent", "passwd", userName])
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_wheel(userName, useSpeech):
|
|
||||||
result = run_command(["id", "-nG", userName])
|
|
||||||
groups = result.stdout.strip().split()
|
|
||||||
if "wheel" not in groups:
|
|
||||||
run_privileged(["usermod", "-a", "-G", "wheel", userName], useSpeech)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_password():
|
|
||||||
allowedChars = string.ascii_letters + string.digits
|
|
||||||
length = secrets.randbelow(5) + 6
|
|
||||||
return "".join(secrets.choice(allowedChars) for _ in range(length))
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_home(userName):
|
|
||||||
return pwd.getpwnam(userName).pw_dir
|
|
||||||
|
|
||||||
|
|
||||||
def set_password(userName, password, useSpeech):
|
|
||||||
run_privileged(["chpasswd"], useSpeech, inputText=f"{userName}:{password}\n")
|
|
||||||
|
|
||||||
|
|
||||||
def generate_ssh_key(userName, useSpeech):
|
|
||||||
userHome = get_user_home(userName)
|
|
||||||
sshDir = os.path.join(userHome, ".ssh")
|
|
||||||
privateKeyPath = os.path.join(sshDir, "id_ed25519")
|
|
||||||
publicKeyPath = f"{privateKeyPath}.pub"
|
|
||||||
|
|
||||||
run_privileged(["mkdir", "-p", sshDir], useSpeech)
|
|
||||||
run_privileged(["chown", f"{userName}:{userName}", sshDir], useSpeech)
|
|
||||||
run_privileged(["chmod", "700", sshDir], useSpeech)
|
|
||||||
for entry in list_subdirs(sshDir):
|
|
||||||
if os.path.isfile(entry) or os.path.islink(entry):
|
|
||||||
run_privileged(["rm", "-f", entry], useSpeech, check=False)
|
|
||||||
|
|
||||||
run_as_user(
|
|
||||||
userName,
|
|
||||||
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", privateKeyPath],
|
|
||||||
useSpeech,
|
|
||||||
)
|
|
||||||
|
|
||||||
run_privileged(["chmod", "600", privateKeyPath], useSpeech)
|
|
||||||
run_privileged(["chmod", "644", publicKeyPath], useSpeech)
|
|
||||||
run_privileged(["chown", f"{userName}:{userName}", privateKeyPath, publicKeyPath], useSpeech)
|
|
||||||
|
|
||||||
return privateKeyPath, publicKeyPath
|
|
||||||
|
|
||||||
|
|
||||||
def path_exists_for_user(path, userName, useSpeech):
|
|
||||||
result = run_as_user(userName, ["stat", path], useSpeech, check=False)
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def extract_message_text(line):
|
|
||||||
if "> " in line:
|
|
||||||
return line.split("> ", 1)[1].strip()
|
|
||||||
if ": " in line:
|
|
||||||
return line.split(": ", 1)[1].strip()
|
|
||||||
return line.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_sender(line):
|
|
||||||
match = re.search(r"<([^>]+)>", line)
|
|
||||||
if match:
|
|
||||||
return match.group(1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def find_wormhole_code(message):
|
|
||||||
lowered = message.strip().lower()
|
|
||||||
if lowered in ("yes", "accept"):
|
|
||||||
return None
|
|
||||||
match = re.search(r"\b\d+-[A-Za-z0-9-]+\b", message)
|
|
||||||
if match:
|
|
||||||
return match.group(0)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class IrcSession:
|
|
||||||
def __init__(self, server, port, nick, channel, baseDir):
|
|
||||||
self.server = server
|
|
||||||
self.port = port
|
|
||||||
self.nick = nick
|
|
||||||
self.channel = channel
|
|
||||||
self.baseDir = baseDir
|
|
||||||
self.serverDir = None
|
|
||||||
self.serverInPath = None
|
|
||||||
self.channelInPath = None
|
|
||||||
self.iiProcess = None
|
|
||||||
self.pmOffsets = {}
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
if not shutil_which("ii"):
|
|
||||||
raise RuntimeError("ii is not installed")
|
|
||||||
supportsI = ii_supports_i()
|
|
||||||
processEnv = os.environ.copy()
|
|
||||||
iiCommand = ["ii", "-s", self.server, "-p", str(self.port), "-n", self.nick]
|
|
||||||
if supportsI:
|
|
||||||
iiCommand += ["-i", self.baseDir]
|
|
||||||
else:
|
|
||||||
processEnv["HOME"] = self.baseDir
|
|
||||||
self.iiProcess = subprocess.Popen(
|
|
||||||
iiCommand,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
env=processEnv,
|
|
||||||
)
|
|
||||||
self.serverDir = self.wait_for_server_dir()
|
|
||||||
self.serverInPath = os.path.join(self.serverDir, "in")
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
if self.channelInPath and os.path.exists(self.channelInPath):
|
|
||||||
try:
|
|
||||||
self.write_line(self.channelInPath, f"/part {self.channel}")
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if self.serverInPath and os.path.exists(self.serverInPath):
|
|
||||||
try:
|
|
||||||
self.write_line(self.serverInPath, "/quit")
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if self.iiProcess and self.iiProcess.poll() is None:
|
|
||||||
self.iiProcess.terminate()
|
|
||||||
try:
|
|
||||||
self.iiProcess.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
self.iiProcess.kill()
|
|
||||||
|
|
||||||
def join_channel(self):
|
|
||||||
joinMessage = f"/join {self.channel}"
|
|
||||||
channelDir = os.path.join(self.serverDir, self.channel)
|
|
||||||
channelAltDir = os.path.join(self.serverDir, self.channel.lstrip("#"))
|
|
||||||
startTime = time.monotonic()
|
|
||||||
nextJoinTime = startTime
|
|
||||||
while time.monotonic() - startTime < 60:
|
|
||||||
if time.monotonic() >= nextJoinTime:
|
|
||||||
self.write_line(self.serverInPath, joinMessage)
|
|
||||||
nextJoinTime = time.monotonic() + 5
|
|
||||||
for candidate in (channelDir, channelAltDir):
|
|
||||||
inPath = os.path.join(candidate, "in")
|
|
||||||
if os.path.exists(inPath):
|
|
||||||
self.channelInPath = inPath
|
|
||||||
return
|
|
||||||
time.sleep(0.5)
|
|
||||||
self.channelInPath = None
|
|
||||||
|
|
||||||
def send_channel_message(self, message):
|
|
||||||
if not self.channelInPath:
|
|
||||||
self.refresh_channel_in_path()
|
|
||||||
if self.channelInPath and os.path.exists(self.channelInPath):
|
|
||||||
self.write_line(self.channelInPath, message)
|
|
||||||
else:
|
|
||||||
self.write_line(self.serverInPath, f"/msg {self.channel} {message}")
|
|
||||||
|
|
||||||
def send_private_message(self, nick, message):
|
|
||||||
nickDir = os.path.join(self.serverDir, nick)
|
|
||||||
inPath = os.path.join(nickDir, "in")
|
|
||||||
if os.path.exists(inPath):
|
|
||||||
self.write_line(inPath, message)
|
|
||||||
else:
|
|
||||||
self.write_line(self.serverInPath, f"/msg {nick} {message}")
|
|
||||||
|
|
||||||
def get_private_messages(self, allowedUsers):
|
|
||||||
messages = []
|
|
||||||
for nick in allowedUsers:
|
|
||||||
nickDir = os.path.join(self.serverDir, nick)
|
|
||||||
outPath = os.path.join(nickDir, "out")
|
|
||||||
if not os.path.exists(outPath):
|
|
||||||
continue
|
|
||||||
lastPos = self.pmOffsets.get(outPath, 0)
|
|
||||||
with open(outPath, "r", encoding="utf-8", errors="ignore") as fileHandle:
|
|
||||||
fileHandle.seek(lastPos)
|
|
||||||
for line in fileHandle:
|
|
||||||
sender = parse_sender(line)
|
|
||||||
if sender and sender == self.nick:
|
|
||||||
continue
|
|
||||||
if sender and sender != nick:
|
|
||||||
continue
|
|
||||||
messageText = extract_message_text(line)
|
|
||||||
if messageText:
|
|
||||||
messages.append((nick, messageText))
|
|
||||||
self.pmOffsets[outPath] = fileHandle.tell()
|
|
||||||
return messages
|
|
||||||
|
|
||||||
def refresh_channel_in_path(self):
|
|
||||||
channelDir = os.path.join(self.serverDir, self.channel)
|
|
||||||
channelAltDir = os.path.join(self.serverDir, self.channel.lstrip("#"))
|
|
||||||
for candidate in (channelDir, channelAltDir):
|
|
||||||
inPath = os.path.join(candidate, "in")
|
|
||||||
if os.path.exists(inPath):
|
|
||||||
self.channelInPath = inPath
|
|
||||||
return
|
|
||||||
|
|
||||||
def wait_for_server_dir(self):
|
|
||||||
for _ in range(120):
|
|
||||||
for rootDir in [self.baseDir] + list_subdirs(self.baseDir):
|
|
||||||
if not os.path.isdir(rootDir):
|
|
||||||
continue
|
|
||||||
for entry in os.listdir(rootDir):
|
|
||||||
path = os.path.join(rootDir, entry)
|
|
||||||
if os.path.isdir(path) and self.server in entry:
|
|
||||||
inPath = os.path.join(path, "in")
|
|
||||||
if os.path.exists(inPath):
|
|
||||||
return path
|
|
||||||
time.sleep(0.5)
|
|
||||||
raise RuntimeError("ii server directory not found")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def write_line(path, message):
|
|
||||||
with open(path, "w", encoding="utf-8", errors="ignore") as fileHandle:
|
|
||||||
fileHandle.write(message + "\n")
|
|
||||||
fileHandle.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def ii_supports_i():
|
|
||||||
result = run_command(["ii", "-h"])
|
|
||||||
output = (result.stdout or "") + (result.stderr or "")
|
|
||||||
return "-i" in output
|
|
||||||
|
|
||||||
|
|
||||||
def list_subdirs(path):
|
|
||||||
try:
|
|
||||||
return [os.path.join(path, entry) for entry in os.listdir(path)]
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def shutil_which(command):
|
|
||||||
for path in os.environ.get("PATH", "").split(os.pathsep):
|
|
||||||
candidate = os.path.join(path, command)
|
|
||||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def build_nick():
|
|
||||||
baseUser = os.environ.get("SUDO_USER") or os.environ.get("USER") or "sas"
|
|
||||||
return f"{baseUser}-{int(time.time())}"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
say_or_print("Checking accessibility. Is your screen reader working? (y/n)", True)
|
|
||||||
answer = input().strip().lower()
|
|
||||||
useSpeech = answer in ("n", "no")
|
|
||||||
|
|
||||||
shouldRemoveUser = False
|
|
||||||
cleanupDone = False
|
|
||||||
tempDir = tempfile.mkdtemp(prefix="sas-ii-")
|
|
||||||
ircSession = None
|
|
||||||
sshProcess = None
|
|
||||||
|
|
||||||
def cleanup(exitMessage=None):
|
|
||||||
nonlocal cleanupDone
|
|
||||||
if cleanupDone:
|
|
||||||
return
|
|
||||||
cleanupDone = True
|
|
||||||
nonlocal sshProcess
|
|
||||||
if exitMessage:
|
|
||||||
say_or_print(exitMessage, useSpeech)
|
|
||||||
|
|
||||||
if sshProcess and sshProcess.poll() is None:
|
|
||||||
sshProcess.terminate()
|
|
||||||
try:
|
|
||||||
sshProcess.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
sshProcess.kill()
|
|
||||||
|
|
||||||
if ircSession:
|
|
||||||
ircSession.stop()
|
|
||||||
|
|
||||||
if shouldRemoveUser:
|
|
||||||
try:
|
|
||||||
run_privileged(["pkill", "-u", sasUser], useSpeech, check=False)
|
|
||||||
time.sleep(1)
|
|
||||||
result = run_privileged(["userdel", "-r", sasUser], useSpeech, check=False)
|
|
||||||
run_privileged(["rm", "-rf", f"/home/{sasUser}"], useSpeech, check=False)
|
|
||||||
if result.returncode != 0 and user_exists(sasUser):
|
|
||||||
say_or_print(
|
|
||||||
"Cleanup warning: failed to remove sas user. Please remove it manually.",
|
|
||||||
useSpeech,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sudoKeepaliveStop.set()
|
|
||||||
if sudoKeepaliveThread:
|
|
||||||
sudoKeepaliveThread.join(timeout=2)
|
|
||||||
|
|
||||||
try:
|
|
||||||
remove_tree(tempDir)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def handle_signal(signum, frame):
|
|
||||||
cleanup("Interrupted. Cleaning up.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, handle_signal)
|
|
||||||
signal.signal(signal.SIGTERM, handle_signal)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not user_exists(sasUser):
|
|
||||||
run_privileged(
|
|
||||||
["useradd", "-m", "-d", f"/home/{sasUser}", "-s", "/bin/bash", "-G", "wheel", sasUser],
|
|
||||||
useSpeech,
|
|
||||||
)
|
|
||||||
shouldRemoveUser = True
|
|
||||||
else:
|
|
||||||
say_or_print(
|
|
||||||
"User 'sas' exists. Remove and recreate it? This will delete /home/sas. (y/n)",
|
|
||||||
useSpeech,
|
|
||||||
)
|
|
||||||
response = input().strip().lower()
|
|
||||||
if response not in ("y", "yes"):
|
|
||||||
cleanup("The sas user is unavailable. Remove it manually and try again.")
|
|
||||||
return 1
|
|
||||||
run_privileged(["pkill", "-u", sasUser], useSpeech, check=False)
|
|
||||||
run_privileged(["userdel", "-r", sasUser], useSpeech, check=False)
|
|
||||||
run_privileged(["rm", "-rf", f"/home/{sasUser}"], useSpeech, check=False)
|
|
||||||
run_privileged(
|
|
||||||
["useradd", "-m", "-d", f"/home/{sasUser}", "-s", "/bin/bash", "-G", "wheel", sasUser],
|
|
||||||
useSpeech,
|
|
||||||
)
|
|
||||||
shouldRemoveUser = True
|
|
||||||
|
|
||||||
ensure_wheel(sasUser, useSpeech)
|
|
||||||
|
|
||||||
password = generate_password()
|
|
||||||
set_password(sasUser, password, useSpeech)
|
|
||||||
|
|
||||||
privateKeyPath, publicKeyPath = generate_ssh_key(sasUser, useSpeech)
|
|
||||||
sasHome = get_user_home(sasUser)
|
|
||||||
knownHostsPath = os.path.join(sasHome, ".ssh", "known_hosts_sas")
|
|
||||||
run_privileged(["touch", knownHostsPath], useSpeech)
|
|
||||||
run_privileged(["chmod", "600", knownHostsPath], useSpeech)
|
|
||||||
run_privileged(["chown", f"{sasUser}:{sasUser}", knownHostsPath], useSpeech)
|
|
||||||
|
|
||||||
nick = build_nick()
|
|
||||||
ircSession = IrcSession(ircServer, ircPort, nick, ircChannel, tempDir)
|
|
||||||
ircSession.start()
|
|
||||||
ircSession.join_channel()
|
|
||||||
|
|
||||||
say_or_print("Waiting for assistance on IRC.", useSpeech)
|
|
||||||
startTime = time.monotonic()
|
|
||||||
nextPingTime = startTime
|
|
||||||
pingsSent = 0
|
|
||||||
confirmedAdmin = None
|
|
||||||
|
|
||||||
while time.monotonic() - startTime < pingIntervalSeconds * pingCount:
|
|
||||||
now = time.monotonic()
|
|
||||||
if pingsSent < pingCount and now >= nextPingTime:
|
|
||||||
ircSession.send_channel_message(f"{nick} is requesting assistance.")
|
|
||||||
pingsSent += 1
|
|
||||||
nextPingTime = startTime + (pingsSent * pingIntervalSeconds)
|
|
||||||
|
|
||||||
for adminNick, messageText in ircSession.get_private_messages(stormuxAdmin):
|
|
||||||
if messageText.strip().lower() in ("yes", "accept"):
|
|
||||||
confirmedAdmin = adminNick
|
|
||||||
break
|
|
||||||
if confirmedAdmin:
|
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
if not confirmedAdmin:
|
|
||||||
cleanup("No one was available to help, please try again later.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
ircSession.send_private_message(
|
|
||||||
confirmedAdmin,
|
|
||||||
f'password: "{password}" please send wormhole ssh invite code',
|
|
||||||
)
|
|
||||||
|
|
||||||
failures = 0
|
|
||||||
while failures < maxWormholeFailures:
|
|
||||||
inviteCode = None
|
|
||||||
while inviteCode is None:
|
|
||||||
for adminNick, messageText in ircSession.get_private_messages(stormuxAdmin):
|
|
||||||
inviteCode = find_wormhole_code(messageText)
|
|
||||||
if inviteCode:
|
|
||||||
break
|
|
||||||
if inviteCode:
|
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
if not path_exists_for_user(publicKeyPath, sasUser, useSpeech):
|
|
||||||
raise RuntimeError(f"Public key missing: {publicKeyPath}")
|
|
||||||
|
|
||||||
wormholeCommand = [
|
|
||||||
"wormhole",
|
|
||||||
"ssh",
|
|
||||||
"accept",
|
|
||||||
"--yes",
|
|
||||||
inviteCode,
|
|
||||||
]
|
|
||||||
result = run_as_user(sasUser, wormholeCommand, useSpeech, check=False)
|
|
||||||
if result.returncode == 0:
|
|
||||||
say_or_print("Wormhole key transfer succeeded.", useSpeech)
|
|
||||||
break
|
|
||||||
|
|
||||||
failures += 1
|
|
||||||
errorTextFull = (result.stderr or result.stdout or "").strip()
|
|
||||||
if errorTextFull and not useSpeech:
|
|
||||||
print("Wormhole ssh accept error:", flush=True)
|
|
||||||
print(errorTextFull, flush=True)
|
|
||||||
errorText = errorTextFull
|
|
||||||
if errorText:
|
|
||||||
errorText = " ".join(errorText.split())
|
|
||||||
if len(errorText) > 400:
|
|
||||||
errorText = errorText[:400] + "..."
|
|
||||||
ircSession.send_private_message(
|
|
||||||
confirmedAdmin,
|
|
||||||
f"Wormhole ssh accept failed: {errorText}",
|
|
||||||
)
|
|
||||||
ircSession.send_private_message(
|
|
||||||
confirmedAdmin,
|
|
||||||
"Wormhole ssh accept failed. Please send a new invite code.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if failures >= maxWormholeFailures:
|
|
||||||
cleanup("Wormhole failed too many times. Exiting.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
say_or_print("Starting reverse SSH tunnel. Press Ctrl+C to stop.", useSpeech)
|
|
||||||
sshCommand = [
|
|
||||||
"ssh",
|
|
||||||
"-N",
|
|
||||||
"-R",
|
|
||||||
"localhost:2232:localhost:22",
|
|
||||||
"-o",
|
|
||||||
"ExitOnForwardFailure=yes",
|
|
||||||
"-o",
|
|
||||||
"BatchMode=yes",
|
|
||||||
"-o",
|
|
||||||
"ServerAliveInterval=30",
|
|
||||||
"-o",
|
|
||||||
"ServerAliveCountMax=3",
|
|
||||||
"-o",
|
|
||||||
"StrictHostKeyChecking=accept-new",
|
|
||||||
"-o",
|
|
||||||
f"UserKnownHostsFile={knownHostsPath}",
|
|
||||||
"-i",
|
|
||||||
privateKeyPath,
|
|
||||||
f"{sasUser}@{remoteHost}",
|
|
||||||
]
|
|
||||||
sshCommand = ["sudo", "-u", sasUser, "-H"] + sshCommand
|
|
||||||
sshProcess = subprocess.Popen(sshCommand)
|
|
||||||
sshProcess.wait()
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
cleanup(f"Error: {exc}")
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def remove_tree(path):
|
|
||||||
if not os.path.exists(path):
|
|
||||||
return
|
|
||||||
for rootDir, dirNames, fileNames in os.walk(path, topdown=False):
|
|
||||||
for fileName in fileNames:
|
|
||||||
try:
|
|
||||||
os.unlink(os.path.join(rootDir, fileName))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
for dirName in dirNames:
|
|
||||||
try:
|
|
||||||
os.rmdir(os.path.join(rootDir, dirName))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
os.rmdir(path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -18,6 +18,7 @@ btrfs-progs
|
|||||||
clonezilla
|
clonezilla
|
||||||
cloud-init
|
cloud-init
|
||||||
cryptsetup
|
cryptsetup
|
||||||
|
curl
|
||||||
darkhttpd
|
darkhttpd
|
||||||
ddrescue
|
ddrescue
|
||||||
dhcpcd
|
dhcpcd
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ file_permissions=(
|
|||||||
["/etc/shadow"]="0:0:400"
|
["/etc/shadow"]="0:0:400"
|
||||||
["/root"]="0:0:750"
|
["/root"]="0:0:750"
|
||||||
["/root/.automated_script.sh"]="0:0:755"
|
["/root/.automated_script.sh"]="0:0:755"
|
||||||
["/usr/local/bin/sas"]="0:0:755"
|
|
||||||
["/root/customize_airootfs.sh"]="0:0:755"
|
["/root/customize_airootfs.sh"]="0:0:755"
|
||||||
["/root/.gnupg"]="0:0:700"
|
["/root/.gnupg"]="0:0:700"
|
||||||
["/root/.config"]="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/install-stormux"]="0:0:755"
|
||||||
["/usr/local/bin/stormux-setup.sh"]="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/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"
|
["/usr/share/fenrirscreenreader/scripts/ssh-login-monitor.sh"]="0:0:755"
|
||||||
["/etc/stormux-assist"]="0:0:755"
|
["/etc/stormux-assist"]="0:0:755"
|
||||||
["/etc/stormux-assist/client.conf"]="0:0:644"
|
["/etc/stormux-assist/client.conf"]="0:0:644"
|
||||||
|
|||||||
Reference in New Issue
Block a user