Поиск сеансов пользователя по домену и добавление баз прямо в них

При перемещаемых профилях базу нужно писать в профиль на той машине, где у
пользователя открыт сеанс, иначе правку затрёт при выходе. Раньше, чтобы
узнать, где человек сидит, приходилось отдельно лезть в 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:
2026-07-29 17:14:32 +03:00
co-authored by Claude
parent 9331eff420
commit 72ce3541f5
3 changed files with 852 additions and 11 deletions
+8 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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