"""Explicit SSH service starts; no remote shell interpolation or automatic restarts.""" import concurrent.futures import configparser import json import os from pathlib import Path import re import shutil import subprocess import time import urllib.request def load_targets(config_path, mode): if mode not in ('controller', 'all'): return [] config=configparser.ConfigParser(interpolation=None) config.read(config_path, encoding='utf-8-sig') if not config.getboolean('remote', 'enabled', fallback=False): return [] raw=json.loads(config.get('remote', 'cabinets', fallback='[]')) if not isinstance(raw,list) or not raw: raise ValueError('remote.cabinets must be a nonempty JSON list') ssh=shutil.which('ssh') if not ssh: raise ValueError('OpenSSH client (ssh) is required for remote cabinet startup') targets=[] seen=set() for item in raw: if not isinstance(item,dict): raise ValueError('Each remote cabinet must be an object') cabinet,host,user=(item.get(k,'') for k in ('id','host','user')) for value,pattern,label in [(cabinet,r'[A-Za-z0-9_]+','cabinet ID'),(host,r'[A-Za-z0-9][A-Za-z0-9.-]*','host'),(user,r'[A-Za-z_][A-Za-z0-9_-]*','SSH user')]: if not isinstance(value,str) or not re.fullmatch(pattern,value): raise ValueError('Invalid remote '+label) if cabinet in seen: raise ValueError('Duplicate remote cabinet ID') seen.add(cabinet) port=item.get('port',22) if type(port) is not int or not 1<=port<=65535: raise ValueError('Invalid SSH port') command=[ssh,'-T','-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','ConnectTimeout=5','-o','ConnectionAttempts=1','-p',str(port)] if item.get('identity_file'): key=(Path(config_path).resolve().parent / Path(item['identity_file']).expanduser()).resolve() if not key.is_file(): raise ValueError('SSH identity file not found: '+str(key)) command+=['-i',str(key),'-o','IdentitiesOnly=yes'] command += [user+'@'+host,'systemctl','--user','start','arcade-cabinet.service'] targets.append(dict(id=cabinet,host=host,command=command)) return targets def start_one(target): try: result=subprocess.run(target['command'],stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,timeout=15,creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0) if result.returncode: return target['id'],False,(result.stderr or result.stdout).strip()[-1500:] return target['id'],True,'Service start accepted (already-running services are left running)' except (OSError,subprocess.TimeoutExpired) as exc: return target['id'],False,str(exc) def start_and_check(targets,controller): if not targets: return print('Starting configured Pi services over SSH...',flush=True) with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: for cabinet,ok,detail in executor.map(start_one,targets): print(f"{cabinet}: {'SSH OK' if ok else 'SSH FAILED'} - {detail}",flush=True) pending={t['id'] for t in targets} deadline=time.monotonic()+15 while pending and time.monotonic()