Compare commits

..
2 Commits
Author SHA1 Message Date
saturnandClaude 72ce3541f5 Поиск сеансов пользователя по домену и добавление баз прямо в них
При перемещаемых профилях базу нужно писать в профиль на той машине, где у
пользователя открыт сеанс, иначе правку затрёт при выходе. Раньше, чтобы
узнать, где человек сидит, приходилось отдельно лезть в 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>
2026-07-29 17:14:32 +03:00
saturnandClaude 9331eff420 Диагностический пробник обнаружения сеансов в домене
PowerShell-скрипт для проверки, как поиск сеансов поведёт себя в незнакомом
домене, до того как разворачивать там программу: список компьютеров из AD,
отсев выключенных машин по порту 445, перечисление сеансов через WTS API и
разрешение папки профиля через SID + удалённый реестр ProfileList.

Сеансы читаются через P/Invoke к wtsapi32, а не разбором вывода quser —
тот зависит от локали Windows и ломается на русской версии.

Python на целевой машине не нужен, скрипт только читает данные и ничего
не меняет. Показывает тайминги по шагам и группирует причины отказов —
по ним видно, где закрыт RPC и где отключена служба RemoteRegistry.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 17:14:32 +03:00
4 changed files with 1334 additions and 11 deletions
+8 -1
View File
@@ -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=[],
+313 -10
View File
@@ -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("<Return>", 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("<Return>", 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("<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):
"""Прокручиваемая область для чекбоксов. Возвращает (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))
+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
+482
View File
@@ -0,0 +1,482 @@
<#
Пробник для v2.0 "Добавление баз 1С" — проверка обнаружения сеансов по домену.
Что проверяет:
1. Окружение (домен, права, версия ОС/PS).
2. Получение списка компьютеров из AD (через [adsisearcher], RSAT не нужен).
3. Быстрый отсев мёртвых машин по порту 445 (асинхронно, как это делает Dameware).
4. Перечисление сеансов через WTS API (P/Invoke, без парсинга quser — не зависит от локали).
5. Разрешение реальной папки профиля пользователя (SID -> удалённый реестр ProfileList).
Запуск (на терминальном сервере, под доменным админом):
powershell -ExecutionPolicy Bypass -File .\probe_sessions.ps1 -User Saturn | Tee-Object probe_report.txt
Быстрый прогон только по двум машинам, без опроса всего домена:
powershell -ExecutionPolicy Bypass -File .\probe_sessions.ps1 -User Saturn -Computers "SERVERTERM,SERVERSQL"
Скрипт только читает данные: ничего не пишет на другие машины и не меняет настройки.
#>
[CmdletBinding()]
param(
# Имя пользователя, чьи сеансы ищем (без домена).
[string]$User = "Saturn",
# Явный список машин через запятую. Если задан — AD не опрашивается.
[string]$Computers = "",
# Ограничить число машин из AD (0 = без ограничения). Полезно для первого прогона.
[int]$Limit = 0,
# Таймаут проверки порта 445, мс. Меньше = быстрее, но можно потерять медленные машины.
[int]$PortTimeoutMs = 400,
# Число параллельных потоков опроса WTS.
[int]$Threads = 64,
# Общий таймаут этапа опроса сеансов, секунд.
[int]$WtsTimeoutSec = 90
)
$ErrorActionPreference = "Stop"
$swTotal = [Diagnostics.Stopwatch]::StartNew()
function Section($title) {
Write-Output ""
Write-Output ("=" * 78)
Write-Output " $title"
Write-Output ("=" * 78)
}
function TableOut($objects, $props) {
if (-not $objects -or $objects.Count -eq 0) { Write-Output " (пусто)"; return }
$t = $objects | Format-Table -Property $props -AutoSize | Out-String
Write-Output $t.TrimEnd()
}
# ---------------------------------------------------------------- ШАГ 0: окружение
Section "ШАГ 0. Окружение"
$os = Get-CimInstance Win32_OperatingSystem
$cs = Get-CimInstance Win32_ComputerSystem
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($id)
$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
Write-Output " Компьютер : $($env:COMPUTERNAME)"
Write-Output " ОС : $($os.Caption) ($($os.Version))"
Write-Output " PowerShell : $($PSVersionTable.PSVersion)"
Write-Output " Домен машины : $($cs.Domain) (в домене: $($cs.PartOfDomain))"
Write-Output " Запущен от : $($id.Name)"
Write-Output " Права локал. админа: $isAdmin"
Write-Output " Ищем сеансы юзера : '$User'"
if (-not $cs.PartOfDomain) {
Write-Output ""
Write-Output " !! Машина не в домене — поиск по AD работать не будет."
}
if (-not $isAdmin) {
Write-Output ""
Write-Output " !! Нет прав локального администратора. Опрос WTS на удалённых машинах"
Write-Output " почти наверняка вернёт 'Отказано в доступе'. Запусти из-под доменного админа."
}
# ------------------------------------------------- Компиляция WTS-обёртки (P/Invoke)
$cs_code = @'
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
public class WtsProbe
{
[DllImport("wtsapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr WTSOpenServerW(string pServerName);
[DllImport("wtsapi32.dll")]
private static extern void WTSCloseServer(IntPtr hServer);
[DllImport("wtsapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern int WTSEnumerateSessionsW(IntPtr hServer, int Reserved, int Version,
ref IntPtr ppSessionInfo, ref int pCount);
[DllImport("wtsapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern int WTSQuerySessionInformationW(IntPtr hServer, int sessionId, int wtsInfoClass,
out IntPtr ppBuffer, out int pBytesReturned);
[DllImport("wtsapi32.dll")]
private static extern void WTSFreeMemory(IntPtr pMemory);
[StructLayout(LayoutKind.Sequential)]
private struct WTS_SESSION_INFO
{
public int SessionId;
[MarshalAs(UnmanagedType.LPWStr)] public string pWinStationName;
public int State;
}
private const int WTSUserName = 5;
private const int WTSDomainName = 7;
private const int WTSClientName = 10;
private static string Query(IntPtr h, int sid, int infoClass)
{
IntPtr buf = IntPtr.Zero;
int bytes = 0;
if (WTSQuerySessionInformationW(h, sid, infoClass, out buf, out bytes) == 0) return "";
try { string s = Marshal.PtrToStringUni(buf); return s == null ? "" : s; }
finally { if (buf != IntPtr.Zero) WTSFreeMemory(buf); }
}
// Возвращает строки "SessionId|Station|State|User|Domain|Client".
// server = null/"" -> локальная машина.
public static string[] Enumerate(string server)
{
bool remote = !string.IsNullOrEmpty(server);
IntPtr h = IntPtr.Zero; // WTS_CURRENT_SERVER_HANDLE
if (remote)
{
h = WTSOpenServerW(server);
if (h == IntPtr.Zero)
throw new Exception("WTSOpenServer: win32 error " + Marshal.GetLastWin32Error());
}
IntPtr pInfo = IntPtr.Zero;
int count = 0;
List<string> res = new List<string>();
try
{
if (WTSEnumerateSessionsW(h, 0, 1, ref pInfo, ref count) == 0)
throw new Exception("WTSEnumerateSessions: win32 error " + Marshal.GetLastWin32Error());
int sz = Marshal.SizeOf(typeof(WTS_SESSION_INFO));
for (int i = 0; i < count; i++)
{
IntPtr cur = new IntPtr(pInfo.ToInt64() + (long)i * sz);
WTS_SESSION_INFO si = (WTS_SESSION_INFO)Marshal.PtrToStructure(cur, typeof(WTS_SESSION_INFO));
string user = Query(h, si.SessionId, WTSUserName);
if (string.IsNullOrEmpty(user)) continue; // служебные сеансы пропускаем
res.Add(si.SessionId + "|" + si.pWinStationName + "|" + si.State + "|" +
user + "|" + Query(h, si.SessionId, WTSDomainName) + "|" +
Query(h, si.SessionId, WTSClientName));
}
}
finally
{
if (pInfo != IntPtr.Zero) WTSFreeMemory(pInfo);
if (remote && h != IntPtr.Zero) WTSCloseServer(h);
}
return res.ToArray();
}
}
'@
# Компилируем в DLL: так её можно быстро загрузить в каждый параллельный runspace
# (повторный Add-Type в каждом потоке стоил бы 1-2 секунды на компиляцию).
$dllPath = Join-Path $env:TEMP ("WtsProbe_{0}.dll" -f $PID)
try {
Add-Type -TypeDefinition $cs_code -OutputAssembly $dllPath -OutputType Library
Add-Type -Path $dllPath
} catch {
Write-Output ""
Write-Output " !! Не удалось скомпилировать WTS-обёртку: $($_.Exception.Message)"
Write-Output " Проверь, что доступен .NET-компилятор (csc) и %TEMP% доступен на запись."
exit 1
}
$STATES = @{
0 = "Active"; 1 = "Connected"; 2 = "ConnectQuery"; 3 = "Shadow"; 4 = "Disconnected";
5 = "Idle"; 6 = "Listen"; 7 = "Reset"; 8 = "Down"; 9 = "Init"
}
# --------------------------------------------------------- ШАГ 1: список компьютеров
Section "ШАГ 1. Список компьютеров"
$machines = @()
$adInfo = @{}
if ($Computers.Trim()) {
$machines = $Computers.Split(",") | ForEach-Object { $_.Trim() } | Where-Object { $_ }
Write-Output " Источник: задан вручную (-Computers)"
Write-Output " Машин: $($machines.Count)"
} else {
$sw = [Diagnostics.Stopwatch]::StartNew()
try {
# (!userAccountControl:...:=2) — отсекаем отключённые учётки компьютеров
$searcher = [adsisearcher]"(&(objectCategory=computer)(!userAccountControl:1.2.840.113556.1.4.803:=2))"
$searcher.PageSize = 1000
[void]$searcher.PropertiesToLoad.AddRange(@('name', 'dnshostname', 'operatingsystem', 'lastlogontimestamp'))
$found = $searcher.FindAll()
foreach ($r in $found) {
$p = $r.Properties
$nm = if ($p.dnshostname) { [string]$p.dnshostname[0] } else { [string]$p.name[0] }
if (-not $nm) { continue }
$llt = $null
if ($p.lastlogontimestamp) {
try { $llt = [DateTime]::FromFileTimeUtc([int64]$p.lastlogontimestamp[0]).ToLocalTime() } catch {}
}
$machines += $nm
$adInfo[$nm] = [pscustomobject]@{
OS = if ($p.operatingsystem) { [string]$p.operatingsystem[0] } else { "" }
LastLogon = $llt
}
}
$found.Dispose()
$sw.Stop()
Write-Output " Источник: Active Directory (LDAP)"
Write-Output " Найдено компьютеров: $($machines.Count) (за $([math]::Round($sw.Elapsed.TotalSeconds,2)) c)"
$stale = @($adInfo.Values | Where-Object { $_.LastLogon -and $_.LastLogon -lt (Get-Date).AddDays(-30) }).Count
Write-Output " Из них не логинились >30 дней: $stale (кандидаты на отсев)"
} catch {
Write-Output " !! Ошибка запроса к AD: $($_.Exception.Message)"
exit 1
}
if ($Limit -gt 0 -and $machines.Count -gt $Limit) {
$machines = $machines[0..($Limit - 1)]
Write-Output " Ограничено параметром -Limit: $($machines.Count)"
}
}
if ($machines.Count -eq 0) { Write-Output " Нет машин для опроса."; exit 1 }
# ------------------------------------------------- ШАГ 2: отсев мёртвых машин (порт 445)
Section "ШАГ 2. Проверка живости (TCP 445, асинхронно)"
$sw = [Diagnostics.Stopwatch]::StartNew()
$pending = @()
foreach ($m in $machines) {
$client = New-Object Net.Sockets.TcpClient
$ar = $null
try { $ar = $client.BeginConnect($m, 445, $null, $null) } catch { }
$pending += [pscustomobject]@{ Machine = $m; Client = $client; Async = $ar }
}
Start-Sleep -Milliseconds $PortTimeoutMs
$alive = @()
foreach ($p in $pending) {
$ok = $false
try { if ($p.Client.Connected) { $ok = $true } } catch { }
if ($ok) { $alive += $p.Machine }
try { $p.Client.Close() } catch { }
}
$sw.Stop()
Write-Output " Живых машин: $($alive.Count) из $($machines.Count) (за $([math]::Round($sw.Elapsed.TotalSeconds,2)) c)"
Write-Output " Недоступны : $($machines.Count - $alive.Count)"
if ($alive.Count -eq 0) {
Write-Output " !! Ни одна машина не ответила. Проверь таймаут (-PortTimeoutMs) и сетевую доступность."
exit 1
}
# ------------------------------------------------------ ШАГ 3: опрос сеансов через WTS
Section "ШАГ 3. Опрос сеансов (WTS API, параллельно)"
$sw = [Diagnostics.Stopwatch]::StartNew()
$pool = [runspacefactory]::CreateRunspacePool(1, [Math]::Max(1, $Threads))
$pool.Open()
$worker = {
param($dll, $machine)
try {
[void][Reflection.Assembly]::LoadFrom($dll)
$rows = [WtsProbe]::Enumerate($machine)
[pscustomobject]@{ Machine = $machine; Ok = $true; Rows = $rows; Err = $null }
} catch {
[pscustomobject]@{ Machine = $machine; Ok = $false; Rows = @(); Err = $_.Exception.Message }
}
}
$jobs = @()
foreach ($m in $alive) {
$ps = [powershell]::Create()
$ps.RunspacePool = $pool
[void]$ps.AddScript($worker).AddArgument($dllPath).AddArgument($m)
$jobs += [pscustomobject]@{ Machine = $m; PS = $ps; Handle = $ps.BeginInvoke() }
}
$deadline = (Get-Date).AddSeconds($WtsTimeoutSec)
while (@($jobs | Where-Object { -not $_.Handle.IsCompleted }).Count -gt 0 -and (Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 100
}
$sessions = @()
$failures = @()
$timedOut = @()
foreach ($j in $jobs) {
if (-not $j.Handle.IsCompleted) {
$timedOut += $j.Machine
try { $j.PS.Stop() } catch { }
try { $j.PS.Dispose() } catch { }
continue
}
try {
$res = $j.PS.EndInvoke($j.Handle)
foreach ($r in $res) {
if (-not $r.Ok) { $failures += [pscustomobject]@{ Machine = $r.Machine; Err = $r.Err }; continue }
foreach ($row in $r.Rows) {
$f = $row -split "\|"
$sessions += [pscustomobject]@{
Machine = $r.Machine
SessionId = [int]$f[0]
Station = $f[1]
State = $STATES[[int]$f[2]]
User = $f[3]
Domain = $f[4]
Client = $f[5]
}
}
}
} catch {
$failures += [pscustomobject]@{ Machine = $j.Machine; Err = $_.Exception.Message }
}
try { $j.PS.Dispose() } catch { }
}
$pool.Close()
$pool.Dispose()
$sw.Stop()
$wtsSec = [math]::Round($sw.Elapsed.TotalSeconds, 2)
Write-Output " Опрошено машин : $($alive.Count) (за $wtsSec c, потоков: $Threads)"
Write-Output " Успешно : $($alive.Count - $failures.Count - $timedOut.Count)"
Write-Output " С ошибкой : $($failures.Count)"
Write-Output " Не уложились в таймаут: $($timedOut.Count)"
Write-Output " Всего сеансов с пользователями: $($sessions.Count)"
if ($failures.Count -gt 0) {
Write-Output ""
Write-Output " Ошибки, сгруппированные по причине:"
$failures | Group-Object Err | Sort-Object Count -Descending | ForEach-Object {
$sample = ($_.Group | Select-Object -First 3 | ForEach-Object { $_.Machine }) -join ", "
Write-Output (" {0,4} шт. {1}" -f $_.Count, $_.Name)
Write-Output (" напр.: {0}" -f $sample)
}
}
if ($timedOut.Count -gt 0) {
Write-Output ""
Write-Output (" Зависли: " + (($timedOut | Select-Object -First 10) -join ", "))
}
# ------------------------------------------------------------ ШАГ 4: результат по юзеру
Section "ШАГ 4. Сеансы пользователя '$User'"
$mine = @($sessions | Where-Object { $_.User -like $User })
if ($mine.Count -eq 0) {
Write-Output " Сеансов не найдено."
Write-Output " (Проверь имя. Ниже — сводка всех найденных сеансов.)"
} else {
TableOut $mine @("Machine", "SessionId", "Station", "State", "Domain", "User", "Client")
}
Section "Все найденные сеансы (топ-40)"
TableOut @($sessions | Sort-Object Machine, SessionId | Select-Object -First 40) `
@("Machine", "SessionId", "Station", "State", "Domain", "User", "Client")
# ------------------------------------------- ШАГ 5: разрешение папки профиля
Section "ШАГ 5. Разрешение папки профиля (SID -> реестр ProfileList)"
if ($mine.Count -eq 0) {
Write-Output " Пропущено: не найдено сеансов искомого пользователя."
} else {
foreach ($s in ($mine | Sort-Object Machine -Unique)) {
Write-Output ""
Write-Output " --- $($s.Machine) ---"
# 1) имя -> SID
$sid = $null
try {
$acct = New-Object Security.Principal.NTAccount($s.Domain, $s.User)
$sid = $acct.Translate([Security.Principal.SecurityIdentifier]).Value
Write-Output " SID : $sid"
} catch {
Write-Output " SID : ОШИБКА — $($_.Exception.Message)"
}
# 2) SID -> ProfileImagePath (удалённый реестр; нужна служба RemoteRegistry)
$profilePath = $null
if ($sid) {
try {
$rk = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey('LocalMachine', $s.Machine)
$sub = $rk.OpenSubKey("SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$sid")
if ($sub) {
$profilePath = $sub.GetValue("ProfileImagePath")
Write-Output " ProfileImagePath : $profilePath"
$sub.Close()
} else {
Write-Output " ProfileImagePath : ключ для SID не найден (профиль ещё не создан?)"
}
$rk.Close()
} catch {
Write-Output " ProfileImagePath : ОШИБКА — $($_.Exception.Message)"
Write-Output " (вероятно, служба RemoteRegistry остановлена)"
}
}
# 3) Фоллбэк: угадываем папку перебором и проверяем доступ по административной шаре
if (-not $profilePath) {
Write-Output " Фоллбэк (перебор папок):"
$shortDom = $s.Domain
foreach ($cand in @($s.User, "$($s.User).$shortDom", "$($s.User).000")) {
$p = "\\$($s.Machine)\C`$\Users\$cand"
$exists = $false
try { $exists = Test-Path -LiteralPath $p } catch { }
Write-Output (" {0,-45} {1}" -f $p, $(if ($exists) { "НАЙДЕНА" } else { "нет" }))
if ($exists -and -not $profilePath) { $profilePath = "C:\Users\$cand" }
}
}
# 4) Проверяем доступность самого ibases.v8i по админской шаре
if ($profilePath) {
$unc = "\\$($s.Machine)\" + ($profilePath -replace '^([A-Za-z]):', '$1$')
$v8i = Join-Path $unc "AppData\Roaming\1C\1CEStart\ibases.v8i"
Write-Output " UNC до профиля : $unc"
try {
if (Test-Path -LiteralPath $v8i) {
$fi = Get-Item -LiteralPath $v8i
$cnt = @(Select-String -LiteralPath $v8i -Pattern '^\[' -ErrorAction SilentlyContinue).Count
Write-Output " ibases.v8i : ЕСТЬ, $($fi.Length) байт, баз в файле: $cnt"
Write-Output " Запись в профиль : доступна (файл читается)"
} else {
$dir = Split-Path $v8i -Parent
$dirOk = Test-Path -LiteralPath $dir
Write-Output " ibases.v8i : файла нет (каталог 1CEStart существует: $dirOk)"
}
} catch {
Write-Output " ibases.v8i : ОШИБКА доступа — $($_.Exception.Message)"
}
}
}
}
# ------------------------------------------------------------------ Итоги
Section "ИТОГИ"
$swTotal.Stop()
Write-Output " Машин из источника : $($machines.Count)"
Write-Output " Живых (порт 445) : $($alive.Count)"
Write-Output " Сеансов найдено : $($sessions.Count)"
Write-Output " Сеансов юзера '$User' : $($mine.Count)"
Write-Output " Время опроса сеансов : $wtsSec c"
Write-Output " Общее время работы : $([math]::Round($swTotal.Elapsed.TotalSeconds,2)) c"
Write-Output ""
Write-Output " Уникальных машин с сеансами:"
$byMachine = $sessions | Group-Object Machine | Sort-Object Count -Descending | Select-Object -First 10
foreach ($g in $byMachine) { Write-Output (" {0,-30} сеансов: {1}" -f $g.Name, $g.Count) }
try { Remove-Item -LiteralPath $dllPath -Force -ErrorAction SilentlyContinue } catch { }
Write-Output ""
Write-Output "Готово."