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.
- computer_use_ootb_internal/app_teachmode.py +533 -535
- computer_use_ootb_internal/computer_use_demo/animation/test_animation.py +39 -39
- computer_use_ootb_internal/computer_use_demo/executor/teachmode_executor.py +3 -3
- computer_use_ootb_internal/computer_use_demo/tools/base.py +4 -27
- computer_use_ootb_internal/computer_use_demo/tools/computer.py +42 -40
- computer_use_ootb_internal/guard_service.py +810 -810
- computer_use_ootb_internal/preparation/star_rail_prepare.py +64 -64
- computer_use_ootb_internal/run_teachmode_ootb_args.py +20 -15
- computer_use_ootb_internal/service_manager.py +194 -194
- {computer_use_ootb_internal-0.0.122.dist-info → computer_use_ootb_internal-0.0.124.dist-info}/METADATA +8 -9
- {computer_use_ootb_internal-0.0.122.dist-info → computer_use_ootb_internal-0.0.124.dist-info}/RECORD +13 -14
- computer_use_ootb_internal-0.0.124.dist-info/entry_points.txt +2 -0
- computer_use_ootb_internal/app_teachmode_gradio.py +0 -200
- computer_use_ootb_internal-0.0.122.dist-info/entry_points.txt +0 -4
- {computer_use_ootb_internal-0.0.122.dist-info → computer_use_ootb_internal-0.0.124.dist-info}/WHEEL +0 -0
@@ -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)
|