computer-use-ootb-internal 0.0.122__py3-none-any.whl → 0.0.124__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,811 +1,811 @@
1
- # src/computer_use_ootb_internal/guard_service.py
2
- import sys
3
- import os
4
- import time
5
- import logging
6
- import subprocess
7
- import pathlib
8
- import ctypes
9
- import threading # For running server thread
10
- import queue # For queuing commands
11
- import requests # Keep for status reporting back
12
- import servicemanager # From pywin32
13
- import win32serviceutil # From pywin32
14
- import win32service # From pywin32
15
- import win32event # From pywin32
16
- import win32api # From pywin32
17
- import win32process # From pywin32
18
- import win32security # From pywin32
19
- import win32profile # From pywin32
20
- import win32ts # From pywin32 (Terminal Services API)
21
- import win32con
22
- import psutil # For process/user info
23
- from flask import Flask, request, jsonify # For embedded server
24
- from waitress import serve # For serving Flask app
25
- import json # Needed for status reporting
26
-
27
- # --- Configuration ---
28
- _SERVICE_NAME = "OOTBGuardService"
29
- _SERVICE_DISPLAY_NAME = "OOTB Guard Service"
30
- _SERVICE_DESCRIPTION = "Background service for OOTB monitoring and remote management (Server POST mode)."
31
- _PACKAGE_NAME = "computer-use-ootb-internal"
32
- _OOTB_MODULE = "computer_use_ootb_internal.app_teachmode"
33
- # --- Server POST Configuration ---
34
- _LISTEN_HOST = "0.0.0.0" # Listen on all interfaces
35
- _LISTEN_PORT = 14000 # Port for server to POST commands TO
36
- # _SHARED_SECRET = "YOUR_SECRET_HERE" # !! REMOVED !! - No secret check implemented now
37
- # --- End Server POST Configuration ---
38
- _SERVER_STATUS_REPORT_URL = "http://52.160.105.102:7000/guard/status" # URL to POST status back TO (Path changed)
39
- _LOG_FILE = pathlib.Path(os.environ['PROGRAMDATA']) / "OOTBGuardService" / "guard_post_mode.log" # Different log file
40
- # --- End Configuration ---
41
-
42
- _LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
43
- logging.basicConfig(
44
- filename=_LOG_FILE,
45
- level=logging.INFO,
46
- format='%(asctime)s %(levelname)s:%(name)s:%(threadName)s: %(message)s'
47
- )
48
-
49
- # --- Global service instance reference (needed for Flask routes) ---
50
- _service_instance = None
51
-
52
- # --- Flask App Definition ---
53
- flask_app = Flask(__name__)
54
-
55
- @flask_app.route('/command', methods=['POST'])
56
- def receive_command():
57
- global _service_instance
58
- if not _service_instance:
59
- logging.error("Received command but service instance is not set.")
60
- return jsonify({"error": "Service not ready"}), 503
61
-
62
- # --- Authentication REMOVED ---
63
- # secret = request.headers.get('X-Guard-Secret')
64
- # if not secret or secret != _SHARED_SECRET:
65
- # logging.warning(f"Unauthorized command POST received (Invalid/Missing X-Guard-Secret). Remote Addr: {request.remote_addr}")
66
- # return jsonify({"error": "Unauthorized"}), 403
67
- # --- End Authentication REMOVED ---
68
-
69
- if not request.is_json:
70
- logging.warning("Received non-JSON command POST.")
71
- return jsonify({"error": "Request must be JSON"}), 400
72
-
73
- command = request.get_json()
74
- logging.info(f"Received command via POST: {command}")
75
-
76
- # Basic validation
77
- action = command.get("action")
78
- command_id = command.get("command_id", "N/A") # Use for status reporting
79
- if not action:
80
- logging.error(f"Received command POST with no action: {command}")
81
- return jsonify({"error": "Missing 'action' in command"}), 400
82
-
83
- # Queue the command for processing in a background thread
84
- _service_instance.command_queue.put((command_id, command))
85
- logging.info(f"Queued command {command_id} ({action}) for processing.")
86
-
87
- return jsonify({"message": f"Command {command_id} received and queued"}), 202 # Accepted
88
-
89
- # --- Helper Functions --- Only logging helpers needed adjustments
90
- # Move these inside the class later
91
- # def get_python_executable(): ...
92
- # def get_pip_executable(): ...
93
-
94
- # Define loggers at module level for use before instance exists?
95
- # Or handle carefully within instance methods.
96
-
97
- # --- PowerShell Task Scheduler Helpers --- (These will become methods) ---
98
-
99
- # _TASK_NAME_PREFIX = "OOTB_UserLogon_" # Move to class
100
-
101
- # def run_powershell_command(command, log_output=True): ...
102
- # def create_or_update_logon_task(username, task_command, python_executable): ...
103
- # def remove_logon_task(username): ...
104
-
105
- # --- End PowerShell Task Scheduler Helpers ---
106
-
107
- class GuardService(win32serviceutil.ServiceFramework):
108
- _svc_name_ = _SERVICE_NAME
109
- _svc_display_name_ = _SERVICE_DISPLAY_NAME
110
- _svc_description_ = _SERVICE_DESCRIPTION
111
- _task_name_prefix = "OOTB_UserLogon_" # Class attribute for task prefix
112
-
113
- # --- Instance Logging Methods ---
114
- def log_info(self, msg):
115
- thread_name = threading.current_thread().name
116
- full_msg = f"[{thread_name}] {msg}"
117
- logging.info(full_msg)
118
- try:
119
- if threading.current_thread().name in ["MainThread", "CommandProcessor"]:
120
- servicemanager.LogInfoMsg(str(full_msg))
121
- except Exception as e:
122
- # Log only to file if event log fails
123
- logging.warning(f"(Instance) Could not write info to Windows Event Log: {e}")
124
-
125
- def log_error(self, msg, exc_info=False):
126
- thread_name = threading.current_thread().name
127
- full_msg = f"[{thread_name}] {msg}"
128
- logging.error(full_msg, exc_info=exc_info)
129
- try:
130
- if threading.current_thread().name in ["MainThread", "CommandProcessor"]:
131
- servicemanager.LogErrorMsg(str(full_msg))
132
- except Exception as e:
133
- logging.warning(f"(Instance) Could not write error to Windows Event Log: {e}")
134
- # --- End Instance Logging ---
135
-
136
- # --- Instance Helper Methods (Moved from module level) ---
137
- def get_base_python_executable(self):
138
- """Tries to find python.exe instead of pythonservice.exe if running as service."""
139
- service_exe = sys.executable
140
- self.log_info(f"get_base_python_executable: sys.executable is {service_exe}")
141
- # Normalize path for comparison
142
- service_exe_lower = service_exe.lower()
143
-
144
- # Check if running as pythonservice.exe
145
- if 'pythonservice.exe' in os.path.basename(service_exe_lower):
146
- # Construct expected python.exe path in the same directory
147
- dir_name = os.path.dirname(service_exe)
148
- potential_python_exe = os.path.join(dir_name, 'python.exe')
149
- self.log_info(f"get_base_python_executable: Checking for python.exe at {potential_python_exe}")
150
-
151
- if os.path.exists(potential_python_exe):
152
- self.log_info(f"get_base_python_executable: Found python.exe at {potential_python_exe}")
153
- # Quote if necessary
154
- final_exe = potential_python_exe
155
- if " " in final_exe and not final_exe.startswith('"'):
156
- return f'"{final_exe}"'
157
- return final_exe
158
- else:
159
- self.log_error(f"get_base_python_executable: Could not find python.exe near pythonservice.exe (checked {potential_python_exe}). Falling back to using {service_exe}.")
160
- # Fallback to original sys.executable (quoted if needed)
161
- if " " in service_exe and not service_exe.startswith('"'):
162
- return f'"{service_exe}"'
163
- return service_exe
164
- else: # Not running as pythonservice.exe, assume sys.executable is correct
165
- self.log_info(f"get_base_python_executable: sys.executable is not pythonservice.exe, using it directly.")
166
- if " " in service_exe and not service_exe.startswith('"'):
167
- return f'"{service_exe}"'
168
- return service_exe
169
-
170
- def get_python_executable(self):
171
- # This method is now just an alias for clarity if needed elsewhere,
172
- # but primary logic uses get_base_python_executable directly.
173
- # Keep for potential compatibility if anything still calls it.
174
- return self.get_base_python_executable()
175
-
176
- def get_pip_executable(self):
177
- """Tries to locate the pip executable in the same environment."""
178
- try:
179
- # Use the potentially corrected python path to find pip's location
180
- # Note: This assumes pip is relative to python.exe, not pythonservice.exe
181
- base_python = self.get_base_python_executable()
182
- python_path = pathlib.Path(base_python.strip('"')) # Use unquoted path
183
- pip_path = python_path.parent / "Scripts" / "pip.exe"
184
- self.log_info(f"get_pip_executable: Checking for pip relative to {python_path.parent} at {pip_path}")
185
-
186
- if pip_path.exists():
187
- self.log_info(f"get_pip_executable: pip.exe found at {pip_path}")
188
- pip_exe = str(pip_path)
189
- if " " in pip_exe and not pip_exe.startswith('"'):
190
- pip_exe = f'"{pip_exe}"'
191
- return pip_exe
192
- else:
193
- self.log_error(f"get_pip_executable: pip.exe NOT found at {pip_path}. Falling back to '{base_python} -m pip'.")
194
- pass
195
- except Exception as e:
196
- self.log_error(f"get_pip_executable: Error determining pip path: {e}", exc_info=True)
197
- self.log_error(f"get_pip_executable: Falling back to 'python -m pip' due to error.")
198
-
199
- # Fallback uses the potentially corrected python path
200
- return f"{self.get_base_python_executable()} -m pip"
201
-
202
- # --- PowerShell Methods (Moved from module level) ---
203
- def run_powershell_command(self, command, log_output=True):
204
- """Executes a PowerShell command and handles output/errors. Returns True on success."""
205
- self.log_info(f"Executing PowerShell: {command}")
206
- try:
207
- result = subprocess.run(
208
- ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command],
209
- capture_output=True, text=True, check=True, encoding='utf-8', errors='ignore'
210
- )
211
- if log_output and result.stdout:
212
- self.log_info(f"PowerShell STDOUT:\n{result.stdout.strip()}")
213
- if log_output and result.stderr:
214
- self.log_info(f"PowerShell STDERR:\n{result.stderr.strip()}")
215
- return True
216
- except FileNotFoundError:
217
- self.log_error("'powershell.exe' not found. Cannot manage scheduled tasks.")
218
- return False
219
- except subprocess.CalledProcessError as e:
220
- self.log_error(f"PowerShell command failed (Exit Code {e.returncode}):")
221
- self.log_error(f" Command: {e.cmd}")
222
- if e.stdout: self.log_error(f" STDOUT: {e.stdout.strip()}")
223
- if e.stderr: self.log_error(f" STDERR: {e.stderr.strip()}")
224
- return False
225
- except Exception as e:
226
- self.log_error(f"Unexpected error running PowerShell: {e}", exc_info=True)
227
- return False
228
-
229
- def create_or_update_logon_task(self, username, task_command, python_executable):
230
- """Creates or updates a scheduled task to run a command at user logon."""
231
- task_name = f"{self._task_name_prefix}{username}"
232
- safe_python_exe = python_executable.replace("'", "''")
233
- command_parts = task_command.split(' ', 1)
234
- if len(command_parts) > 1 and command_parts[0] == python_executable:
235
- safe_task_command_args = command_parts[1].replace("'", "''")
236
- else:
237
- safe_task_command_args = task_command.replace(python_executable, "").strip().replace("'", "''")
238
-
239
- safe_task_name = task_name.replace("'", "''")
240
- safe_username = username.replace("'", "''")
241
-
242
- action = f"$Action = New-ScheduledTaskAction -Execute '{safe_python_exe}' -Argument '{safe_task_command_args}'"
243
- trigger = f"$Trigger = New-ScheduledTaskTrigger -AtLogOn -User '{safe_username}'"
244
- principal = f"$Principal = New-ScheduledTaskPrincipal -UserId '{safe_username}' -LogonType Interactive"
245
- settings = "$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -DontStopOnIdleEnd -ExecutionTimeLimit ([System.TimeSpan]::Zero) -RunOnlyIfNetworkAvailable:$false"
246
-
247
- command = f"""
248
- try {{
249
- {action}
250
- {trigger}
251
- {principal}
252
- {settings}
253
- Register-ScheduledTask -TaskName '{safe_task_name}' -Action $Action -Trigger $Trigger -Principal $Principal -Settings $Settings -Force -ErrorAction Stop
254
- Write-Host "Scheduled task '{safe_task_name}' registered/updated successfully."
255
- }} catch {{
256
- Write-Error "Failed to register/update scheduled task '{safe_task_name}': $_"
257
- exit 1
258
- }}
259
- """
260
- success = self.run_powershell_command(command)
261
- if success:
262
- self.log_info(f"Successfully created/updated scheduled task '{task_name}' for user '{username}'.")
263
- else:
264
- self.log_error(f"Failed to create/update scheduled task '{task_name}' for user '{username}'.")
265
- return success
266
-
267
- def remove_logon_task(self, username):
268
- """Removes the logon scheduled task for a user."""
269
- task_name = f"{self._task_name_prefix}{username}"
270
- safe_task_name = task_name.replace("'", "''")
271
- command = f"Unregister-ScheduledTask -TaskName '{safe_task_name}' -Confirm:$false -ErrorAction SilentlyContinue"
272
- self.run_powershell_command(command, log_output=False)
273
- self.log_info(f"Attempted removal of scheduled task '{task_name}' for user '{username}'.")
274
- return True
275
- # --- End Instance Helper Methods ---
276
-
277
- def __init__(self, args):
278
- global _service_instance
279
- win32serviceutil.ServiceFramework.__init__(self, args)
280
- self.hWaitStop = win32event.CreateEvent(None, 0, 0, None)
281
- self.is_running = True
282
- self.server_thread = None
283
- self.command_queue = queue.Queue()
284
- self.command_processor_thread = None
285
- self.session = requests.Session()
286
-
287
- # Initialize paths using instance methods that prefer python.exe
288
- self.python_exe = self.get_base_python_executable()
289
- self.pip_command_base = self.get_pip_executable()
290
- # Construct command using the potentially corrected python.exe path
291
- self.ootb_command = f"{self.python_exe} -m {_OOTB_MODULE}"
292
- _service_instance = self
293
- self.log_info(f"Service initialized. OOTB command set to: {self.ootb_command}")
294
-
295
- def SvcStop(self):
296
- self.log_info(f"Service stop requested.")
297
- self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
298
- self.is_running = False
299
- # Signal the command processor thread to stop
300
- self.command_queue.put(None) # Sentinel value
301
- # Signal the main wait loop
302
- win32event.SetEvent(self.hWaitStop)
303
- # Stopping waitress gracefully from another thread is non-trivial.
304
- # We rely on the SCM timeout / process termination for now.
305
- self.log_info(f"{_SERVICE_NAME} SvcStop: Stop signaled. Server thread will be terminated by SCM.")
306
-
307
-
308
- def SvcDoRun(self):
309
- servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE,
310
- servicemanager.PYS_SERVICE_STARTED,
311
- (self._svc_name_, ''))
312
- try:
313
- self.log_info(f"{_SERVICE_NAME} starting.")
314
- # Start the command processor thread
315
- self.command_processor_thread = threading.Thread(
316
- target=self.process_commands, name="CommandProcessor", daemon=True)
317
- self.command_processor_thread.start()
318
- self.log_info("Command processor thread started.")
319
-
320
- # Start the Flask server (via Waitress) in a separate thread
321
- self.server_thread = threading.Thread(
322
- target=self.run_server, name="WebServerThread", daemon=True)
323
- self.server_thread.start()
324
- self.log_info(f"Web server thread started, listening on {_LISTEN_HOST}:{_LISTEN_PORT}.")
325
-
326
- self.log_info(f"{_SERVICE_NAME} running. Waiting for stop signal.")
327
- # Keep the main service thread alive waiting for stop signal
328
- win32event.WaitForSingleObject(self.hWaitStop, win32event.INFINITE)
329
- self.log_info(f"{_SERVICE_NAME} received stop signal in main thread.")
330
-
331
- except Exception as e:
332
- self.log_error(f"Fatal error in SvcDoRun: {e}", exc_info=True)
333
- self.SvcStop() # Signal stop if possible
334
- finally:
335
- self.log_info(f"{_SERVICE_NAME} SvcDoRun finished.")
336
-
337
-
338
- def run_server(self):
339
- """Runs the Flask app using Waitress."""
340
- self.log_info(f"Waitress server starting on {_LISTEN_HOST}:{_LISTEN_PORT}")
341
- try:
342
- serve(flask_app, host=_LISTEN_HOST, port=_LISTEN_PORT, threads=4)
343
- self.log_info("Waitress server has stopped.") # Should only happen on shutdown
344
- except Exception as e:
345
- self.log_error(f"Web server thread encountered an error: {e}", exc_info=True)
346
- # Consider signaling the main thread to stop if the web server fails critically
347
- # For now, just log the error.
348
-
349
-
350
- def process_commands(self):
351
- """Worker thread to process commands from the queue."""
352
- self.log_info("Command processor thread starting.")
353
- while self.is_running:
354
- try:
355
- item = self.command_queue.get(block=True, timeout=1) # Add timeout to check is_running periodically
356
- if item is None:
357
- self.log_info("Command processor received stop signal.")
358
- break # Exit loop
359
-
360
- command_id, command = item
361
- action = command.get("action")
362
- target = command.get("target_user", "all_active")
363
- status = "failed_unknown" # Default
364
-
365
- self.log_info(f"Dequeued Command ID {command_id}: action='{action}', target='{target}'")
366
-
367
- try:
368
- if action == "update":
369
- status = self.handle_update()
370
- elif action == "stop_ootb":
371
- status = self.handle_stop(target)
372
- elif action == "start_ootb":
373
- status = self.handle_start(target)
374
- else:
375
- self.log_error(f"Unknown action in queue: {action}")
376
- status = "failed_unknown_action"
377
- except Exception as handler_ex:
378
- self.log_error(f"Exception processing Command ID {command_id} ({action}): {handler_ex}", exc_info=True)
379
- status = "failed_exception"
380
- finally:
381
- self.report_command_status(command_id, status)
382
- self.command_queue.task_done()
383
-
384
- except queue.Empty:
385
- # Timeout occurred, just loop again and check self.is_running
386
- continue
387
- except Exception as e:
388
- self.log_error(f"Error in command processing loop: {e}", exc_info=True)
389
- if self.is_running:
390
- time.sleep(5)
391
-
392
- self.log_info("Command processor thread finished.")
393
-
394
-
395
- def report_command_status(self, command_id, status, details=""):
396
- """Sends command status back to the server."""
397
- if not _SERVER_STATUS_REPORT_URL:
398
- self.log_error("No server status report URL configured. Skipping report.")
399
- return
400
-
401
- payload = {
402
- "command_id": command_id,
403
- "status": status,
404
- "details": details,
405
- "machine_id": os.getenv('COMPUTERNAME', 'unknown_guard')
406
- }
407
- self.log_info(f"Reporting status for command {command_id}: {status}")
408
- try:
409
- response = self.session.post(_SERVER_STATUS_REPORT_URL, json=payload, timeout=15)
410
- response.raise_for_status()
411
- self.log_info(f"Status report for command {command_id} accepted by server.")
412
- except requests.exceptions.RequestException as e:
413
- self.log_error(f"Failed to report status for command {command_id}: {e}")
414
- except Exception as e:
415
- self.log_error(f"Unexpected error reporting status for command {command_id}: {e}", exc_info=True)
416
-
417
- # --- Command Handlers --- Now call self. for helpers
418
-
419
- def handle_update(self):
420
- self.log_info("Executing OOTB update...")
421
- if not self.pip_command_base:
422
- self.log_error("Cannot update: pip command not found.")
423
- return "failed_pip_not_found"
424
-
425
- update_command = f"{self.pip_command_base} install --upgrade --no-cache-dir {_PACKAGE_NAME}"
426
- self.log_info(f"Running update command: {update_command}")
427
- try:
428
- result = subprocess.run(update_command, shell=True, capture_output=True, text=True, check=True, timeout=300, encoding='utf-8')
429
- self.log_info(f"Update successful: \nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}")
430
- return "success"
431
- except subprocess.CalledProcessError as e:
432
- self.log_error(f"Update failed (Exit Code {e.returncode}):\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}")
433
- return f"failed_exit_{e.returncode}"
434
- except subprocess.TimeoutExpired:
435
- self.log_error(f"Update command timed out.")
436
- return "failed_timeout"
437
- except Exception as e:
438
- self.log_error(f"Unexpected error during update: {e}", exc_info=True)
439
- return "failed_exception"
440
-
441
- def _get_ootb_processes(self, target_user="all_active"):
442
- ootb_procs = []
443
- target_pid_list = []
444
- try:
445
- target_users = set()
446
- if target_user == "all_active":
447
- for user_session in psutil.users():
448
- username = user_session.name.split('\\')[-1]
449
- target_users.add(username.lower())
450
- else:
451
- target_users.add(target_user.lower())
452
- self.log_info(f"Searching for OOTB processes for users: {target_users}")
453
-
454
- # Use the potentially corrected python.exe path for matching
455
- python_exe_path_for_check = self.python_exe.strip('"')
456
- self.log_info(f"_get_ootb_processes: Checking against python path: {python_exe_path_for_check}")
457
-
458
- for proc in psutil.process_iter(['pid', 'name', 'username', 'cmdline', 'exe']):
459
- try:
460
- pinfo = proc.info
461
- proc_username = pinfo['username']
462
- if proc_username:
463
- proc_username = proc_username.split('\\')[-1].lower()
464
-
465
- if proc_username in target_users:
466
- cmdline = ' '.join(pinfo['cmdline']) if pinfo['cmdline'] else ''
467
- # Check if the process executable matches our corrected python path AND module is in cmdline
468
- if pinfo['exe'] and pinfo['exe'] == python_exe_path_for_check and _OOTB_MODULE in cmdline:
469
- self.log_info(f"Found matching OOTB process: PID={pinfo['pid']}, User={pinfo['username']}, Cmd={cmdline}")
470
- ootb_procs.append(proc)
471
- target_pid_list.append(pinfo['pid'])
472
- except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
473
- continue
474
- self.log_info(f"Found {len(ootb_procs)} OOTB process(es) matching criteria: {target_pid_list}")
475
- except Exception as e:
476
- self.log_error(f"Error enumerating processes: {e}", exc_info=True)
477
- return ootb_procs
478
-
479
- def handle_stop(self, target_user="all_active"):
480
- self.log_info(f"Executing stop OOTB for target '{target_user}'...")
481
- stop_results = {} # Track results per user {username: (task_status, immediate_status)}
482
- failed_users = set()
483
-
484
- try:
485
- # --- Get target users and active sessions ---
486
- active_sessions = {} # user_lower: session_id
487
- # No need for all_system_users for stop, we only care about active or the specific target
488
- try:
489
- sessions = win32ts.WTSEnumerateSessions(win32ts.WTS_CURRENT_SERVER_HANDLE)
490
- for session in sessions:
491
- if session['State'] == win32ts.WTSActive:
492
- try:
493
- user = win32ts.WTSQuerySessionInformation(win32ts.WTS_CURRENT_SERVER_HANDLE, session['SessionId'], win32ts.WTSUserName)
494
- if user:
495
- active_sessions[user.lower()] = session['SessionId']
496
- except Exception as query_err:
497
- self.log_error(f"Could not query session {session['SessionId']} during stop: {query_err}")
498
- except Exception as user_enum_err:
499
- self.log_error(f"Error enumerating users/sessions during stop: {user_enum_err}", exc_info=True)
500
- return "failed_user_enumeration"
501
-
502
- self.log_info(f"Stop target: '{target_user}'. Active sessions: {active_sessions}")
503
-
504
- target_users_normalized = set()
505
- if target_user == "all_active":
506
- # Target only currently active users for stop all
507
- target_users_normalized = set(active_sessions.keys())
508
- self.log_info(f"Stop targeting all active users: {target_users_normalized}")
509
- else:
510
- # Target the specific user, regardless of active status (for task removal)
511
- normalized_target = target_user.lower()
512
- target_users_normalized.add(normalized_target)
513
- self.log_info(f"Stop targeting specific user: {normalized_target}")
514
-
515
- if not target_users_normalized:
516
- self.log_info("No target users identified for stop.")
517
- return "failed_no_target_users" # Or success if none were targeted?
518
-
519
- # --- Process each target user ---
520
- for user in target_users_normalized:
521
- task_removed_status = "task_unknown"
522
- immediate_stop_status = "stop_not_attempted"
523
- stopped_count = 0
524
-
525
- self.log_info(f"Processing stop for user '{user}'...")
526
-
527
- # 1. Always try to remove the scheduled task
528
- try:
529
- # remove_logon_task always returns True for now, just logs attempt
530
- self.remove_logon_task(user)
531
- task_removed_status = "task_removed_attempted"
532
- except Exception as task_err:
533
- self.log_error(f"Exception removing scheduled task for {user}: {task_err}", exc_info=True)
534
- task_removed_status = "task_exception"
535
- failed_users.add(user)
536
- # Continue to try and stop process if active
537
-
538
- # 2. If user is active, try to terminate process
539
- is_active = user in active_sessions
540
-
541
- if is_active:
542
- immediate_stop_status = "stop_attempted"
543
- self.log_info(f"User '{user}' is active. Attempting to terminate OOTB process(es)...")
544
- # Pass the specific username to _get_ootb_processes
545
- procs_to_stop = self._get_ootb_processes(user)
546
-
547
- if not procs_to_stop:
548
- self.log_info(f"No running OOTB processes found for active user '{user}'.")
549
- immediate_stop_status = "stop_skipped_not_running"
550
- else:
551
- self.log_info(f"Found {len(procs_to_stop)} process(es) for user '{user}' to stop.")
552
- for proc in procs_to_stop:
553
- try:
554
- pid = proc.pid # Get pid before potential termination
555
- username = proc.info.get('username', 'unknown_user')
556
- self.log_info(f"Terminating process PID={pid}, User={username}")
557
- proc.terminate()
558
- try:
559
- proc.wait(timeout=3)
560
- self.log_info(f"Process PID={pid} terminated successfully.")
561
- stopped_count += 1
562
- except psutil.TimeoutExpired:
563
- self.log_error(f"Process PID={pid} did not terminate gracefully, killing.")
564
- proc.kill()
565
- stopped_count += 1
566
- except psutil.NoSuchProcess:
567
- self.log_info(f"Process PID={pid} already terminated.")
568
- # Don't increment stopped_count here as we didn't stop it now
569
- except psutil.AccessDenied:
570
- self.log_error(f"Access denied trying to terminate process PID={pid}.")
571
- failed_users.add(user) # Mark user as failed if stop fails
572
- except Exception as e:
573
- self.log_error(f"Error stopping process PID={pid}: {e}", exc_info=True)
574
- failed_users.add(user) # Mark user as failed
575
-
576
- # Determine status based on how many were found vs stopped
577
- if user in failed_users:
578
- immediate_stop_status = f"stop_errors_terminated_{stopped_count}_of_{len(procs_to_stop)}"
579
- elif stopped_count == len(procs_to_stop):
580
- immediate_stop_status = f"stop_success_terminated_{stopped_count}"
581
- else: # Should ideally not happen if NoSuchProcess doesn't count
582
- immediate_stop_status = f"stop_partial_terminated_{stopped_count}_of_{len(procs_to_stop)}"
583
-
584
- else: # User not active
585
- self.log_info(f"User '{user}' is not active. Skipping immediate process stop (task removal attempted).")
586
- immediate_stop_status = "stop_skipped_inactive"
587
-
588
- # Record final results for this user
589
- stop_results[user] = (task_removed_status, immediate_stop_status)
590
-
591
-
592
- # --- Consolidate status ---
593
- total_processed = len(target_users_normalized)
594
- final_status = "partial_success" if failed_users else "success"
595
- if not stop_results: final_status = "no_targets_processed"
596
- if len(failed_users) == total_processed and total_processed > 0 : final_status = "failed"
597
-
598
- self.log_info(f"Finished stopping OOTB. Overall Status: {final_status}. Results: {stop_results}")
599
- try:
600
- details = json.dumps(stop_results)
601
- except Exception:
602
- details = str(stop_results) # Fallback
603
- return f"{final_status}::{details}" # Use :: as separator
604
-
605
- except Exception as e:
606
- self.log_error(f"Error during combined stop OOTB process: {e}", exc_info=True)
607
- return "failed_exception"
608
-
609
-
610
- def handle_start(self, target_user="all_active"):
611
- self.log_info(f"Executing start OOTB for target '{target_user}'...")
612
- start_results = {} # Track results per user {username: (task_status, immediate_status)}
613
- failed_users = set()
614
-
615
- try:
616
- # --- Get target users and active sessions ---
617
- active_sessions = {} # user_lower: session_id
618
- all_system_users = set() # user_lower
619
- try:
620
- # Use psutil for system user list, WTS for active sessions/IDs
621
- for user_session in psutil.users():
622
- username_lower = user_session.name.split('\\')[-1].lower()
623
- all_system_users.add(username_lower)
624
-
625
- sessions = win32ts.WTSEnumerateSessions(win32ts.WTS_CURRENT_SERVER_HANDLE)
626
- for session in sessions:
627
- if session['State'] == win32ts.WTSActive:
628
- try:
629
- user = win32ts.WTSQuerySessionInformation(win32ts.WTS_CURRENT_SERVER_HANDLE, session['SessionId'], win32ts.WTSUserName)
630
- if user:
631
- active_sessions[user.lower()] = session['SessionId']
632
- except Exception as query_err:
633
- self.log_error(f"Could not query session {session['SessionId']}: {query_err}")
634
- except Exception as user_enum_err:
635
- self.log_error(f"Error enumerating users/sessions: {user_enum_err}", exc_info=True)
636
- return "failed_user_enumeration"
637
-
638
- self.log_info(f"Found active user sessions: {active_sessions}")
639
-
640
- target_users_normalized = set()
641
- if target_user == "all_active":
642
- # If targeting all_active, only target those CURRENTLY active
643
- target_users_normalized = set(active_sessions.keys())
644
- self.log_info(f"Targeting all active users: {target_users_normalized}")
645
- else:
646
- normalized_target = target_user.lower()
647
- # Check if the target user actually exists on the system, even if inactive
648
- # This check might be complex/unreliable. Rely on task scheduler potentially failing?
649
- # Let's assume admin provides a valid username for specific targeting.
650
- # if normalized_target in all_system_users: # Removing this check, assume valid user input
651
- target_users_normalized.add(normalized_target)
652
- self.log_info(f"Targeting specific user: {normalized_target}")
653
- # else:
654
- # log_error(f"Target user '{target_user}' does not appear to exist on this system based on psutil.")
655
- # return "failed_user_does_not_exist"
656
-
657
- if not target_users_normalized:
658
- self.log_info("No target users identified (or none active for 'all_active').")
659
- # If target was specific user but they weren't found active, still try task?
660
- # Let's proceed to task creation anyway for specific user case.
661
- if target_user != "all_active": target_users_normalized.add(target_user.lower())
662
- if not target_users_normalized:
663
- return "failed_no_target_users"
664
-
665
- # --- Check existing processes ---
666
- # This check is only relevant for immediate start attempt
667
- running_procs_by_user = {} # user_lower: count
668
- try:
669
- current_running = self._get_ootb_processes("all_active") # Check all
670
- for proc in current_running:
671
- try:
672
- proc_username = proc.info.get('username')
673
- if proc_username:
674
- user_lower = proc_username.split('\\')[-1].lower()
675
- running_procs_by_user[user_lower] = running_procs_by_user.get(user_lower, 0) + 1
676
- except Exception: pass
677
- except Exception as e:
678
- self.log_error(f"Error checking existing processes: {e}")
679
- self.log_info(f"Users currently running OOTB: {running_procs_by_user}")
680
-
681
- # --- Process each target user ---
682
- for user in target_users_normalized:
683
- task_created_status = "task_unknown"
684
- immediate_start_status = "start_not_attempted"
685
- token = None # Ensure token is reset/defined
686
-
687
- self.log_info(f"Processing start for user '{user}'...")
688
-
689
- # 1. Always try to create/update the scheduled task
690
- try:
691
- task_created = self.create_or_update_logon_task(user, self.ootb_command, self.python_exe)
692
- task_created_status = "task_success" if task_created else "task_failed"
693
- except Exception as task_err:
694
- self.log_error(f"Exception creating/updating scheduled task for {user}: {task_err}", exc_info=True)
695
- task_created_status = "task_exception"
696
- failed_users.add(user)
697
- # Continue to potentially try immediate start IF user is active?
698
- # Or maybe skip if task creation failed badly?
699
- # Let's skip immediate start if task creation had exception.
700
- start_results[user] = (task_created_status, immediate_start_status)
701
- continue
702
-
703
- # 2. If user is active AND not already running, try immediate start
704
- is_active = user in active_sessions
705
- is_running = running_procs_by_user.get(user, 0) > 0
706
-
707
- if is_active:
708
- if not is_running:
709
- immediate_start_status = "start_attempted"
710
- self.log_info(f"User '{user}' is active and not running OOTB. Attempting immediate start...")
711
- try:
712
- session_id = active_sessions[user]
713
- token = win32ts.WTSQueryUserToken(session_id)
714
- env = win32profile.CreateEnvironmentBlock(token, False)
715
- startup = win32process.STARTUPINFO()
716
- startup.dwFlags = win32process.STARTF_USESHOWWINDOW
717
- startup.wShowWindow = win32con.SW_HIDE
718
- creation_flags = win32process.CREATE_NEW_CONSOLE | win32process.CREATE_UNICODE_ENVIRONMENT
719
- user_profile_dir = win32profile.GetUserProfileDirectory(token)
720
-
721
- hProcess, hThread, dwPid, dwTid = win32process.CreateProcessAsUser(
722
- token, self.python_exe, self.ootb_command,
723
- None, None, False, creation_flags, env, user_profile_dir, startup
724
- )
725
- self.log_info(f"CreateProcessAsUser call succeeded for user '{user}' (PID: {dwPid}). Checking existence...")
726
- win32api.CloseHandle(hProcess)
727
- win32api.CloseHandle(hThread)
728
-
729
- time.sleep(1)
730
- if psutil.pid_exists(dwPid):
731
- self.log_info(f"Immediate start succeeded for user '{user}' (PID {dwPid}).")
732
- immediate_start_status = "start_success"
733
- else:
734
- self.log_error(f"Immediate start failed for user '{user}': Process {dwPid} exited immediately.")
735
- immediate_start_status = "start_failed_exited"
736
- failed_users.add(user)
737
-
738
- except Exception as proc_err:
739
- self.log_error(f"Exception during immediate start for user '{user}': {proc_err}", exc_info=True)
740
- immediate_start_status = "start_failed_exception"
741
- failed_users.add(user)
742
- finally:
743
- if token:
744
- try: win32api.CloseHandle(token)
745
- except: pass
746
- else: # User is active but already running
747
- self.log_info(f"User '{user}' is active but OOTB is already running. Skipping immediate start.")
748
- immediate_start_status = "start_skipped_already_running"
749
- else: # User is not active
750
- self.log_info(f"User '{user}' is not active. Skipping immediate start (task created/updated).")
751
- immediate_start_status = "start_skipped_inactive"
752
-
753
- # Record final results for this user
754
- start_results[user] = (task_created_status, immediate_start_status)
755
-
756
-
757
- # --- Consolidate status ---
758
- total_processed = len(target_users_normalized)
759
- final_status = "partial_success" if failed_users else "success"
760
- if not start_results: final_status = "no_targets_processed"
761
- # If all processed users failed in some way (either task or start)
762
- if len(failed_users) == total_processed and total_processed > 0: final_status = "failed"
763
- # Special case: target was specific user who wasn't found active
764
- elif total_processed == 1 and target_user != "all_active" and target_user.lower() not in active_sessions:
765
- user_key = target_user.lower()
766
- if user_key in start_results and start_results[user_key][0] == "task_success":
767
- final_status = "success_task_only_user_inactive"
768
- else:
769
- final_status = "failed_task_user_inactive"
770
-
771
- self.log_info(f"Finished starting OOTB. Overall Status: {final_status}. Results: {start_results}")
772
- # Return detailed results as a JSON string for easier parsing/logging server-side
773
- try:
774
- details = json.dumps(start_results)
775
- except Exception:
776
- details = str(start_results) # Fallback
777
- return f"{final_status}::{details}"
778
-
779
- except Exception as e:
780
- self.log_error(f"Error during combined start OOTB process: {e}", exc_info=True)
781
- return "failed_exception"
782
-
783
- # --- Main Execution Block ---
784
- if __name__ == '__main__':
785
- if len(sys.argv) > 1 and sys.argv[1] == 'debug':
786
- self.log_info("Starting service in debug mode...")
787
- print(f"Running Flask server via Waitress on {_LISTEN_HOST}:{_LISTEN_PORT} for debugging...")
788
- print("Service logic (command processing) will NOT run in this mode.")
789
- print("Use this primarily to test the '/command' endpoint receiving POSTs.")
790
- print("Press Ctrl+C to stop.")
791
- try:
792
- serve(flask_app, host=_LISTEN_HOST, port=_LISTEN_PORT, threads=1)
793
- except KeyboardInterrupt:
794
- print("\nDebug server stopped.")
795
-
796
- elif len(sys.argv) == 1:
797
- try:
798
- servicemanager.Initialize()
799
- servicemanager.PrepareToHostSingle(GuardService)
800
- servicemanager.StartServiceCtrlDispatcher()
801
- except win32service.error as details:
802
- import winerror
803
- if details.winerror == winerror.ERROR_FAILED_SERVICE_CONTROLLER_CONNECT:
804
- print(f"Error: Not started by SCM.")
805
- print(f"Use 'python {os.path.basename(__file__)} install|start|stop|remove|debug'")
806
- else:
807
- print(f"Error preparing service: {details}")
808
- except Exception as e:
809
- print(f"Unexpected error initializing service: {e}")
810
- else:
1
+ # src/computer_use_ootb_internal/guard_service.py
2
+ import sys
3
+ import os
4
+ import time
5
+ import logging
6
+ import subprocess
7
+ import pathlib
8
+ import ctypes
9
+ import threading # For running server thread
10
+ import queue # For queuing commands
11
+ import requests # Keep for status reporting back
12
+ import servicemanager # From pywin32
13
+ import win32serviceutil # From pywin32
14
+ import win32service # From pywin32
15
+ import win32event # From pywin32
16
+ import win32api # From pywin32
17
+ import win32process # From pywin32
18
+ import win32security # From pywin32
19
+ import win32profile # From pywin32
20
+ import win32ts # From pywin32 (Terminal Services API)
21
+ import win32con
22
+ import psutil # For process/user info
23
+ from flask import Flask, request, jsonify # For embedded server
24
+ from waitress import serve # For serving Flask app
25
+ import json # Needed for status reporting
26
+
27
+ # --- Configuration ---
28
+ _SERVICE_NAME = "OOTBGuardService"
29
+ _SERVICE_DISPLAY_NAME = "OOTB Guard Service"
30
+ _SERVICE_DESCRIPTION = "Background service for OOTB monitoring and remote management (Server POST mode)."
31
+ _PACKAGE_NAME = "computer-use-ootb-internal"
32
+ _OOTB_MODULE = "computer_use_ootb_internal.app_teachmode"
33
+ # --- Server POST Configuration ---
34
+ _LISTEN_HOST = "0.0.0.0" # Listen on all interfaces
35
+ _LISTEN_PORT = 14000 # Port for server to POST commands TO
36
+ # _SHARED_SECRET = "YOUR_SECRET_HERE" # !! REMOVED !! - No secret check implemented now
37
+ # --- End Server POST Configuration ---
38
+ _SERVER_STATUS_REPORT_URL = "http://52.160.105.102:7000/guard/status" # URL to POST status back TO (Path changed)
39
+ _LOG_FILE = pathlib.Path(os.environ['PROGRAMDATA']) / "OOTBGuardService" / "guard_post_mode.log" # Different log file
40
+ # --- End Configuration ---
41
+
42
+ _LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
43
+ logging.basicConfig(
44
+ filename=_LOG_FILE,
45
+ level=logging.INFO,
46
+ format='%(asctime)s %(levelname)s:%(name)s:%(threadName)s: %(message)s'
47
+ )
48
+
49
+ # --- Global service instance reference (needed for Flask routes) ---
50
+ _service_instance = None
51
+
52
+ # --- Flask App Definition ---
53
+ flask_app = Flask(__name__)
54
+
55
+ @flask_app.route('/command', methods=['POST'])
56
+ def receive_command():
57
+ global _service_instance
58
+ if not _service_instance:
59
+ logging.error("Received command but service instance is not set.")
60
+ return jsonify({"error": "Service not ready"}), 503
61
+
62
+ # --- Authentication REMOVED ---
63
+ # secret = request.headers.get('X-Guard-Secret')
64
+ # if not secret or secret != _SHARED_SECRET:
65
+ # logging.warning(f"Unauthorized command POST received (Invalid/Missing X-Guard-Secret). Remote Addr: {request.remote_addr}")
66
+ # return jsonify({"error": "Unauthorized"}), 403
67
+ # --- End Authentication REMOVED ---
68
+
69
+ if not request.is_json:
70
+ logging.warning("Received non-JSON command POST.")
71
+ return jsonify({"error": "Request must be JSON"}), 400
72
+
73
+ command = request.get_json()
74
+ logging.info(f"Received command via POST: {command}")
75
+
76
+ # Basic validation
77
+ action = command.get("action")
78
+ command_id = command.get("command_id", "N/A") # Use for status reporting
79
+ if not action:
80
+ logging.error(f"Received command POST with no action: {command}")
81
+ return jsonify({"error": "Missing 'action' in command"}), 400
82
+
83
+ # Queue the command for processing in a background thread
84
+ _service_instance.command_queue.put((command_id, command))
85
+ logging.info(f"Queued command {command_id} ({action}) for processing.")
86
+
87
+ return jsonify({"message": f"Command {command_id} received and queued"}), 202 # Accepted
88
+
89
+ # --- Helper Functions --- Only logging helpers needed adjustments
90
+ # Move these inside the class later
91
+ # def get_python_executable(): ...
92
+ # def get_pip_executable(): ...
93
+
94
+ # Define loggers at module level for use before instance exists?
95
+ # Or handle carefully within instance methods.
96
+
97
+ # --- PowerShell Task Scheduler Helpers --- (These will become methods) ---
98
+
99
+ # _TASK_NAME_PREFIX = "OOTB_UserLogon_" # Move to class
100
+
101
+ # def run_powershell_command(command, log_output=True): ...
102
+ # def create_or_update_logon_task(username, task_command, python_executable): ...
103
+ # def remove_logon_task(username): ...
104
+
105
+ # --- End PowerShell Task Scheduler Helpers ---
106
+
107
+ class GuardService(win32serviceutil.ServiceFramework):
108
+ _svc_name_ = _SERVICE_NAME
109
+ _svc_display_name_ = _SERVICE_DISPLAY_NAME
110
+ _svc_description_ = _SERVICE_DESCRIPTION
111
+ _task_name_prefix = "OOTB_UserLogon_" # Class attribute for task prefix
112
+
113
+ # --- Instance Logging Methods ---
114
+ def log_info(self, msg):
115
+ thread_name = threading.current_thread().name
116
+ full_msg = f"[{thread_name}] {msg}"
117
+ logging.info(full_msg)
118
+ try:
119
+ if threading.current_thread().name in ["MainThread", "CommandProcessor"]:
120
+ servicemanager.LogInfoMsg(str(full_msg))
121
+ except Exception as e:
122
+ # Log only to file if event log fails
123
+ logging.warning(f"(Instance) Could not write info to Windows Event Log: {e}")
124
+
125
+ def log_error(self, msg, exc_info=False):
126
+ thread_name = threading.current_thread().name
127
+ full_msg = f"[{thread_name}] {msg}"
128
+ logging.error(full_msg, exc_info=exc_info)
129
+ try:
130
+ if threading.current_thread().name in ["MainThread", "CommandProcessor"]:
131
+ servicemanager.LogErrorMsg(str(full_msg))
132
+ except Exception as e:
133
+ logging.warning(f"(Instance) Could not write error to Windows Event Log: {e}")
134
+ # --- End Instance Logging ---
135
+
136
+ # --- Instance Helper Methods (Moved from module level) ---
137
+ def get_base_python_executable(self):
138
+ """Tries to find python.exe instead of pythonservice.exe if running as service."""
139
+ service_exe = sys.executable
140
+ self.log_info(f"get_base_python_executable: sys.executable is {service_exe}")
141
+ # Normalize path for comparison
142
+ service_exe_lower = service_exe.lower()
143
+
144
+ # Check if running as pythonservice.exe
145
+ if 'pythonservice.exe' in os.path.basename(service_exe_lower):
146
+ # Construct expected python.exe path in the same directory
147
+ dir_name = os.path.dirname(service_exe)
148
+ potential_python_exe = os.path.join(dir_name, 'python.exe')
149
+ self.log_info(f"get_base_python_executable: Checking for python.exe at {potential_python_exe}")
150
+
151
+ if os.path.exists(potential_python_exe):
152
+ self.log_info(f"get_base_python_executable: Found python.exe at {potential_python_exe}")
153
+ # Quote if necessary
154
+ final_exe = potential_python_exe
155
+ if " " in final_exe and not final_exe.startswith('"'):
156
+ return f'"{final_exe}"'
157
+ return final_exe
158
+ else:
159
+ self.log_error(f"get_base_python_executable: Could not find python.exe near pythonservice.exe (checked {potential_python_exe}). Falling back to using {service_exe}.")
160
+ # Fallback to original sys.executable (quoted if needed)
161
+ if " " in service_exe and not service_exe.startswith('"'):
162
+ return f'"{service_exe}"'
163
+ return service_exe
164
+ else: # Not running as pythonservice.exe, assume sys.executable is correct
165
+ self.log_info(f"get_base_python_executable: sys.executable is not pythonservice.exe, using it directly.")
166
+ if " " in service_exe and not service_exe.startswith('"'):
167
+ return f'"{service_exe}"'
168
+ return service_exe
169
+
170
+ def get_python_executable(self):
171
+ # This method is now just an alias for clarity if needed elsewhere,
172
+ # but primary logic uses get_base_python_executable directly.
173
+ # Keep for potential compatibility if anything still calls it.
174
+ return self.get_base_python_executable()
175
+
176
+ def get_pip_executable(self):
177
+ """Tries to locate the pip executable in the same environment."""
178
+ try:
179
+ # Use the potentially corrected python path to find pip's location
180
+ # Note: This assumes pip is relative to python.exe, not pythonservice.exe
181
+ base_python = self.get_base_python_executable()
182
+ python_path = pathlib.Path(base_python.strip('"')) # Use unquoted path
183
+ pip_path = python_path.parent / "Scripts" / "pip.exe"
184
+ self.log_info(f"get_pip_executable: Checking for pip relative to {python_path.parent} at {pip_path}")
185
+
186
+ if pip_path.exists():
187
+ self.log_info(f"get_pip_executable: pip.exe found at {pip_path}")
188
+ pip_exe = str(pip_path)
189
+ if " " in pip_exe and not pip_exe.startswith('"'):
190
+ pip_exe = f'"{pip_exe}"'
191
+ return pip_exe
192
+ else:
193
+ self.log_error(f"get_pip_executable: pip.exe NOT found at {pip_path}. Falling back to '{base_python} -m pip'.")
194
+ pass
195
+ except Exception as e:
196
+ self.log_error(f"get_pip_executable: Error determining pip path: {e}", exc_info=True)
197
+ self.log_error(f"get_pip_executable: Falling back to 'python -m pip' due to error.")
198
+
199
+ # Fallback uses the potentially corrected python path
200
+ return f"{self.get_base_python_executable()} -m pip"
201
+
202
+ # --- PowerShell Methods (Moved from module level) ---
203
+ def run_powershell_command(self, command, log_output=True):
204
+ """Executes a PowerShell command and handles output/errors. Returns True on success."""
205
+ self.log_info(f"Executing PowerShell: {command}")
206
+ try:
207
+ result = subprocess.run(
208
+ ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command],
209
+ capture_output=True, text=True, check=True, encoding='utf-8', errors='ignore'
210
+ )
211
+ if log_output and result.stdout:
212
+ self.log_info(f"PowerShell STDOUT:\n{result.stdout.strip()}")
213
+ if log_output and result.stderr:
214
+ self.log_info(f"PowerShell STDERR:\n{result.stderr.strip()}")
215
+ return True
216
+ except FileNotFoundError:
217
+ self.log_error("'powershell.exe' not found. Cannot manage scheduled tasks.")
218
+ return False
219
+ except subprocess.CalledProcessError as e:
220
+ self.log_error(f"PowerShell command failed (Exit Code {e.returncode}):")
221
+ self.log_error(f" Command: {e.cmd}")
222
+ if e.stdout: self.log_error(f" STDOUT: {e.stdout.strip()}")
223
+ if e.stderr: self.log_error(f" STDERR: {e.stderr.strip()}")
224
+ return False
225
+ except Exception as e:
226
+ self.log_error(f"Unexpected error running PowerShell: {e}", exc_info=True)
227
+ return False
228
+
229
+ def create_or_update_logon_task(self, username, task_command, python_executable):
230
+ """Creates or updates a scheduled task to run a command at user logon."""
231
+ task_name = f"{self._task_name_prefix}{username}"
232
+ safe_python_exe = python_executable.replace("'", "''")
233
+ command_parts = task_command.split(' ', 1)
234
+ if len(command_parts) > 1 and command_parts[0] == python_executable:
235
+ safe_task_command_args = command_parts[1].replace("'", "''")
236
+ else:
237
+ safe_task_command_args = task_command.replace(python_executable, "").strip().replace("'", "''")
238
+
239
+ safe_task_name = task_name.replace("'", "''")
240
+ safe_username = username.replace("'", "''")
241
+
242
+ action = f"$Action = New-ScheduledTaskAction -Execute '{safe_python_exe}' -Argument '{safe_task_command_args}'"
243
+ trigger = f"$Trigger = New-ScheduledTaskTrigger -AtLogOn -User '{safe_username}'"
244
+ principal = f"$Principal = New-ScheduledTaskPrincipal -UserId '{safe_username}' -LogonType Interactive"
245
+ settings = "$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -DontStopOnIdleEnd -ExecutionTimeLimit ([System.TimeSpan]::Zero) -RunOnlyIfNetworkAvailable:$false"
246
+
247
+ command = f"""
248
+ try {{
249
+ {action}
250
+ {trigger}
251
+ {principal}
252
+ {settings}
253
+ Register-ScheduledTask -TaskName '{safe_task_name}' -Action $Action -Trigger $Trigger -Principal $Principal -Settings $Settings -Force -ErrorAction Stop
254
+ Write-Host "Scheduled task '{safe_task_name}' registered/updated successfully."
255
+ }} catch {{
256
+ Write-Error "Failed to register/update scheduled task '{safe_task_name}': $_"
257
+ exit 1
258
+ }}
259
+ """
260
+ success = self.run_powershell_command(command)
261
+ if success:
262
+ self.log_info(f"Successfully created/updated scheduled task '{task_name}' for user '{username}'.")
263
+ else:
264
+ self.log_error(f"Failed to create/update scheduled task '{task_name}' for user '{username}'.")
265
+ return success
266
+
267
+ def remove_logon_task(self, username):
268
+ """Removes the logon scheduled task for a user."""
269
+ task_name = f"{self._task_name_prefix}{username}"
270
+ safe_task_name = task_name.replace("'", "''")
271
+ command = f"Unregister-ScheduledTask -TaskName '{safe_task_name}' -Confirm:$false -ErrorAction SilentlyContinue"
272
+ self.run_powershell_command(command, log_output=False)
273
+ self.log_info(f"Attempted removal of scheduled task '{task_name}' for user '{username}'.")
274
+ return True
275
+ # --- End Instance Helper Methods ---
276
+
277
+ def __init__(self, args):
278
+ global _service_instance
279
+ win32serviceutil.ServiceFramework.__init__(self, args)
280
+ self.hWaitStop = win32event.CreateEvent(None, 0, 0, None)
281
+ self.is_running = True
282
+ self.server_thread = None
283
+ self.command_queue = queue.Queue()
284
+ self.command_processor_thread = None
285
+ self.session = requests.Session()
286
+
287
+ # Initialize paths using instance methods that prefer python.exe
288
+ self.python_exe = self.get_base_python_executable()
289
+ self.pip_command_base = self.get_pip_executable()
290
+ # Construct command using the potentially corrected python.exe path
291
+ self.ootb_command = f"{self.python_exe} -m {_OOTB_MODULE}"
292
+ _service_instance = self
293
+ self.log_info(f"Service initialized. OOTB command set to: {self.ootb_command}")
294
+
295
+ def SvcStop(self):
296
+ self.log_info(f"Service stop requested.")
297
+ self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
298
+ self.is_running = False
299
+ # Signal the command processor thread to stop
300
+ self.command_queue.put(None) # Sentinel value
301
+ # Signal the main wait loop
302
+ win32event.SetEvent(self.hWaitStop)
303
+ # Stopping waitress gracefully from another thread is non-trivial.
304
+ # We rely on the SCM timeout / process termination for now.
305
+ self.log_info(f"{_SERVICE_NAME} SvcStop: Stop signaled. Server thread will be terminated by SCM.")
306
+
307
+
308
+ def SvcDoRun(self):
309
+ servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE,
310
+ servicemanager.PYS_SERVICE_STARTED,
311
+ (self._svc_name_, ''))
312
+ try:
313
+ self.log_info(f"{_SERVICE_NAME} starting.")
314
+ # Start the command processor thread
315
+ self.command_processor_thread = threading.Thread(
316
+ target=self.process_commands, name="CommandProcessor", daemon=True)
317
+ self.command_processor_thread.start()
318
+ self.log_info("Command processor thread started.")
319
+
320
+ # Start the Flask server (via Waitress) in a separate thread
321
+ self.server_thread = threading.Thread(
322
+ target=self.run_server, name="WebServerThread", daemon=True)
323
+ self.server_thread.start()
324
+ self.log_info(f"Web server thread started, listening on {_LISTEN_HOST}:{_LISTEN_PORT}.")
325
+
326
+ self.log_info(f"{_SERVICE_NAME} running. Waiting for stop signal.")
327
+ # Keep the main service thread alive waiting for stop signal
328
+ win32event.WaitForSingleObject(self.hWaitStop, win32event.INFINITE)
329
+ self.log_info(f"{_SERVICE_NAME} received stop signal in main thread.")
330
+
331
+ except Exception as e:
332
+ self.log_error(f"Fatal error in SvcDoRun: {e}", exc_info=True)
333
+ self.SvcStop() # Signal stop if possible
334
+ finally:
335
+ self.log_info(f"{_SERVICE_NAME} SvcDoRun finished.")
336
+
337
+
338
+ def run_server(self):
339
+ """Runs the Flask app using Waitress."""
340
+ self.log_info(f"Waitress server starting on {_LISTEN_HOST}:{_LISTEN_PORT}")
341
+ try:
342
+ serve(flask_app, host=_LISTEN_HOST, port=_LISTEN_PORT, threads=4)
343
+ self.log_info("Waitress server has stopped.") # Should only happen on shutdown
344
+ except Exception as e:
345
+ self.log_error(f"Web server thread encountered an error: {e}", exc_info=True)
346
+ # Consider signaling the main thread to stop if the web server fails critically
347
+ # For now, just log the error.
348
+
349
+
350
+ def process_commands(self):
351
+ """Worker thread to process commands from the queue."""
352
+ self.log_info("Command processor thread starting.")
353
+ while self.is_running:
354
+ try:
355
+ item = self.command_queue.get(block=True, timeout=1) # Add timeout to check is_running periodically
356
+ if item is None:
357
+ self.log_info("Command processor received stop signal.")
358
+ break # Exit loop
359
+
360
+ command_id, command = item
361
+ action = command.get("action")
362
+ target = command.get("target_user", "all_active")
363
+ status = "failed_unknown" # Default
364
+
365
+ self.log_info(f"Dequeued Command ID {command_id}: action='{action}', target='{target}'")
366
+
367
+ try:
368
+ if action == "update":
369
+ status = self.handle_update()
370
+ elif action == "stop_ootb":
371
+ status = self.handle_stop(target)
372
+ elif action == "start_ootb":
373
+ status = self.handle_start(target)
374
+ else:
375
+ self.log_error(f"Unknown action in queue: {action}")
376
+ status = "failed_unknown_action"
377
+ except Exception as handler_ex:
378
+ self.log_error(f"Exception processing Command ID {command_id} ({action}): {handler_ex}", exc_info=True)
379
+ status = "failed_exception"
380
+ finally:
381
+ self.report_command_status(command_id, status)
382
+ self.command_queue.task_done()
383
+
384
+ except queue.Empty:
385
+ # Timeout occurred, just loop again and check self.is_running
386
+ continue
387
+ except Exception as e:
388
+ self.log_error(f"Error in command processing loop: {e}", exc_info=True)
389
+ if self.is_running:
390
+ time.sleep(5)
391
+
392
+ self.log_info("Command processor thread finished.")
393
+
394
+
395
+ def report_command_status(self, command_id, status, details=""):
396
+ """Sends command status back to the server."""
397
+ if not _SERVER_STATUS_REPORT_URL:
398
+ self.log_error("No server status report URL configured. Skipping report.")
399
+ return
400
+
401
+ payload = {
402
+ "command_id": command_id,
403
+ "status": status,
404
+ "details": details,
405
+ "machine_id": os.getenv('COMPUTERNAME', 'unknown_guard')
406
+ }
407
+ self.log_info(f"Reporting status for command {command_id}: {status}")
408
+ try:
409
+ response = self.session.post(_SERVER_STATUS_REPORT_URL, json=payload, timeout=15)
410
+ response.raise_for_status()
411
+ self.log_info(f"Status report for command {command_id} accepted by server.")
412
+ except requests.exceptions.RequestException as e:
413
+ self.log_error(f"Failed to report status for command {command_id}: {e}")
414
+ except Exception as e:
415
+ self.log_error(f"Unexpected error reporting status for command {command_id}: {e}", exc_info=True)
416
+
417
+ # --- Command Handlers --- Now call self. for helpers
418
+
419
+ def handle_update(self):
420
+ self.log_info("Executing OOTB update...")
421
+ if not self.pip_command_base:
422
+ self.log_error("Cannot update: pip command not found.")
423
+ return "failed_pip_not_found"
424
+
425
+ update_command = f"{self.pip_command_base} install --upgrade --no-cache-dir {_PACKAGE_NAME}"
426
+ self.log_info(f"Running update command: {update_command}")
427
+ try:
428
+ result = subprocess.run(update_command, shell=True, capture_output=True, text=True, check=True, timeout=300, encoding='utf-8')
429
+ self.log_info(f"Update successful: \nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}")
430
+ return "success"
431
+ except subprocess.CalledProcessError as e:
432
+ self.log_error(f"Update failed (Exit Code {e.returncode}):\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}")
433
+ return f"failed_exit_{e.returncode}"
434
+ except subprocess.TimeoutExpired:
435
+ self.log_error(f"Update command timed out.")
436
+ return "failed_timeout"
437
+ except Exception as e:
438
+ self.log_error(f"Unexpected error during update: {e}", exc_info=True)
439
+ return "failed_exception"
440
+
441
+ def _get_ootb_processes(self, target_user="all_active"):
442
+ ootb_procs = []
443
+ target_pid_list = []
444
+ try:
445
+ target_users = set()
446
+ if target_user == "all_active":
447
+ for user_session in psutil.users():
448
+ username = user_session.name.split('\\')[-1]
449
+ target_users.add(username.lower())
450
+ else:
451
+ target_users.add(target_user.lower())
452
+ self.log_info(f"Searching for OOTB processes for users: {target_users}")
453
+
454
+ # Use the potentially corrected python.exe path for matching
455
+ python_exe_path_for_check = self.python_exe.strip('"')
456
+ self.log_info(f"_get_ootb_processes: Checking against python path: {python_exe_path_for_check}")
457
+
458
+ for proc in psutil.process_iter(['pid', 'name', 'username', 'cmdline', 'exe']):
459
+ try:
460
+ pinfo = proc.info
461
+ proc_username = pinfo['username']
462
+ if proc_username:
463
+ proc_username = proc_username.split('\\')[-1].lower()
464
+
465
+ if proc_username in target_users:
466
+ cmdline = ' '.join(pinfo['cmdline']) if pinfo['cmdline'] else ''
467
+ # Check if the process executable matches our corrected python path AND module is in cmdline
468
+ if pinfo['exe'] and pinfo['exe'] == python_exe_path_for_check and _OOTB_MODULE in cmdline:
469
+ self.log_info(f"Found matching OOTB process: PID={pinfo['pid']}, User={pinfo['username']}, Cmd={cmdline}")
470
+ ootb_procs.append(proc)
471
+ target_pid_list.append(pinfo['pid'])
472
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
473
+ continue
474
+ self.log_info(f"Found {len(ootb_procs)} OOTB process(es) matching criteria: {target_pid_list}")
475
+ except Exception as e:
476
+ self.log_error(f"Error enumerating processes: {e}", exc_info=True)
477
+ return ootb_procs
478
+
479
+ def handle_stop(self, target_user="all_active"):
480
+ self.log_info(f"Executing stop OOTB for target '{target_user}'...")
481
+ stop_results = {} # Track results per user {username: (task_status, immediate_status)}
482
+ failed_users = set()
483
+
484
+ try:
485
+ # --- Get target users and active sessions ---
486
+ active_sessions = {} # user_lower: session_id
487
+ # No need for all_system_users for stop, we only care about active or the specific target
488
+ try:
489
+ sessions = win32ts.WTSEnumerateSessions(win32ts.WTS_CURRENT_SERVER_HANDLE)
490
+ for session in sessions:
491
+ if session['State'] == win32ts.WTSActive:
492
+ try:
493
+ user = win32ts.WTSQuerySessionInformation(win32ts.WTS_CURRENT_SERVER_HANDLE, session['SessionId'], win32ts.WTSUserName)
494
+ if user:
495
+ active_sessions[user.lower()] = session['SessionId']
496
+ except Exception as query_err:
497
+ self.log_error(f"Could not query session {session['SessionId']} during stop: {query_err}")
498
+ except Exception as user_enum_err:
499
+ self.log_error(f"Error enumerating users/sessions during stop: {user_enum_err}", exc_info=True)
500
+ return "failed_user_enumeration"
501
+
502
+ self.log_info(f"Stop target: '{target_user}'. Active sessions: {active_sessions}")
503
+
504
+ target_users_normalized = set()
505
+ if target_user == "all_active":
506
+ # Target only currently active users for stop all
507
+ target_users_normalized = set(active_sessions.keys())
508
+ self.log_info(f"Stop targeting all active users: {target_users_normalized}")
509
+ else:
510
+ # Target the specific user, regardless of active status (for task removal)
511
+ normalized_target = target_user.lower()
512
+ target_users_normalized.add(normalized_target)
513
+ self.log_info(f"Stop targeting specific user: {normalized_target}")
514
+
515
+ if not target_users_normalized:
516
+ self.log_info("No target users identified for stop.")
517
+ return "failed_no_target_users" # Or success if none were targeted?
518
+
519
+ # --- Process each target user ---
520
+ for user in target_users_normalized:
521
+ task_removed_status = "task_unknown"
522
+ immediate_stop_status = "stop_not_attempted"
523
+ stopped_count = 0
524
+
525
+ self.log_info(f"Processing stop for user '{user}'...")
526
+
527
+ # 1. Always try to remove the scheduled task
528
+ try:
529
+ # remove_logon_task always returns True for now, just logs attempt
530
+ self.remove_logon_task(user)
531
+ task_removed_status = "task_removed_attempted"
532
+ except Exception as task_err:
533
+ self.log_error(f"Exception removing scheduled task for {user}: {task_err}", exc_info=True)
534
+ task_removed_status = "task_exception"
535
+ failed_users.add(user)
536
+ # Continue to try and stop process if active
537
+
538
+ # 2. If user is active, try to terminate process
539
+ is_active = user in active_sessions
540
+
541
+ if is_active:
542
+ immediate_stop_status = "stop_attempted"
543
+ self.log_info(f"User '{user}' is active. Attempting to terminate OOTB process(es)...")
544
+ # Pass the specific username to _get_ootb_processes
545
+ procs_to_stop = self._get_ootb_processes(user)
546
+
547
+ if not procs_to_stop:
548
+ self.log_info(f"No running OOTB processes found for active user '{user}'.")
549
+ immediate_stop_status = "stop_skipped_not_running"
550
+ else:
551
+ self.log_info(f"Found {len(procs_to_stop)} process(es) for user '{user}' to stop.")
552
+ for proc in procs_to_stop:
553
+ try:
554
+ pid = proc.pid # Get pid before potential termination
555
+ username = proc.info.get('username', 'unknown_user')
556
+ self.log_info(f"Terminating process PID={pid}, User={username}")
557
+ proc.terminate()
558
+ try:
559
+ proc.wait(timeout=3)
560
+ self.log_info(f"Process PID={pid} terminated successfully.")
561
+ stopped_count += 1
562
+ except psutil.TimeoutExpired:
563
+ self.log_error(f"Process PID={pid} did not terminate gracefully, killing.")
564
+ proc.kill()
565
+ stopped_count += 1
566
+ except psutil.NoSuchProcess:
567
+ self.log_info(f"Process PID={pid} already terminated.")
568
+ # Don't increment stopped_count here as we didn't stop it now
569
+ except psutil.AccessDenied:
570
+ self.log_error(f"Access denied trying to terminate process PID={pid}.")
571
+ failed_users.add(user) # Mark user as failed if stop fails
572
+ except Exception as e:
573
+ self.log_error(f"Error stopping process PID={pid}: {e}", exc_info=True)
574
+ failed_users.add(user) # Mark user as failed
575
+
576
+ # Determine status based on how many were found vs stopped
577
+ if user in failed_users:
578
+ immediate_stop_status = f"stop_errors_terminated_{stopped_count}_of_{len(procs_to_stop)}"
579
+ elif stopped_count == len(procs_to_stop):
580
+ immediate_stop_status = f"stop_success_terminated_{stopped_count}"
581
+ else: # Should ideally not happen if NoSuchProcess doesn't count
582
+ immediate_stop_status = f"stop_partial_terminated_{stopped_count}_of_{len(procs_to_stop)}"
583
+
584
+ else: # User not active
585
+ self.log_info(f"User '{user}' is not active. Skipping immediate process stop (task removal attempted).")
586
+ immediate_stop_status = "stop_skipped_inactive"
587
+
588
+ # Record final results for this user
589
+ stop_results[user] = (task_removed_status, immediate_stop_status)
590
+
591
+
592
+ # --- Consolidate status ---
593
+ total_processed = len(target_users_normalized)
594
+ final_status = "partial_success" if failed_users else "success"
595
+ if not stop_results: final_status = "no_targets_processed"
596
+ if len(failed_users) == total_processed and total_processed > 0 : final_status = "failed"
597
+
598
+ self.log_info(f"Finished stopping OOTB. Overall Status: {final_status}. Results: {stop_results}")
599
+ try:
600
+ details = json.dumps(stop_results)
601
+ except Exception:
602
+ details = str(stop_results) # Fallback
603
+ return f"{final_status}::{details}" # Use :: as separator
604
+
605
+ except Exception as e:
606
+ self.log_error(f"Error during combined stop OOTB process: {e}", exc_info=True)
607
+ return "failed_exception"
608
+
609
+
610
+ def handle_start(self, target_user="all_active"):
611
+ self.log_info(f"Executing start OOTB for target '{target_user}'...")
612
+ start_results = {} # Track results per user {username: (task_status, immediate_status)}
613
+ failed_users = set()
614
+
615
+ try:
616
+ # --- Get target users and active sessions ---
617
+ active_sessions = {} # user_lower: session_id
618
+ all_system_users = set() # user_lower
619
+ try:
620
+ # Use psutil for system user list, WTS for active sessions/IDs
621
+ for user_session in psutil.users():
622
+ username_lower = user_session.name.split('\\')[-1].lower()
623
+ all_system_users.add(username_lower)
624
+
625
+ sessions = win32ts.WTSEnumerateSessions(win32ts.WTS_CURRENT_SERVER_HANDLE)
626
+ for session in sessions:
627
+ if session['State'] == win32ts.WTSActive:
628
+ try:
629
+ user = win32ts.WTSQuerySessionInformation(win32ts.WTS_CURRENT_SERVER_HANDLE, session['SessionId'], win32ts.WTSUserName)
630
+ if user:
631
+ active_sessions[user.lower()] = session['SessionId']
632
+ except Exception as query_err:
633
+ self.log_error(f"Could not query session {session['SessionId']}: {query_err}")
634
+ except Exception as user_enum_err:
635
+ self.log_error(f"Error enumerating users/sessions: {user_enum_err}", exc_info=True)
636
+ return "failed_user_enumeration"
637
+
638
+ self.log_info(f"Found active user sessions: {active_sessions}")
639
+
640
+ target_users_normalized = set()
641
+ if target_user == "all_active":
642
+ # If targeting all_active, only target those CURRENTLY active
643
+ target_users_normalized = set(active_sessions.keys())
644
+ self.log_info(f"Targeting all active users: {target_users_normalized}")
645
+ else:
646
+ normalized_target = target_user.lower()
647
+ # Check if the target user actually exists on the system, even if inactive
648
+ # This check might be complex/unreliable. Rely on task scheduler potentially failing?
649
+ # Let's assume admin provides a valid username for specific targeting.
650
+ # if normalized_target in all_system_users: # Removing this check, assume valid user input
651
+ target_users_normalized.add(normalized_target)
652
+ self.log_info(f"Targeting specific user: {normalized_target}")
653
+ # else:
654
+ # log_error(f"Target user '{target_user}' does not appear to exist on this system based on psutil.")
655
+ # return "failed_user_does_not_exist"
656
+
657
+ if not target_users_normalized:
658
+ self.log_info("No target users identified (or none active for 'all_active').")
659
+ # If target was specific user but they weren't found active, still try task?
660
+ # Let's proceed to task creation anyway for specific user case.
661
+ if target_user != "all_active": target_users_normalized.add(target_user.lower())
662
+ if not target_users_normalized:
663
+ return "failed_no_target_users"
664
+
665
+ # --- Check existing processes ---
666
+ # This check is only relevant for immediate start attempt
667
+ running_procs_by_user = {} # user_lower: count
668
+ try:
669
+ current_running = self._get_ootb_processes("all_active") # Check all
670
+ for proc in current_running:
671
+ try:
672
+ proc_username = proc.info.get('username')
673
+ if proc_username:
674
+ user_lower = proc_username.split('\\')[-1].lower()
675
+ running_procs_by_user[user_lower] = running_procs_by_user.get(user_lower, 0) + 1
676
+ except Exception: pass
677
+ except Exception as e:
678
+ self.log_error(f"Error checking existing processes: {e}")
679
+ self.log_info(f"Users currently running OOTB: {running_procs_by_user}")
680
+
681
+ # --- Process each target user ---
682
+ for user in target_users_normalized:
683
+ task_created_status = "task_unknown"
684
+ immediate_start_status = "start_not_attempted"
685
+ token = None # Ensure token is reset/defined
686
+
687
+ self.log_info(f"Processing start for user '{user}'...")
688
+
689
+ # 1. Always try to create/update the scheduled task
690
+ try:
691
+ task_created = self.create_or_update_logon_task(user, self.ootb_command, self.python_exe)
692
+ task_created_status = "task_success" if task_created else "task_failed"
693
+ except Exception as task_err:
694
+ self.log_error(f"Exception creating/updating scheduled task for {user}: {task_err}", exc_info=True)
695
+ task_created_status = "task_exception"
696
+ failed_users.add(user)
697
+ # Continue to potentially try immediate start IF user is active?
698
+ # Or maybe skip if task creation failed badly?
699
+ # Let's skip immediate start if task creation had exception.
700
+ start_results[user] = (task_created_status, immediate_start_status)
701
+ continue
702
+
703
+ # 2. If user is active AND not already running, try immediate start
704
+ is_active = user in active_sessions
705
+ is_running = running_procs_by_user.get(user, 0) > 0
706
+
707
+ if is_active:
708
+ if not is_running:
709
+ immediate_start_status = "start_attempted"
710
+ self.log_info(f"User '{user}' is active and not running OOTB. Attempting immediate start...")
711
+ try:
712
+ session_id = active_sessions[user]
713
+ token = win32ts.WTSQueryUserToken(session_id)
714
+ env = win32profile.CreateEnvironmentBlock(token, False)
715
+ startup = win32process.STARTUPINFO()
716
+ startup.dwFlags = win32process.STARTF_USESHOWWINDOW
717
+ startup.wShowWindow = win32con.SW_HIDE
718
+ creation_flags = win32process.CREATE_NEW_CONSOLE | win32process.CREATE_UNICODE_ENVIRONMENT
719
+ user_profile_dir = win32profile.GetUserProfileDirectory(token)
720
+
721
+ hProcess, hThread, dwPid, dwTid = win32process.CreateProcessAsUser(
722
+ token, self.python_exe, self.ootb_command,
723
+ None, None, False, creation_flags, env, user_profile_dir, startup
724
+ )
725
+ self.log_info(f"CreateProcessAsUser call succeeded for user '{user}' (PID: {dwPid}). Checking existence...")
726
+ win32api.CloseHandle(hProcess)
727
+ win32api.CloseHandle(hThread)
728
+
729
+ time.sleep(1)
730
+ if psutil.pid_exists(dwPid):
731
+ self.log_info(f"Immediate start succeeded for user '{user}' (PID {dwPid}).")
732
+ immediate_start_status = "start_success"
733
+ else:
734
+ self.log_error(f"Immediate start failed for user '{user}': Process {dwPid} exited immediately.")
735
+ immediate_start_status = "start_failed_exited"
736
+ failed_users.add(user)
737
+
738
+ except Exception as proc_err:
739
+ self.log_error(f"Exception during immediate start for user '{user}': {proc_err}", exc_info=True)
740
+ immediate_start_status = "start_failed_exception"
741
+ failed_users.add(user)
742
+ finally:
743
+ if token:
744
+ try: win32api.CloseHandle(token)
745
+ except: pass
746
+ else: # User is active but already running
747
+ self.log_info(f"User '{user}' is active but OOTB is already running. Skipping immediate start.")
748
+ immediate_start_status = "start_skipped_already_running"
749
+ else: # User is not active
750
+ self.log_info(f"User '{user}' is not active. Skipping immediate start (task created/updated).")
751
+ immediate_start_status = "start_skipped_inactive"
752
+
753
+ # Record final results for this user
754
+ start_results[user] = (task_created_status, immediate_start_status)
755
+
756
+
757
+ # --- Consolidate status ---
758
+ total_processed = len(target_users_normalized)
759
+ final_status = "partial_success" if failed_users else "success"
760
+ if not start_results: final_status = "no_targets_processed"
761
+ # If all processed users failed in some way (either task or start)
762
+ if len(failed_users) == total_processed and total_processed > 0: final_status = "failed"
763
+ # Special case: target was specific user who wasn't found active
764
+ elif total_processed == 1 and target_user != "all_active" and target_user.lower() not in active_sessions:
765
+ user_key = target_user.lower()
766
+ if user_key in start_results and start_results[user_key][0] == "task_success":
767
+ final_status = "success_task_only_user_inactive"
768
+ else:
769
+ final_status = "failed_task_user_inactive"
770
+
771
+ self.log_info(f"Finished starting OOTB. Overall Status: {final_status}. Results: {start_results}")
772
+ # Return detailed results as a JSON string for easier parsing/logging server-side
773
+ try:
774
+ details = json.dumps(start_results)
775
+ except Exception:
776
+ details = str(start_results) # Fallback
777
+ return f"{final_status}::{details}"
778
+
779
+ except Exception as e:
780
+ self.log_error(f"Error during combined start OOTB process: {e}", exc_info=True)
781
+ return "failed_exception"
782
+
783
+ # --- Main Execution Block ---
784
+ if __name__ == '__main__':
785
+ if len(sys.argv) > 1 and sys.argv[1] == 'debug':
786
+ self.log_info("Starting service in debug mode...")
787
+ print(f"Running Flask server via Waitress on {_LISTEN_HOST}:{_LISTEN_PORT} for debugging...")
788
+ print("Service logic (command processing) will NOT run in this mode.")
789
+ print("Use this primarily to test the '/command' endpoint receiving POSTs.")
790
+ print("Press Ctrl+C to stop.")
791
+ try:
792
+ serve(flask_app, host=_LISTEN_HOST, port=_LISTEN_PORT, threads=1)
793
+ except KeyboardInterrupt:
794
+ print("\nDebug server stopped.")
795
+
796
+ elif len(sys.argv) == 1:
797
+ try:
798
+ servicemanager.Initialize()
799
+ servicemanager.PrepareToHostSingle(GuardService)
800
+ servicemanager.StartServiceCtrlDispatcher()
801
+ except win32service.error as details:
802
+ import winerror
803
+ if details.winerror == winerror.ERROR_FAILED_SERVICE_CONTROLLER_CONNECT:
804
+ print(f"Error: Not started by SCM.")
805
+ print(f"Use 'python {os.path.basename(__file__)} install|start|stop|remove|debug'")
806
+ else:
807
+ print(f"Error preparing service: {details}")
808
+ except Exception as e:
809
+ print(f"Unexpected error initializing service: {e}")
810
+ else:
811
811
  win32serviceutil.HandleCommandLine(GuardService)