From 72ce3541f5bc2c9e1c3853997d9e04ff373184a5 Mon Sep 17 00:00:00 2001 From: "Satur@it-depot.ru" Date: Wed, 29 Jul 2026 17:14:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B8=D1=81=D0=BA=20=D1=81=D0=B5?= =?UTF-8?q?=D0=B0=D0=BD=D1=81=D0=BE=D0=B2=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=D1=83=20=D0=B8=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=B7=20=D0=BF=D1=80=D1=8F=D0=BC=D0=BE=20=D0=B2=20=D0=BD=D0=B8?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При перемещаемых профилях базу нужно писать в профиль на той машине, где у пользователя открыт сеанс, иначе правку затрёт при выходе. Раньше, чтобы узнать, где человек сидит, приходилось отдельно лезть в 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 --- 1C_Base_Adder.spec | 9 +- Main_v3.py | 323 ++++++++++++++++++++++++++- ad_sessions.py | 531 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 852 insertions(+), 11 deletions(-) create mode 100644 ad_sessions.py diff --git a/1C_Base_Adder.spec b/1C_Base_Adder.spec index e74a4ef..aa84b91 100644 --- a/1C_Base_Adder.spec +++ b/1C_Base_Adder.spec @@ -6,7 +6,14 @@ a = Analysis( pathex=[], binaries=[], datas=[], - hiddenimports=[], + # ad_sessions импортируется внутри try/except, а pywin32 подтягивает часть + # модулей динамически — перечисляем явно, иначе их не будет в сборке + hiddenimports=[ + 'ad_sessions', + 'win32timezone', + 'win32com.client', + 'win32security', + ], hookspath=[], hooksconfig={}, runtime_hooks=[], diff --git a/Main_v3.py b/Main_v3.py index 437fcf4..7294aed 100644 --- a/Main_v3.py +++ b/Main_v3.py @@ -1,11 +1,22 @@ import os import re +import threading +import time import tkinter as tk from tkinter import messagebox, filedialog import pyodbc import uuid import sys +# Поиск сеансов по домену — необязательная часть: если pywin32 недоступен, +# программа должна продолжать работать в режиме «по профилям». +try: + import ad_sessions + SESSIONS_ERROR = "" +except Exception as _exc: + ad_sessions = None + SESSIONS_ERROR = str(_exc) + _BANNER = (""" @@ -73,8 +84,8 @@ class RemoteFolderSelector(tk.Tk): def __init__(self): super().__init__() self.title("Добавление баз 1С пользователям") - self.geometry("760x780") - self.minsize(700, 640) + self.geometry("1180x820") + self.minsize(1040, 700) # --- Состояние --- self.ip_address = None # None/"" => локальный ПК, иначе IP удалённого ПК @@ -82,27 +93,49 @@ class RemoteFolderSelector(tk.Tk): self.user_vars = {} # имя пользователя -> BooleanVar (галочка) self.db_vars = {} # имя базы из SQL -> 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(1, weight=1) + self.grid_columnconfigure(2, weight=1) self.grid_rowconfigure(0, weight=1) self.frame_left = tk.Frame(self) 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.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_sessions_panel() self._build_right_panel() - # Большая кнопка добавления снизу, на всю ширину окна - self.add_button = tk.Button( - self, text="Добавить отмеченные базы отмеченным пользователям", - font=("Segoe UI", 11, "bold"), command=self.add_database_to_ibases + # Кнопки действий снизу, на всю ширину окна + actions = tk.Frame(self) + actions.grid(row=1, column=0, columnspan=3, padx=10, pady=(0, 12), sticky="ew") + 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() @@ -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") self.ip_entry = tk.Entry(f) self.ip_entry.grid(row=1, column=0, padx=5, pady=(0, 5), sticky="ew") + self.ip_entry.bind("", lambda e: self.load_users()) tk.Button(f, text="Загрузить пользователей", command=self.load_users)\ .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.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("", lambda e: self.load_databases()) + tk.Button(f, text="Загрузить базы", command=self.load_databases)\ .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") 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("", 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): """Прокручиваемая область для чекбоксов. Возвращает (container, inner).""" 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()) def on_user_click(self, user): - """Клик по пользователю — делаем его активным и показываем его подключённые базы.""" + """Клик по пользователю — делаем его активным и показываем его подключённые базы. + Заодно подставляем имя в поле поиска сеансов, чтобы не набирать вручную.""" self.load_connected_bases(user) + if ad_sessions is not None: + self.session_user_var.set(user) def load_connected_bases(self, user): """Читаем ibases.v8i пользователя и показываем список его баз в панели.""" @@ -571,6 +670,210 @@ class RemoteFolderSelector(tk.Tk): summary += f"\nУдалено в ZIP-профиле: {removed_zip}" 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): if errors: messagebox.showwarning("Готово с ошибками", summary + "\n\nОшибки:\n" + "\n".join(errors)) diff --git a/ad_sessions.py b/ad_sessions.py new file mode 100644 index 0000000..84acc6c --- /dev/null +++ b/ad_sessions.py @@ -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";" + # (!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