|
|
|
|
@@ -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())
|
|
|
|
|
|