Поиск сеансов пользователя по домену и добавление баз прямо в них
При перемещаемых профилях базу нужно писать в профиль на той машине, где у пользователя открыт сеанс, иначе правку затрёт при выходе. Раньше, чтобы узнать, где человек сидит, приходилось отдельно лезть в Dameware. Что добавлено: - ad_sessions.py — обнаружение сеансов: список компьютеров из AD через ADSI (RSAT не нужен), отсев выключенных по порту 445, опрос WTS, разрешение реальной папки профиля через SID и реестр ProfileList, с фоллбэком на перебор имён папок, если служба RemoteRegistry отключена. - Панель «Сеансы пользователя в домене»: поиск по имени, выбор сеансов галочками и отдельная кнопка добавления баз в отмеченные сеансы. Старый режим записи по профилям остался для пользователей без активных сеансов. - Отключённые сеансы учитываются наравне с активными: они тоже держат профиль загруженным, и запись в серверную копию так же затрётся. - Фильтр по паре домен + пользователь, чтобы локальные учётки с тем же именем не смешивались с доменными. - Enter в полях ввода запускает соответствующее действие. Скорость (на парке в 316 машин опрос занимал около 90 секунд): - WTS вызывается через ctypes, а не pywin32: pywin32 не отпускает GIL на время нативного вызова, из-за чего опрос шёл строго последовательно и никакие потоки не помогали. На живом домене 21,4 с -> 1,3 с. - Проверка порта и опрос WTS слиты в один проход, потоков 256. - Машины, зависшие на RPC, не ждём дольше 4 секунд после того, как очередь разобрана: прервать вызов нельзя, но можно перестать его ждать. - Снимок домена кэшируется на 2 минуты, поэтому поиск следующего пользователя мгновенный; для принудительного обновления есть кнопка «Пересканировать». - COM инициализируется явно в каждом потоке: без этого повторное сканирование из нового потока падало с MK_E_SYNTAX. Безопасность записи: перед добавлением сеансы перепроверяются на живость, а если сеансов не нашлось и при этом часть машин не ответила — выводится предупреждение, чтобы базу не записали в перемещаемый профиль вслепую. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+8
-1
@@ -6,7 +6,14 @@ a = Analysis(
|
|||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[],
|
datas=[],
|
||||||
hiddenimports=[],
|
# ad_sessions импортируется внутри try/except, а pywin32 подтягивает часть
|
||||||
|
# модулей динамически — перечисляем явно, иначе их не будет в сборке
|
||||||
|
hiddenimports=[
|
||||||
|
'ad_sessions',
|
||||||
|
'win32timezone',
|
||||||
|
'win32com.client',
|
||||||
|
'win32security',
|
||||||
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
|
|||||||
+313
-10
@@ -1,11 +1,22 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import messagebox, filedialog
|
from tkinter import messagebox, filedialog
|
||||||
import pyodbc
|
import pyodbc
|
||||||
import uuid
|
import uuid
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
# Поиск сеансов по домену — необязательная часть: если pywin32 недоступен,
|
||||||
|
# программа должна продолжать работать в режиме «по профилям».
|
||||||
|
try:
|
||||||
|
import ad_sessions
|
||||||
|
SESSIONS_ERROR = ""
|
||||||
|
except Exception as _exc:
|
||||||
|
ad_sessions = None
|
||||||
|
SESSIONS_ERROR = str(_exc)
|
||||||
|
|
||||||
_BANNER = ("""
|
_BANNER = ("""
|
||||||
|
|
||||||
|
|
||||||
@@ -73,8 +84,8 @@ class RemoteFolderSelector(tk.Tk):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.title("Добавление баз 1С пользователям")
|
self.title("Добавление баз 1С пользователям")
|
||||||
self.geometry("760x780")
|
self.geometry("1180x820")
|
||||||
self.minsize(700, 640)
|
self.minsize(1040, 700)
|
||||||
|
|
||||||
# --- Состояние ---
|
# --- Состояние ---
|
||||||
self.ip_address = None # None/"" => локальный ПК, иначе IP удалённого ПК
|
self.ip_address = None # None/"" => локальный ПК, иначе IP удалённого ПК
|
||||||
@@ -82,27 +93,49 @@ class RemoteFolderSelector(tk.Tk):
|
|||||||
self.user_vars = {} # имя пользователя -> BooleanVar (галочка)
|
self.user_vars = {} # имя пользователя -> BooleanVar (галочка)
|
||||||
self.db_vars = {} # имя базы из SQL -> BooleanVar (галочка)
|
self.db_vars = {} # имя базы из SQL -> BooleanVar (галочка)
|
||||||
self.conn_vars = {} # имя подключённой базы активного пользователя -> BooleanVar
|
self.conn_vars = {} # имя подключённой базы активного пользователя -> BooleanVar
|
||||||
|
self.session_vars = {} # подпись сеанса -> BooleanVar (галочка)
|
||||||
|
self.sessions_by_key = {} # подпись сеанса -> словарь с данными сеанса
|
||||||
|
self.scanning = False # идёт ли сейчас поиск сеансов
|
||||||
|
self.scan_cache = None # последний снимок сеансов всего домена
|
||||||
|
self.scan_cache_at = 0.0 # время снимка (time.monotonic)
|
||||||
|
self.scan_cache_stats = {} # статистика последнего скана
|
||||||
|
|
||||||
# Компоновка окна: две колонки сверху, широкая кнопка "Добавить" снизу
|
# Компоновка окна: три колонки сверху, кнопки действий снизу
|
||||||
self.grid_columnconfigure(0, weight=1)
|
self.grid_columnconfigure(0, weight=1)
|
||||||
self.grid_columnconfigure(1, weight=1)
|
self.grid_columnconfigure(1, weight=1)
|
||||||
|
self.grid_columnconfigure(2, weight=1)
|
||||||
self.grid_rowconfigure(0, weight=1)
|
self.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
self.frame_left = tk.Frame(self)
|
self.frame_left = tk.Frame(self)
|
||||||
self.frame_left.grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
|
self.frame_left.grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
|
||||||
|
|
||||||
|
self.frame_mid = tk.Frame(self)
|
||||||
|
self.frame_mid.grid(row=0, column=1, padx=10, pady=10, sticky="nsew")
|
||||||
|
|
||||||
self.frame_right = tk.Frame(self)
|
self.frame_right = tk.Frame(self)
|
||||||
self.frame_right.grid(row=0, column=1, padx=10, pady=10, sticky="nsew")
|
self.frame_right.grid(row=0, column=2, padx=10, pady=10, sticky="nsew")
|
||||||
|
|
||||||
self._build_left_panel()
|
self._build_left_panel()
|
||||||
|
self._build_sessions_panel()
|
||||||
self._build_right_panel()
|
self._build_right_panel()
|
||||||
|
|
||||||
# Большая кнопка добавления снизу, на всю ширину окна
|
# Кнопки действий снизу, на всю ширину окна
|
||||||
self.add_button = tk.Button(
|
actions = tk.Frame(self)
|
||||||
self, text="Добавить отмеченные базы отмеченным пользователям",
|
actions.grid(row=1, column=0, columnspan=3, padx=10, pady=(0, 12), sticky="ew")
|
||||||
font=("Segoe UI", 11, "bold"), command=self.add_database_to_ibases
|
actions.grid_columnconfigure(0, weight=1)
|
||||||
|
actions.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
self.add_session_button = tk.Button(
|
||||||
|
actions, text="Добавить базы в отмеченные сеансы",
|
||||||
|
font=("Segoe UI", 11, "bold"), command=self.add_bases_to_sessions
|
||||||
)
|
)
|
||||||
self.add_button.grid(row=1, column=0, columnspan=2, padx=10, pady=(0, 12), sticky="ew")
|
self.add_session_button.grid(row=0, column=0, padx=(0, 5), sticky="ew")
|
||||||
|
|
||||||
|
self.add_button = tk.Button(
|
||||||
|
actions, text="Добавить базы отмеченным пользователям (профили)",
|
||||||
|
font=("Segoe UI", 11), command=self.add_database_to_ibases
|
||||||
|
)
|
||||||
|
self.add_button.grid(row=0, column=1, padx=(5, 0), sticky="ew")
|
||||||
|
|
||||||
# По умолчанию грузим пользователей локального ПК
|
# По умолчанию грузим пользователей локального ПК
|
||||||
self.load_users()
|
self.load_users()
|
||||||
@@ -116,6 +149,7 @@ class RemoteFolderSelector(tk.Tk):
|
|||||||
tk.Label(f, text="IP-адрес (необязательно):").grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w")
|
tk.Label(f, text="IP-адрес (необязательно):").grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w")
|
||||||
self.ip_entry = tk.Entry(f)
|
self.ip_entry = tk.Entry(f)
|
||||||
self.ip_entry.grid(row=1, column=0, padx=5, pady=(0, 5), sticky="ew")
|
self.ip_entry.grid(row=1, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||||||
|
self.ip_entry.bind("<Return>", lambda e: self.load_users())
|
||||||
|
|
||||||
tk.Button(f, text="Загрузить пользователей", command=self.load_users)\
|
tk.Button(f, text="Загрузить пользователей", command=self.load_users)\
|
||||||
.grid(row=2, column=0, padx=5, pady=5, sticky="ew")
|
.grid(row=2, column=0, padx=5, pady=5, sticky="ew")
|
||||||
@@ -194,6 +228,10 @@ class RemoteFolderSelector(tk.Tk):
|
|||||||
self.password_entry = tk.Entry(f, show="*")
|
self.password_entry = tk.Entry(f, show="*")
|
||||||
self.password_entry.grid(row=5, column=0, padx=5, pady=(0, 5), sticky="ew")
|
self.password_entry.grid(row=5, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||||||
|
|
||||||
|
# Enter в любом из полей подключения — сразу загружаем базы
|
||||||
|
for entry in (self.server_entry, self.login_entry, self.password_entry):
|
||||||
|
entry.bind("<Return>", lambda e: self.load_databases())
|
||||||
|
|
||||||
tk.Button(f, text="Загрузить базы", command=self.load_databases)\
|
tk.Button(f, text="Загрузить базы", command=self.load_databases)\
|
||||||
.grid(row=6, column=0, padx=5, pady=5, sticky="ew")
|
.grid(row=6, column=0, padx=5, pady=5, sticky="ew")
|
||||||
|
|
||||||
@@ -218,6 +256,64 @@ class RemoteFolderSelector(tk.Tk):
|
|||||||
self.db_container.grid(row=10, column=0, padx=5, pady=5, sticky="nsew")
|
self.db_container.grid(row=10, column=0, padx=5, pady=5, sticky="nsew")
|
||||||
f.grid_rowconfigure(10, weight=1)
|
f.grid_rowconfigure(10, weight=1)
|
||||||
|
|
||||||
|
def _build_sessions_panel(self):
|
||||||
|
"""Панель поиска активных сеансов пользователя по домену."""
|
||||||
|
f = self.frame_mid
|
||||||
|
f.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
tk.Label(f, text="Сеансы пользователя в домене", font=("Segoe UI", 10, "bold"))\
|
||||||
|
.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w")
|
||||||
|
|
||||||
|
tk.Label(f, text="Имя пользователя:").grid(row=1, column=0, padx=5, pady=(5, 0), sticky="w")
|
||||||
|
self.session_user_var = tk.StringVar()
|
||||||
|
self.session_user_entry = tk.Entry(f, textvariable=self.session_user_var)
|
||||||
|
self.session_user_entry.grid(row=2, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||||||
|
self.session_user_entry.bind("<Return>", lambda e: self.find_sessions())
|
||||||
|
|
||||||
|
scan_bar = tk.Frame(f)
|
||||||
|
scan_bar.grid(row=3, column=0, padx=5, pady=5, sticky="ew")
|
||||||
|
scan_bar.grid_columnconfigure(0, weight=3)
|
||||||
|
scan_bar.grid_columnconfigure(1, weight=2)
|
||||||
|
|
||||||
|
self.scan_button = tk.Button(scan_bar, text="Найти сеансы", command=self.find_sessions)
|
||||||
|
self.scan_button.grid(row=0, column=0, padx=(0, 4), sticky="ew")
|
||||||
|
|
||||||
|
# Принудительный повторный опрос домена, когда данные скана устарели
|
||||||
|
self.rescan_button = tk.Button(scan_bar, text="Пересканировать",
|
||||||
|
command=lambda: self.find_sessions(force=True))
|
||||||
|
self.rescan_button.grid(row=0, column=1, sticky="ew")
|
||||||
|
|
||||||
|
# Отключённый сеанс тоже держит профиль загруженным, поэтому по умолчанию учитываем и его
|
||||||
|
self.include_disconnected = tk.BooleanVar(value=True)
|
||||||
|
tk.Checkbutton(f, text="Учитывать отключённые сеансы", variable=self.include_disconnected)\
|
||||||
|
.grid(row=4, column=0, padx=5, pady=(0, 3), sticky="w")
|
||||||
|
|
||||||
|
self.scan_status = tk.Label(f, text="", anchor="w", justify="left",
|
||||||
|
fg="#444", wraplength=320)
|
||||||
|
self.scan_status.grid(row=5, column=0, padx=5, pady=(0, 3), sticky="ew")
|
||||||
|
|
||||||
|
bar = tk.Frame(f)
|
||||||
|
bar.grid(row=6, column=0, padx=5, pady=(0, 3), sticky="w")
|
||||||
|
tk.Button(bar, text="Отметить все",
|
||||||
|
command=lambda: self._set_all(self.session_vars, "", True)).pack(side="left", padx=(0, 4))
|
||||||
|
tk.Button(bar, text="Снять все",
|
||||||
|
command=lambda: self._set_all(self.session_vars, "", False)).pack(side="left")
|
||||||
|
|
||||||
|
self.sess_container, self.sess_inner = self._create_scrollable_checklist(f, height=330)
|
||||||
|
self.sess_container.grid(row=7, column=0, padx=5, pady=5, sticky="nsew")
|
||||||
|
f.grid_rowconfigure(7, weight=1)
|
||||||
|
|
||||||
|
tk.Label(f, text="Базы добавляются в профиль на той машине,\nгде найден сеанс — "
|
||||||
|
"чтобы правку не затёрло\nпри выходе из перемещаемого профиля.",
|
||||||
|
anchor="w", justify="left", fg="#666")\
|
||||||
|
.grid(row=8, column=0, padx=5, pady=(3, 0), sticky="ew")
|
||||||
|
|
||||||
|
if ad_sessions is None:
|
||||||
|
self.scan_button.config(state="disabled")
|
||||||
|
self.session_user_entry.config(state="disabled")
|
||||||
|
self.scan_status.config(
|
||||||
|
text=f"Поиск сеансов недоступен: не загружен модуль ({SESSIONS_ERROR})", fg="#a00")
|
||||||
|
|
||||||
def _create_scrollable_checklist(self, parent, width=220, height=180):
|
def _create_scrollable_checklist(self, parent, width=220, height=180):
|
||||||
"""Прокручиваемая область для чекбоксов. Возвращает (container, inner)."""
|
"""Прокручиваемая область для чекбоксов. Возвращает (container, inner)."""
|
||||||
container = tk.Frame(parent, borderwidth=1, relief="sunken")
|
container = tk.Frame(parent, borderwidth=1, relief="sunken")
|
||||||
@@ -328,8 +424,11 @@ class RemoteFolderSelector(tk.Tk):
|
|||||||
self._render_checklist(self.db_inner, self.db_vars, self.db_search_var.get())
|
self._render_checklist(self.db_inner, self.db_vars, self.db_search_var.get())
|
||||||
|
|
||||||
def on_user_click(self, user):
|
def on_user_click(self, user):
|
||||||
"""Клик по пользователю — делаем его активным и показываем его подключённые базы."""
|
"""Клик по пользователю — делаем его активным и показываем его подключённые базы.
|
||||||
|
Заодно подставляем имя в поле поиска сеансов, чтобы не набирать вручную."""
|
||||||
self.load_connected_bases(user)
|
self.load_connected_bases(user)
|
||||||
|
if ad_sessions is not None:
|
||||||
|
self.session_user_var.set(user)
|
||||||
|
|
||||||
def load_connected_bases(self, user):
|
def load_connected_bases(self, user):
|
||||||
"""Читаем ibases.v8i пользователя и показываем список его баз в панели."""
|
"""Читаем ibases.v8i пользователя и показываем список его баз в панели."""
|
||||||
@@ -571,6 +670,210 @@ class RemoteFolderSelector(tk.Tk):
|
|||||||
summary += f"\nУдалено в ZIP-профиле: {removed_zip}"
|
summary += f"\nУдалено в ZIP-профиле: {removed_zip}"
|
||||||
self._report(summary, errors)
|
self._report(summary, errors)
|
||||||
|
|
||||||
|
# ------------------------------------------------------- Сеансы в домене
|
||||||
|
|
||||||
|
# Сколько секунд снимок домена считается свежим. За это время поиск другого
|
||||||
|
# пользователя отрабатывает мгновенно, без повторного опроса машин.
|
||||||
|
SCAN_CACHE_TTL = 120
|
||||||
|
|
||||||
|
def find_sessions(self, force=False):
|
||||||
|
"""Ищем сеансы пользователя. Если снимок домена свежий — фильтруем его
|
||||||
|
мгновенно; иначе опрашиваем домен в фоне, чтобы окно не подвисало."""
|
||||||
|
if ad_sessions is None or self.scanning:
|
||||||
|
return
|
||||||
|
|
||||||
|
user = self.session_user_var.get().strip()
|
||||||
|
if not user:
|
||||||
|
messagebox.showerror("Ошибка", "Укажите имя пользователя для поиска сеансов.")
|
||||||
|
return
|
||||||
|
|
||||||
|
domain = ad_sessions.current_domain()
|
||||||
|
include_disc = self.include_disconnected.get()
|
||||||
|
|
||||||
|
age = time.monotonic() - self.scan_cache_at
|
||||||
|
if not force and self.scan_cache is not None and age < self.SCAN_CACHE_TTL:
|
||||||
|
matched = ad_sessions.filter_sessions(
|
||||||
|
self.scan_cache, user=user, domain=domain,
|
||||||
|
include_disconnected=include_disc)
|
||||||
|
stats = dict(self.scan_cache_stats)
|
||||||
|
stats["matched"] = len(matched)
|
||||||
|
stats["from_cache"] = True
|
||||||
|
stats["cache_age"] = int(age)
|
||||||
|
self._on_sessions_found(user, matched, [], stats, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.scanning = True
|
||||||
|
self.scan_button.config(state="disabled", text="Идёт поиск...")
|
||||||
|
self.rescan_button.config(state="disabled")
|
||||||
|
self.scan_status.config(text="Получаем список компьютеров из AD...", fg="#444")
|
||||||
|
self.session_vars.clear()
|
||||||
|
self.sessions_by_key.clear()
|
||||||
|
self._render_checklist(self.sess_inner, self.session_vars, "")
|
||||||
|
|
||||||
|
def progress(stage, done, total):
|
||||||
|
text = f"{stage} {done}/{total}" if total else stage
|
||||||
|
self.after(0, lambda: self.scan_status.config(text=text))
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
# Снимаем сеансы всех пользователей разом и кладём в кэш —
|
||||||
|
# следующий поиск по другому имени будет мгновенным
|
||||||
|
everyone, errors, stats = ad_sessions.scan_domain(progress=progress)
|
||||||
|
matched = ad_sessions.filter_sessions(
|
||||||
|
everyone, user=user, domain=domain,
|
||||||
|
include_disconnected=include_disc)
|
||||||
|
stats["matched"] = len(matched)
|
||||||
|
self.after(0, self._store_scan_cache, everyone, stats)
|
||||||
|
self.after(0, self._on_sessions_found, user, matched, errors, stats, None)
|
||||||
|
except Exception as exc:
|
||||||
|
self.after(0, self._on_sessions_found, user, [], [], {},
|
||||||
|
f"{type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def _store_scan_cache(self, sessions, stats):
|
||||||
|
self.scan_cache = sessions
|
||||||
|
self.scan_cache_at = time.monotonic()
|
||||||
|
self.scan_cache_stats = dict(stats)
|
||||||
|
|
||||||
|
def _on_sessions_found(self, user, sessions, errors, stats, fatal):
|
||||||
|
"""Обработка результата фонового сканирования (вызывается в потоке GUI)."""
|
||||||
|
self.scanning = False
|
||||||
|
self.scan_button.config(state="normal", text="Найти сеансы")
|
||||||
|
self.rescan_button.config(state="normal")
|
||||||
|
|
||||||
|
if fatal:
|
||||||
|
self.scan_status.config(text=f"Ошибка поиска: {fatal}", fg="#a00")
|
||||||
|
messagebox.showerror("Ошибка", f"Не удалось выполнить поиск сеансов:\n{fatal}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Чистим здесь, а не только перед сканированием: поиск по кэшу приходит
|
||||||
|
# сюда напрямую, и без этого результаты прошлого поиска накапливались бы
|
||||||
|
self.session_vars.clear()
|
||||||
|
self.sessions_by_key.clear()
|
||||||
|
|
||||||
|
for sess in sessions:
|
||||||
|
machine = ad_sessions.short_name(sess["machine"])
|
||||||
|
key = f"{machine} (сеанс {sess['session_id']}, {sess['state_name']})"
|
||||||
|
if sess.get("client"):
|
||||||
|
key += f" ← {sess['client']}"
|
||||||
|
self.sessions_by_key[key] = sess
|
||||||
|
self.session_vars[key] = tk.BooleanVar(value=True) # найденное сразу отмечено
|
||||||
|
|
||||||
|
self._render_checklist(self.sess_inner, self.session_vars, "")
|
||||||
|
|
||||||
|
total = stats.get("total", 0)
|
||||||
|
alive = stats.get("alive", 0)
|
||||||
|
failed = stats.get("failed", 0)
|
||||||
|
unfinished = stats.get("unfinished", 0)
|
||||||
|
|
||||||
|
if sessions:
|
||||||
|
head = f"Найдено сеансов: {len(sessions)} у «{user}»."
|
||||||
|
color = "#060"
|
||||||
|
else:
|
||||||
|
head = f"Сеансов пользователя «{user}» не найдено."
|
||||||
|
color = "#a60"
|
||||||
|
|
||||||
|
if stats.get("from_cache"):
|
||||||
|
tail = (f"Данные скана {stats.get('cache_age', 0)} с назад "
|
||||||
|
f"(машин: {total}, с сеансами: {alive}). "
|
||||||
|
f"Кнопка «Пересканировать» обновит.")
|
||||||
|
else:
|
||||||
|
tail = (f"Машин: {total}, ответили: {alive}, "
|
||||||
|
f"выключено: {stats.get('offline', 0)}. "
|
||||||
|
f"Время: {stats.get('scan_seconds', 0)} с "
|
||||||
|
f"(AD {stats.get('ad_seconds', 0)} с).")
|
||||||
|
if failed:
|
||||||
|
tail += f" Ошибок: {failed}."
|
||||||
|
if unfinished:
|
||||||
|
tail += f" Не успели за отведённое время: {unfinished}."
|
||||||
|
self.scan_status.config(text=head + " " + tail, fg=color)
|
||||||
|
|
||||||
|
# Если сеансов не нашлось, а часть машин не опрошена — предупреждаем явно.
|
||||||
|
# Иначе админ решит, что пользователь нигде не залогинен, добавит базу
|
||||||
|
# в перемещаемый профиль, и её затрёт при выходе из пропущенного сеанса.
|
||||||
|
if not sessions and (failed or unfinished) and not stats.get("from_cache"):
|
||||||
|
details = ""
|
||||||
|
if errors:
|
||||||
|
details = "\n\n" + "\n".join(f"{host}: {err}" for host, err in errors[:8])
|
||||||
|
messagebox.showwarning(
|
||||||
|
"Опрошены не все машины",
|
||||||
|
f"Сеансов пользователя «{user}» не найдено, но "
|
||||||
|
f"{failed + unfinished} машин(ы) не ответили.\n\n"
|
||||||
|
"Пользователь может сидеть на одной из них. Прежде чем писать "
|
||||||
|
"в перемещаемый профиль, нажмите «Пересканировать»."
|
||||||
|
+ details)
|
||||||
|
|
||||||
|
def add_bases_to_sessions(self):
|
||||||
|
"""Добавляем отмеченные базы в профили на машинах отмеченных сеансов."""
|
||||||
|
if ad_sessions is None:
|
||||||
|
messagebox.showerror("Ошибка", "Модуль поиска сеансов недоступен.")
|
||||||
|
return
|
||||||
|
|
||||||
|
keys = self._get_checked(self.session_vars)
|
||||||
|
bases = self._get_checked(self.db_vars)
|
||||||
|
server = self.server_entry.get().strip()
|
||||||
|
|
||||||
|
if not keys:
|
||||||
|
messagebox.showerror("Ошибка", "Не отмечен ни один сеанс!")
|
||||||
|
return
|
||||||
|
if not bases:
|
||||||
|
messagebox.showerror("Ошибка", "Не отмечена ни одна база!")
|
||||||
|
return
|
||||||
|
if not server:
|
||||||
|
messagebox.showerror("Ошибка", "Не указан SQL-сервер!")
|
||||||
|
return
|
||||||
|
|
||||||
|
chosen = [self.sessions_by_key[key] for key in keys]
|
||||||
|
|
||||||
|
# Список сеансов мог быть снят пару минут назад — убеждаемся, что они ещё живы.
|
||||||
|
# Машин тут единицы, проверка почти мгновенная.
|
||||||
|
chosen, gone = ad_sessions.verify_sessions(chosen)
|
||||||
|
if gone:
|
||||||
|
names = "\n".join(
|
||||||
|
f"{ad_sessions.short_name(s['machine'])} (сеанс {s['session_id']})"
|
||||||
|
for s in gone)
|
||||||
|
if not chosen:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Сеансы завершены",
|
||||||
|
"Выбранные сеансы уже завершены — пользователь вышел:\n\n"
|
||||||
|
f"{names}\n\nНажмите «Пересканировать» и попробуйте снова.")
|
||||||
|
return
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Часть сеансов завершена",
|
||||||
|
f"Эти сеансы уже завершены и будут пропущены:\n\n{names}\n\n"
|
||||||
|
"Продолжить с остальными?"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# На одной машине у пользователя может быть несколько сеансов (например,
|
||||||
|
# активный и отключённый), а профиль у них общий — пишем в машину один раз.
|
||||||
|
by_machine = {}
|
||||||
|
for sess in chosen:
|
||||||
|
by_machine.setdefault(sess["machine"], sess)
|
||||||
|
|
||||||
|
added = skipped = 0
|
||||||
|
done_machines = []
|
||||||
|
errors = []
|
||||||
|
for machine, sess in by_machine.items():
|
||||||
|
try:
|
||||||
|
path = ad_sessions.ibases_path_for_session(sess)
|
||||||
|
a, s = self._add_entries_to_file(path, bases, server)
|
||||||
|
added += a
|
||||||
|
skipped += s
|
||||||
|
done_machines.append(f"{ad_sessions.short_name(machine)}: +{a}, пропущено {s}")
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"{ad_sessions.short_name(machine)}: {exc}")
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"Пользователь: {self.session_user_var.get().strip()}\n"
|
||||||
|
f"Машин обработано: {len(done_machines)} из {len(by_machine)}, "
|
||||||
|
f"баз отмечено: {len(bases)}\n"
|
||||||
|
f"Добавлено: {added}, пропущено (уже есть): {skipped}"
|
||||||
|
)
|
||||||
|
if done_machines:
|
||||||
|
summary += "\n\n" + "\n".join(done_machines)
|
||||||
|
self._report(summary, errors)
|
||||||
|
|
||||||
def _report(self, summary, errors):
|
def _report(self, summary, errors):
|
||||||
if errors:
|
if errors:
|
||||||
messagebox.showwarning("Готово с ошибками", summary + "\n\nОшибки:\n" + "\n".join(errors))
|
messagebox.showwarning("Готово с ошибками", summary + "\n\nОшибки:\n" + "\n".join(errors))
|
||||||
|
|||||||
+531
@@ -0,0 +1,531 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Обнаружение сеансов пользователей по домену.
|
||||||
|
|
||||||
|
Логика повторяет то, как это делает Dameware NT Utilities:
|
||||||
|
1. список компьютеров берём из AD (ADSI/LDAP, RSAT не нужен);
|
||||||
|
2. быстро отсеиваем выключенные машины проверкой порта 445 (асинхронно);
|
||||||
|
3. живые опрашиваем через WTS API — тот же интерфейс, что у диспетчера задач,
|
||||||
|
без разбора текстового вывода quser (он зависит от локали Windows).
|
||||||
|
|
||||||
|
Модуль не содержит GUI и может использоваться отдельно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
import win32api
|
||||||
|
import win32con
|
||||||
|
import win32security
|
||||||
|
|
||||||
|
# WTS дёргаем через ctypes, а не через pywin32, сознательно: pywin32 не отпускает
|
||||||
|
# GIL на время нативного вызова, из-за чего опрос машин выполняется строго
|
||||||
|
# последовательно и никакие потоки не помогают. ctypes.WinDLL GIL освобождает,
|
||||||
|
# и параллельный опрос домена начинает работать по-настоящему.
|
||||||
|
_wts = ctypes.WinDLL("wtsapi32.dll", use_last_error=True)
|
||||||
|
|
||||||
|
WTS_CURRENT_SERVER_HANDLE = 0
|
||||||
|
|
||||||
|
# Классы информации о сеансе (WTS_INFO_CLASS)
|
||||||
|
_WTS_USER_NAME = 5
|
||||||
|
_WTS_DOMAIN_NAME = 7
|
||||||
|
_WTS_CLIENT_NAME = 10
|
||||||
|
|
||||||
|
|
||||||
|
class _WTS_SESSION_INFOW(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("SessionId", wintypes.DWORD),
|
||||||
|
("pWinStationName", wintypes.LPWSTR),
|
||||||
|
("State", ctypes.c_int),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_wts.WTSOpenServerW.argtypes = [wintypes.LPWSTR]
|
||||||
|
_wts.WTSOpenServerW.restype = wintypes.HANDLE
|
||||||
|
|
||||||
|
_wts.WTSCloseServer.argtypes = [wintypes.HANDLE]
|
||||||
|
_wts.WTSCloseServer.restype = None
|
||||||
|
|
||||||
|
_wts.WTSEnumerateSessionsW.argtypes = [
|
||||||
|
wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD,
|
||||||
|
ctypes.POINTER(ctypes.POINTER(_WTS_SESSION_INFOW)),
|
||||||
|
ctypes.POINTER(wintypes.DWORD),
|
||||||
|
]
|
||||||
|
_wts.WTSEnumerateSessionsW.restype = wintypes.BOOL
|
||||||
|
|
||||||
|
_wts.WTSQuerySessionInformationW.argtypes = [
|
||||||
|
wintypes.HANDLE, wintypes.DWORD, ctypes.c_int,
|
||||||
|
ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(wintypes.DWORD),
|
||||||
|
]
|
||||||
|
_wts.WTSQuerySessionInformationW.restype = wintypes.BOOL
|
||||||
|
|
||||||
|
_wts.WTSFreeMemory.argtypes = [ctypes.c_void_p]
|
||||||
|
_wts.WTSFreeMemory.restype = None
|
||||||
|
|
||||||
|
# Значения по умолчанию для сканирования домена.
|
||||||
|
# Опрос упирается не в процессор, а в ожидание сети, поэтому потоков берём много.
|
||||||
|
DEFAULT_WORKERS = 256
|
||||||
|
# Жёсткий предел на весь опрос. Машины, у которых открыт порт 445, но закрыт RPC,
|
||||||
|
# вешают WTSOpenServer на 20-45 секунд — без общего дедлайна они растягивают скан.
|
||||||
|
DEFAULT_DEADLINE = 30.0
|
||||||
|
DEFAULT_PORT_TIMEOUT = 0.4
|
||||||
|
# Сколько ждать отставшие машины после того, как очередь разобрана.
|
||||||
|
# Зависшие на RPC не дождутся никогда, а живые обычно отвечают за доли секунды.
|
||||||
|
DEFAULT_GRACE = 4.0
|
||||||
|
|
||||||
|
# Состояния сеанса WTS
|
||||||
|
WTS_ACTIVE = 0
|
||||||
|
WTS_DISCONNECTED = 4
|
||||||
|
|
||||||
|
STATE_NAMES = {
|
||||||
|
0: "Активен",
|
||||||
|
1: "Подключается",
|
||||||
|
2: "Запрос подключения",
|
||||||
|
3: "Теневой",
|
||||||
|
4: "Отключён",
|
||||||
|
5: "Простой",
|
||||||
|
6: "Ожидание",
|
||||||
|
7: "Сброс",
|
||||||
|
8: "Отключение",
|
||||||
|
9: "Инициализация",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сеансы, которые держат профиль пользователя загруженным. Отключённый (Disconnected)
|
||||||
|
# сеанс профиль НЕ выгружает, поэтому при перемещаемых профилях писать в него нужно
|
||||||
|
# так же, как в активный — иначе правку затрёт при выходе пользователя.
|
||||||
|
PROFILE_HELD_STATES = (WTS_ACTIVE, WTS_DISCONNECTED)
|
||||||
|
|
||||||
|
PROFILE_LIST_KEY = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ AD
|
||||||
|
|
||||||
|
def list_domain_computers():
|
||||||
|
"""Список компьютеров домена (активные учётки) через ADSI.
|
||||||
|
|
||||||
|
Возвращает список имён (FQDN, если заполнен dNSHostName).
|
||||||
|
Используется только pywin32 — дополнительных зависимостей и RSAT не требуется.
|
||||||
|
"""
|
||||||
|
import pythoncom
|
||||||
|
import win32com.client
|
||||||
|
|
||||||
|
# COM инициализируется отдельно в КАЖДОМ потоке. Ленивый импорт win32com делает
|
||||||
|
# это неявно только для того потока, где импорт случился первым, поэтому повторный
|
||||||
|
# поиск (кнопка «Пересканировать» создаёт новый поток) падал с MK_E_SYNTAX
|
||||||
|
# «Синтаксическая ошибка». Инициализируем явно и симметрично освобождаем.
|
||||||
|
try:
|
||||||
|
pythoncom.CoInitialize()
|
||||||
|
com_ready = True
|
||||||
|
except Exception:
|
||||||
|
com_ready = False # поток уже в другой модели — работаем как есть
|
||||||
|
|
||||||
|
root = conn = cmd = rs = None
|
||||||
|
names = []
|
||||||
|
try:
|
||||||
|
root = win32com.client.GetObject("LDAP://RootDSE")
|
||||||
|
base_dn = root.Get("defaultNamingContext")
|
||||||
|
|
||||||
|
conn = win32com.client.Dispatch("ADODB.Connection")
|
||||||
|
conn.Provider = "ADsDSOObject"
|
||||||
|
conn.Open("Active Directory Provider")
|
||||||
|
|
||||||
|
cmd = win32com.client.Dispatch("ADODB.Command")
|
||||||
|
cmd.ActiveConnection = conn
|
||||||
|
# Без Page Size AD вернёт максимум 1000 записей и молча обрежет остальные
|
||||||
|
cmd.Properties("Page Size").Value = 1000
|
||||||
|
cmd.Properties("Timeout").Value = 30
|
||||||
|
cmd.CommandText = (
|
||||||
|
f"<LDAP://{base_dn}>;"
|
||||||
|
# (!userAccountControl:...:=2) — отбрасываем отключённые учётки компьютеров
|
||||||
|
"(&(objectCategory=computer)(!userAccountControl:1.2.840.113556.1.4.803:=2));"
|
||||||
|
"dNSHostName,name;subtree"
|
||||||
|
)
|
||||||
|
rs = cmd.Execute()
|
||||||
|
if isinstance(rs, tuple): # позднее связывание отдаёт (recordset, records_affected)
|
||||||
|
rs = rs[0]
|
||||||
|
|
||||||
|
while not rs.EOF:
|
||||||
|
dns = rs.Fields.Item("dNSHostName").Value
|
||||||
|
name = rs.Fields.Item("name").Value
|
||||||
|
host = dns or name
|
||||||
|
if host:
|
||||||
|
names.append(str(host))
|
||||||
|
rs.MoveNext()
|
||||||
|
rs.Close()
|
||||||
|
finally:
|
||||||
|
if conn is not None:
|
||||||
|
try:
|
||||||
|
conn.Close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Отпускаем COM-объекты ДО CoUninitialize: иначе их деструкторы сработают,
|
||||||
|
# когда COM в потоке уже деинициализирован, и посыплется
|
||||||
|
# "Win32 exception occurred releasing IUnknown".
|
||||||
|
rs = cmd = conn = root = None
|
||||||
|
if com_ready:
|
||||||
|
try:
|
||||||
|
pythoncom.CoUninitialize()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def current_domain():
|
||||||
|
"""NetBIOS-имя домена текущего пользователя (пустая строка, если не в домене)."""
|
||||||
|
return os.environ.get("USERDOMAIN", "")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- Проверка доступности
|
||||||
|
|
||||||
|
def is_alive(host, port=445, timeout=0.4):
|
||||||
|
"""Быстрая проверка, что машина включена и отвечает по SMB."""
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=timeout):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parallel(func, items, workers, deadline, grace=DEFAULT_GRACE,
|
||||||
|
progress=None, stage=""):
|
||||||
|
"""Выполняем func по всем items в несколько потоков, не ожидая зависших.
|
||||||
|
|
||||||
|
Своя реализация вместо ThreadPoolExecutor по трём причинам:
|
||||||
|
* потоки демонические — зависший в нативном вызове WTSOpenServer поток
|
||||||
|
не мешает закрыть программу (ThreadPoolExecutor ждёт свои потоки на выходе);
|
||||||
|
* прервать зависший вызов нельзя, но можно перестать его ждать: как только
|
||||||
|
очередь машин разобрана, даём отставшим ровно `grace` секунд и уходим.
|
||||||
|
Именно это отличает 5 секунд от полутора минут — машины с закрытым RPC
|
||||||
|
висят по 20-45 с каждая, и ждать их бессмысленно;
|
||||||
|
* `deadline` остаётся страховкой на случай очень большого парка.
|
||||||
|
|
||||||
|
Возвращает (results, unfinished), где results — список (item, value, error).
|
||||||
|
"""
|
||||||
|
items = list(items)
|
||||||
|
total = len(items)
|
||||||
|
if not total:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
pending = deque(items)
|
||||||
|
pending_lock = threading.Lock()
|
||||||
|
results = []
|
||||||
|
results_lock = threading.Lock()
|
||||||
|
counter = [0]
|
||||||
|
in_flight = [0]
|
||||||
|
end_at = time.monotonic() + deadline
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
while True:
|
||||||
|
if time.monotonic() >= end_at:
|
||||||
|
return
|
||||||
|
with pending_lock:
|
||||||
|
if not pending:
|
||||||
|
return
|
||||||
|
item = pending.popleft()
|
||||||
|
in_flight[0] += 1
|
||||||
|
try:
|
||||||
|
value, error = func(item), None
|
||||||
|
except Exception as exc:
|
||||||
|
value, error = None, f"{type(exc).__name__}: {exc}"
|
||||||
|
with results_lock:
|
||||||
|
results.append((item, value, error))
|
||||||
|
counter[0] += 1
|
||||||
|
done = counter[0]
|
||||||
|
with pending_lock:
|
||||||
|
in_flight[0] -= 1
|
||||||
|
# Прогресс обновляем пачками, чтобы не забивать очередь событий Tk
|
||||||
|
if progress and (done % 5 == 0 or done == total):
|
||||||
|
progress(stage, done, total)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=worker, daemon=True)
|
||||||
|
for _ in range(min(workers, total))]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
drain_started = None
|
||||||
|
while True:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now >= end_at:
|
||||||
|
break
|
||||||
|
with pending_lock:
|
||||||
|
queue_empty = not pending
|
||||||
|
running = in_flight[0]
|
||||||
|
if queue_empty:
|
||||||
|
if running == 0:
|
||||||
|
break # все машины честно опрошены
|
||||||
|
if drain_started is None:
|
||||||
|
drain_started = now
|
||||||
|
elif now - drain_started >= grace:
|
||||||
|
break # остальные зависли — дальше не ждём
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
with results_lock:
|
||||||
|
handled = {item for item, _, _ in results}
|
||||||
|
snapshot = list(results)
|
||||||
|
unfinished = [i for i in items if i not in handled]
|
||||||
|
return snapshot, unfinished
|
||||||
|
|
||||||
|
|
||||||
|
def filter_alive(hosts, timeout=DEFAULT_PORT_TIMEOUT, workers=DEFAULT_WORKERS):
|
||||||
|
"""Оставляем только машины, ответившие на порт 445."""
|
||||||
|
results, _ = _parallel(lambda h: is_alive(h, timeout=timeout), hosts,
|
||||||
|
workers, DEFAULT_DEADLINE)
|
||||||
|
return [host for host, alive, err in results if alive and not err]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ WTS
|
||||||
|
|
||||||
|
def _query_session(handle, session_id, info_class):
|
||||||
|
"""Строковое свойство сеанса; пустая строка, если недоступно."""
|
||||||
|
buffer = ctypes.c_void_p()
|
||||||
|
returned = wintypes.DWORD()
|
||||||
|
ok = _wts.WTSQuerySessionInformationW(
|
||||||
|
handle, session_id, info_class, ctypes.byref(buffer), ctypes.byref(returned))
|
||||||
|
if not ok or not buffer:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return ctypes.cast(buffer, ctypes.c_wchar_p).value or ""
|
||||||
|
finally:
|
||||||
|
_wts.WTSFreeMemory(buffer)
|
||||||
|
|
||||||
|
|
||||||
|
def enum_sessions(machine=None):
|
||||||
|
"""Сеансы одной машины. machine=None — локальная.
|
||||||
|
|
||||||
|
Возвращает список словарей. Сеансы без пользователя (службы, listener) пропускаем.
|
||||||
|
"""
|
||||||
|
remote = bool(machine)
|
||||||
|
if remote:
|
||||||
|
handle = _wts.WTSOpenServerW(machine)
|
||||||
|
if not handle:
|
||||||
|
raise OSError(ctypes.WinError(ctypes.get_last_error()))
|
||||||
|
else:
|
||||||
|
handle = WTS_CURRENT_SERVER_HANDLE
|
||||||
|
|
||||||
|
info_ptr = ctypes.POINTER(_WTS_SESSION_INFOW)()
|
||||||
|
count = wintypes.DWORD()
|
||||||
|
result = []
|
||||||
|
try:
|
||||||
|
ok = _wts.WTSEnumerateSessionsW(
|
||||||
|
handle, 0, 1, ctypes.byref(info_ptr), ctypes.byref(count))
|
||||||
|
if not ok:
|
||||||
|
raise OSError(ctypes.WinError(ctypes.get_last_error()))
|
||||||
|
try:
|
||||||
|
for i in range(count.value):
|
||||||
|
entry = info_ptr[i]
|
||||||
|
session_id = entry.SessionId
|
||||||
|
|
||||||
|
user = _query_session(handle, session_id, _WTS_USER_NAME)
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
result.append({
|
||||||
|
"machine": machine or os.environ.get("COMPUTERNAME", ""),
|
||||||
|
"session_id": session_id,
|
||||||
|
"station": entry.pWinStationName or "",
|
||||||
|
"state": entry.State,
|
||||||
|
"state_name": STATE_NAMES.get(entry.State, str(entry.State)),
|
||||||
|
"user": user,
|
||||||
|
"domain": _query_session(handle, session_id, _WTS_DOMAIN_NAME),
|
||||||
|
"client": _query_session(handle, session_id, _WTS_CLIENT_NAME),
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
_wts.WTSFreeMemory(ctypes.cast(info_ptr, ctypes.c_void_p))
|
||||||
|
finally:
|
||||||
|
if remote:
|
||||||
|
_wts.WTSCloseServer(handle)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_OFFLINE = object() # маркер: машина не ответила на порт 445
|
||||||
|
|
||||||
|
|
||||||
|
def scan_domain(computers=None, workers=DEFAULT_WORKERS, port_timeout=DEFAULT_PORT_TIMEOUT,
|
||||||
|
deadline=DEFAULT_DEADLINE, grace=DEFAULT_GRACE, progress=None):
|
||||||
|
"""Снимаем сеансы со всех машин домена одним проходом.
|
||||||
|
|
||||||
|
Проверка порта и опрос WTS выполняются в одной задаче: поток, освободившись,
|
||||||
|
сразу берёт следующую машину. Раньше это были две последовательные фазы,
|
||||||
|
и вторая простаивала, пока первая доделывала самую медленную машину.
|
||||||
|
|
||||||
|
Возвращает (sessions, errors, stats) — сеансы всех пользователей, без фильтра.
|
||||||
|
"""
|
||||||
|
t_start = time.monotonic()
|
||||||
|
|
||||||
|
if progress:
|
||||||
|
progress("Получаем список компьютеров из AD...", 0, 0)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
hosts = list(computers) if computers else list_domain_computers()
|
||||||
|
ad_seconds = time.monotonic() - t0
|
||||||
|
|
||||||
|
stats = {"total": len(hosts), "alive": 0, "offline": 0, "failed": 0,
|
||||||
|
"unfinished": 0, "sessions_total": 0,
|
||||||
|
"ad_seconds": round(ad_seconds, 2), "scan_seconds": 0.0,
|
||||||
|
"total_seconds": 0.0}
|
||||||
|
if not hosts:
|
||||||
|
return [], [], stats
|
||||||
|
|
||||||
|
def probe(host):
|
||||||
|
# Быстрый отсев выключенных машин: без него WTSOpenServer будет ждать
|
||||||
|
# RPC-таймаут в десятки секунд на каждой недоступной машине.
|
||||||
|
if not is_alive(host, timeout=port_timeout):
|
||||||
|
return _OFFLINE
|
||||||
|
return enum_sessions(host)
|
||||||
|
|
||||||
|
t0 = time.monotonic()
|
||||||
|
results, unfinished = _parallel(probe, hosts, workers, deadline, grace,
|
||||||
|
progress, "Опрашиваем машины...")
|
||||||
|
scan_seconds = time.monotonic() - t0
|
||||||
|
|
||||||
|
sessions, errors = [], []
|
||||||
|
for host, value, error in results:
|
||||||
|
if error:
|
||||||
|
errors.append((host, error))
|
||||||
|
elif value is _OFFLINE:
|
||||||
|
stats["offline"] += 1
|
||||||
|
else:
|
||||||
|
stats["alive"] += 1
|
||||||
|
sessions.extend(value)
|
||||||
|
|
||||||
|
stats["failed"] = len(errors)
|
||||||
|
stats["unfinished"] = len(unfinished)
|
||||||
|
stats["sessions_total"] = len(sessions)
|
||||||
|
stats["scan_seconds"] = round(scan_seconds, 2)
|
||||||
|
stats["total_seconds"] = round(time.monotonic() - t_start, 2)
|
||||||
|
return sessions, errors, stats
|
||||||
|
|
||||||
|
|
||||||
|
def filter_sessions(sessions, user=None, domain=None, include_disconnected=True):
|
||||||
|
"""Отбираем из готового списка сеансы нужного пользователя.
|
||||||
|
|
||||||
|
Вынесено отдельно, чтобы поиск другого пользователя по уже собранным
|
||||||
|
данным происходил мгновенно, без повторного опроса домена.
|
||||||
|
"""
|
||||||
|
allowed = PROFILE_HELD_STATES if include_disconnected else (WTS_ACTIVE,)
|
||||||
|
out = [s for s in sessions if s["state"] in allowed]
|
||||||
|
|
||||||
|
if user:
|
||||||
|
target = user.strip().lower()
|
||||||
|
out = [s for s in out if s["user"].lower() == target]
|
||||||
|
if domain:
|
||||||
|
dom = domain.strip().lower()
|
||||||
|
# У локальных учёток в domain стоит имя машины — такие отсеиваем
|
||||||
|
out = [s for s in out if s["domain"].lower() == dom]
|
||||||
|
|
||||||
|
out.sort(key=lambda s: (s["machine"].lower(), s["session_id"]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def find_sessions(user=None, computers=None, domain=None, include_disconnected=True,
|
||||||
|
port_timeout=DEFAULT_PORT_TIMEOUT, workers=DEFAULT_WORKERS,
|
||||||
|
deadline=DEFAULT_DEADLINE, progress=None):
|
||||||
|
"""Опрашиваем домен и сразу отбираем сеансы одного пользователя."""
|
||||||
|
sessions, errors, stats = scan_domain(
|
||||||
|
computers=computers, workers=workers, port_timeout=port_timeout,
|
||||||
|
deadline=deadline, progress=progress)
|
||||||
|
matched = filter_sessions(sessions, user=user, domain=domain,
|
||||||
|
include_disconnected=include_disconnected)
|
||||||
|
stats["matched"] = len(matched)
|
||||||
|
return matched, errors, stats
|
||||||
|
|
||||||
|
|
||||||
|
def verify_sessions(sessions):
|
||||||
|
"""Перепроверяем перед записью, что сеансы ещё живы.
|
||||||
|
|
||||||
|
Данные скана могут быть слегка устаревшими — пользователь мог выйти.
|
||||||
|
Машин здесь единицы, так что проверка почти мгновенная.
|
||||||
|
"""
|
||||||
|
still_there, gone = [], []
|
||||||
|
checked = {}
|
||||||
|
for sess in sessions:
|
||||||
|
machine = sess["machine"]
|
||||||
|
if machine not in checked:
|
||||||
|
try:
|
||||||
|
checked[machine] = enum_sessions(machine)
|
||||||
|
except Exception:
|
||||||
|
checked[machine] = None # не смогли проверить — не мешаем записи
|
||||||
|
current = checked[machine]
|
||||||
|
if current is None:
|
||||||
|
still_there.append(sess)
|
||||||
|
continue
|
||||||
|
match = any(c["user"].lower() == sess["user"].lower()
|
||||||
|
and c["domain"].lower() == sess["domain"].lower()
|
||||||
|
and c["state"] in PROFILE_HELD_STATES
|
||||||
|
for c in current)
|
||||||
|
(still_there if match else gone).append(sess)
|
||||||
|
return still_there, gone
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- Путь профиля
|
||||||
|
|
||||||
|
def _to_unc(machine, local_path):
|
||||||
|
"""C:\\Users\\Ivanov на машине PC1 -> \\\\PC1\\C$\\Users\\Ivanov"""
|
||||||
|
drive, rest = os.path.splitdrive(local_path)
|
||||||
|
if not drive:
|
||||||
|
return local_path
|
||||||
|
return f"\\\\{machine}\\{drive[0]}$" + rest
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_profile_dir(machine, domain, user):
|
||||||
|
"""Каталог профиля пользователя на машине, в виде UNC-пути.
|
||||||
|
|
||||||
|
Основной способ — SID + реестр ProfileList: корректно разрешает случаи
|
||||||
|
вида Ivanov.CORP или Ivanov.000, которые не угадать по имени.
|
||||||
|
Если удалённый реестр недоступен (служба RemoteRegistry остановлена),
|
||||||
|
перебираем типовые варианты имени папки.
|
||||||
|
"""
|
||||||
|
account = f"{domain}\\{user}" if domain else user
|
||||||
|
|
||||||
|
sid_str = None
|
||||||
|
for lookup_host in (machine, None):
|
||||||
|
try:
|
||||||
|
sid_obj, _, _ = win32security.LookupAccountName(lookup_host, account)
|
||||||
|
sid_str = win32security.ConvertSidToStringSid(sid_obj)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if sid_str:
|
||||||
|
try:
|
||||||
|
root = win32api.RegConnectRegistry(f"\\\\{machine}", win32con.HKEY_LOCAL_MACHINE)
|
||||||
|
key = win32api.RegOpenKeyEx(root, PROFILE_LIST_KEY + "\\" + sid_str,
|
||||||
|
0, win32con.KEY_READ)
|
||||||
|
path, _ = win32api.RegQueryValueEx(key, "ProfileImagePath")
|
||||||
|
win32api.RegCloseKey(key)
|
||||||
|
path = win32api.ExpandEnvironmentStrings(path)
|
||||||
|
if path:
|
||||||
|
return _to_unc(machine, path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Фоллбэк: перебираем типовые имена папок профиля
|
||||||
|
candidates = [user]
|
||||||
|
if domain:
|
||||||
|
candidates.append(f"{user}.{domain}")
|
||||||
|
candidates.append(f"{user}.000")
|
||||||
|
for name in candidates:
|
||||||
|
unc = f"\\\\{machine}\\C$\\Users\\{name}"
|
||||||
|
try:
|
||||||
|
if os.path.isdir(unc):
|
||||||
|
return unc
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"не удалось определить папку профиля "
|
||||||
|
"(нет доступа к удалённому реестру и папка не найдена перебором)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ibases_path_for_session(session):
|
||||||
|
"""Путь к ibases.v8i внутри профиля пользователя из сеанса."""
|
||||||
|
profile = resolve_profile_dir(session["machine"], session["domain"], session["user"])
|
||||||
|
return os.path.join(profile, "AppData", "Roaming", "1C", "1CEStart", "ibases.v8i")
|
||||||
|
|
||||||
|
|
||||||
|
def short_name(machine):
|
||||||
|
"""SERVERTS.CORP.local -> SERVERTS (для компактного отображения в списке)."""
|
||||||
|
return machine.split(".")[0] if machine else machine
|
||||||
Reference in New Issue
Block a user