diff --git a/README.md b/README.md index 91518ae..00fbb1b 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,97 @@ Combos are configurable in `config.json → hotkeys` using pynput syntax (` None: + """Ensure the pywin32 sub-packages are importable. + + When Windows starts the service it runs ``pythonservice.exe`` with a + minimal ``sys.path`` that may NOT include the pywin32 directories + (``site-packages\\win32``, ``win32\\lib``, ``Pythonwin``) where + ``servicemanager``/``win32event``/... actually live. Importing them would + then fail with ``ModuleNotFoundError: No module named 'servicemanager'`` + and the service times out (error 1053). We add those directories (and the + DLL directory) up front so the host can import them regardless of how it + was launched. + """ + # Always put the project root first so "import iceyou_service" and any + # "from iceyou.xxx" imports succeed even if the service host's cwd or + # initial sys.path does not contain the directory where this file lives. + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + + site_dirs = [] + # The project's virtualenv is the primary, reliable location. + site_dirs.append(PROJECT_ROOT / ".venv" / "Lib" / "site-packages") + try: + import site + for p in site.getsitepackages(): + site_dirs.append(Path(p)) + except Exception: + pass + # Fallback: site-packages relative to the running interpreter. + site_dirs.append(Path(sys.prefix) / "Lib" / "site-packages") + + seen = set() + for sp in site_dirs: + if not sp or sp in seen or not sp.is_dir(): + continue + seen.add(sp) + for sub in ("win32", os.path.join("win32", "lib"), "Pythonwin"): + d = sp / sub + if d.is_dir() and str(d) not in sys.path: + sys.path.append(str(d)) + dll_dir = sp / "pywin32_system32" + if dll_dir.is_dir(): + os.environ["PATH"] = str(dll_dir) + os.pathsep + os.environ.get("PATH", "") + try: + os.add_dll_directory(str(dll_dir)) + except (AttributeError, OSError): + pass + + +def _early_crash_dump(exc: BaseException) -> None: + """Write any import-time exception to a file so we can see it even if + the service host never reaches our normal logger.""" + try: + with open(PROJECT_ROOT / "iceyou_service_startup_error.log", "a", encoding="utf-8") as f: + import traceback + f.write("\n" + "=" * 80 + "\n") + f.write("ICEYOU service startup crash at " + __import__("datetime").datetime.now().isoformat() + "\n") + f.write("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) + f.write("=" * 80 + "\n") + except Exception: + pass + + +try: + _bootstrap_pywin32_path() + + import servicemanager + import win32event + import win32service + import win32serviceutil + import win32ts + import win32con + import win32process + import win32profile + import win32api +except Exception as _bootstrap_exc: # pragma: no cover - only triggered on host failure + _early_crash_dump(_bootstrap_exc) + raise + +MAIN_SCRIPT = PROJECT_ROOT / "main.py" +LOG_FILE = PROJECT_ROOT / "iceyou_service.log" + +# How often (seconds) the supervisor re-checks the active session / child. +POLL_INTERVAL = 3.0 + +# Sentinel returned by WTSGetActiveConsoleSessionId when no one is at the +# physical console (e.g. nobody logged in, or a fast-user-switch transition). +NO_ACTIVE_SESSION = 0xFFFFFFFF + + +def _find_pythonw() -> str: + """Locate the windowed Python interpreter used to run the GUI agent. + + Prefer the project's virtualenv (so the agent gets the right packages), + then a pythonw next to the current interpreter, then plain python. + """ + candidates = [ + PROJECT_ROOT / ".venv" / "Scripts" / "pythonw.exe", + Path(sys.executable).with_name("pythonw.exe"), + PROJECT_ROOT / ".venv" / "Scripts" / "python.exe", + Path(sys.executable), + ] + for c in candidates: + if c.exists(): + return str(c) + # Last resort: hope pythonw is on PATH. + return "pythonw.exe" + + +def _setup_logging() -> logging.Logger: + logger = logging.getLogger("iceyou.service") + logger.setLevel(logging.INFO) + if not logger.handlers: + try: + handler = logging.FileHandler(LOG_FILE, encoding="utf-8") + except Exception: + handler = logging.StreamHandler() + handler.setFormatter( + logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") + ) + logger.addHandler(handler) + return logger + + +class ICEYOUService(win32serviceutil.ServiceFramework): + _svc_name_ = "ICEYOUMonitor" + _svc_display_name_ = "ICEYOU Anti-Intrusion Monitor" + _svc_description_ = ( + "Supervises the ICEYOU anti-intrusion GUI agent, launching it into the " + "active interactive desktop session so the screen lock-out and tray " + "icon are visible to the logged-in user. Runs without an interactive " + "session and survives logoff/logon." + ) + + # Tell the SCM we want to be notified of console session changes so we can + # react immediately to logon/unlock rather than only via polling. + def __init__(self, args): + super().__init__(args) + # Both auto-reset, initially non-signalled. + self._stop_event = win32event.CreateEvent(None, 0, 0, None) + self._wake_event = win32event.CreateEvent(None, 0, 0, None) + self._stopping = False + self._child_handle = None # PyHANDLE of the running agent process + self._child_session = None # session id the agent was launched in + self.log = _setup_logging() + + # ---- SCM control handlers ------------------------------------------ + def SvcStop(self): + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + self.log.info("Stop requested; terminating agent and shutting down.") + self._stopping = True + self._terminate_child() + win32event.SetEvent(self._stop_event) + + def SvcDoRun(self): + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, ""), + ) + # MUST report RUNNING promptly (< ~30 s) or SCM times out with error 1053. + self.ReportServiceStatus(win32service.SERVICE_RUNNING) + self.log.info("ICEYOU service starting. project_root=%s", PROJECT_ROOT) + try: + self._run_supervisor() + except Exception: # never let the service crash silently + self.log.exception("Supervisor loop crashed.") + self.log.info("ICEYOU service stopped.") + + def SvcOtherEx(self, control, event_type, data): + # React promptly to logon / unlock by nudging the supervisor loop. + if control == win32service.SERVICE_CONTROL_SESSIONCHANGE: + self.log.info("Session change event: type=%s", event_type) + win32event.SetEvent(self._wake_event) + + # ---- Supervisor loop ------------------------------------------------ + def _run_supervisor(self): + pythonw = _find_pythonw() + self.log.info("GUI agent interpreter: %s", pythonw) + self.log.info("GUI agent script: %s", MAIN_SCRIPT) + + while True: + # Wake on: stop request, session change, or poll timeout. + rc = win32event.WaitForMultipleObjects( + (self._stop_event, self._wake_event), + False, + int(POLL_INTERVAL * 1000), + ) + if rc == win32event.WAIT_OBJECT_0 or self._stopping: + break # stop event signalled + + try: + self._ensure_agent(pythonw) + except Exception: + self.log.exception("Error while ensuring agent is running.") + + def _ensure_agent(self, pythonw: str): + """Make sure exactly one agent is running in the active session.""" + session_id = win32ts.WTSGetActiveConsoleSessionId() + + if session_id == NO_ACTIVE_SESSION: + # No one at the console: nothing to protect. Make sure no stale + # agent lingers and wait for a logon. + if self._child_handle is not None: + self.log.info("No active session; terminating stale agent.") + self._terminate_child() + return + + child_alive = self._child_alive() + + if child_alive and self._child_session == session_id: + return # already running in the right session + + if child_alive and self._child_session != session_id: + # Active session changed (e.g. fast user switch). Move the agent. + self.log.info( + "Active session changed %s -> %s; relaunching agent.", + self._child_session, session_id, + ) + self._terminate_child() + + self._launch_agent(pythonw, session_id) + + def _child_alive(self) -> bool: + if self._child_handle is None: + return False + rc = win32event.WaitForSingleObject(self._child_handle, 0) + return rc == win32event.WAIT_TIMEOUT # still running + + def _terminate_child(self): + if self._child_handle is None: + return + try: + if self._child_alive(): + win32process.TerminateProcess(self._child_handle, 0) + except Exception: + self.log.exception("Failed to terminate agent process.") + finally: + try: + win32api.CloseHandle(self._child_handle) + except Exception: + pass + self._child_handle = None + self._child_session = None + + def _launch_agent(self, pythonw: str, session_id: int): + """Spawn the GUI agent inside the given interactive session.""" + user_token = None + env = None + try: + # Requires SE_TCB_NAME, which LocalSystem holds. + user_token = win32ts.WTSQueryUserToken(session_id) + + try: + env = win32profile.CreateEnvironmentBlock(user_token, False) + creation_flags = ( + win32con.CREATE_NO_WINDOW + | win32con.CREATE_UNICODE_ENVIRONMENT + ) + except Exception: + self.log.warning( + "CreateEnvironmentBlock failed; launching without env block." + ) + env = None + creation_flags = win32con.CREATE_NO_WINDOW + + startup = win32process.STARTUPINFO() + # Target the interactive default desktop so windows are visible. + startup.lpDesktop = "winsta0\\default" + + cmdline = f'"{pythonw}" "{MAIN_SCRIPT}"' + + proc_info = win32process.CreateProcessAsUser( + user_token, # hToken + None, # appName (use cmdline) + cmdline, # commandLine + None, # processAttributes + None, # threadAttributes + False, # inheritHandles + creation_flags, + env, # environment + str(PROJECT_ROOT), # currentDirectory + startup, + ) + h_process, h_thread, pid, tid = proc_info + try: + win32api.CloseHandle(h_thread) + except Exception: + pass + + self._child_handle = h_process + self._child_session = session_id + self.log.info( + "Launched ICEYOU agent in session %s (pid=%s).", session_id, pid + ) + except Exception: + self.log.exception( + "Failed to launch agent in session %s.", session_id + ) + finally: + if user_token is not None: + try: + win32api.CloseHandle(user_token) + except Exception: + pass + + + +def _service_command_line() -> str: + """Full command the SCM should launch to run this service. + + We bypass pywin32's ``pythonservice.exe`` host entirely and register the + venv's ``python.exe`` running THIS script directly. When the SCM starts it + with no extra arguments, ``main()`` calls ``StartServiceCtrlDispatcher`` and + connects to the SCM. This avoids all the host-exe path/module problems that + caused the silent error 1053. + """ + py = PROJECT_ROOT / ".venv" / "Scripts" / "python.exe" + if not py.exists(): + py = Path(sys.executable) + script = Path(__file__).resolve() + return f'"{py}" "{script}"' + + +def _install_service(startup_type: int = win32service.SERVICE_AUTO_START) -> None: + cmdline = _service_command_line() + hscm = win32service.OpenSCManager(None, None, win32service.SC_MANAGER_ALL_ACCESS) + try: + # Remove a stale registration first so we always get a clean binPath. + try: + old = win32service.OpenService( + hscm, ICEYOUService._svc_name_, win32service.SERVICE_ALL_ACCESS + ) + win32service.DeleteService(old) + win32service.CloseServiceHandle(old) + except Exception: + pass + + hs = win32service.CreateService( + hscm, + ICEYOUService._svc_name_, + ICEYOUService._svc_display_name_, + win32service.SERVICE_ALL_ACCESS, + win32service.SERVICE_WIN32_OWN_PROCESS, + startup_type, + win32service.SERVICE_ERROR_NORMAL, + cmdline, + None, # load order group + 0, # tag id + None, # dependencies + None, # account (LocalSystem) + None, # password + ) + try: + win32service.ChangeServiceConfig2( + hs, + win32service.SERVICE_CONFIG_DESCRIPTION, + ICEYOUService._svc_description_, + ) + except Exception: + pass + win32service.CloseServiceHandle(hs) + print(f"Service {ICEYOUService._svc_name_} installed (auto-start).") + print(f" binPath = {cmdline}") + finally: + win32service.CloseServiceHandle(hscm) + + +def _remove_service() -> None: + try: + win32serviceutil.StopService(ICEYOUService._svc_name_) + except Exception: + pass + try: + win32serviceutil.RemoveService(ICEYOUService._svc_name_) + print(f"Service {ICEYOUService._svc_name_} removed.") + except Exception as e: + print(f"Remove failed: {e}") + + +def _run_as_service() -> None: + """Entry point used when the SCM launches us as a service process.""" + try: + servicemanager.Initialize() + servicemanager.PrepareToHostSingle(ICEYOUService) + servicemanager.StartServiceCtrlDispatcher() + except Exception as exc: + _early_crash_dump(exc) + raise + + +def main(): + if len(sys.argv) == 1: + # No args => launched by the Service Control Manager. + _run_as_service() + return + + args_lower = [a.lower() for a in sys.argv[1:]] + + if "install" in args_lower: + startup = ( + win32service.SERVICE_DISABLED + if "disabled" in args_lower + else win32service.SERVICE_AUTO_START + ) + _install_service(startup) + elif "remove" in args_lower or "uninstall" in args_lower: + _remove_service() + elif "start" in args_lower: + win32serviceutil.StartService(ICEYOUService._svc_name_) + print("Service start requested.") + elif "stop" in args_lower: + win32serviceutil.StopService(ICEYOUService._svc_name_) + print("Service stop requested.") + elif "restart" in args_lower: + try: + win32serviceutil.StopService(ICEYOUService._svc_name_) + except Exception: + pass + win32serviceutil.StartService(ICEYOUService._svc_name_) + print("Service restarted.") + else: + # Anything else: defer to pywin32's standard handler. + win32serviceutil.HandleCommandLine(ICEYOUService) + + +if __name__ == "__main__": + main()