Compare commits
3
Commits
72ce3541f5
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd4eec6e1 | ||
|
|
5e3c94f348 | ||
|
|
474f8d38ce |
@@ -1,3 +0,0 @@
|
||||
__pycache__/
|
||||
build/
|
||||
dist/
|
||||
Submodule
+1
Submodule 1C_Bases added at 0aa3007c5e
+199
-766
File diff suppressed because it is too large
Load Diff
-531
@@ -1,531 +0,0 @@
|
||||
# -*- 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
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,289 @@
|
||||
('C:\\Project\\build\\Main_v3\\PYZ-00.pyz',
|
||||
[('_compat_pickle',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\_compat_pickle.py',
|
||||
'PYMODULE'),
|
||||
('_compression',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\_compression.py',
|
||||
'PYMODULE'),
|
||||
('_py_abc',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\_py_abc.py',
|
||||
'PYMODULE'),
|
||||
('_pydecimal',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\_pydecimal.py',
|
||||
'PYMODULE'),
|
||||
('_strptime',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\_strptime.py',
|
||||
'PYMODULE'),
|
||||
('_threading_local',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\_threading_local.py',
|
||||
'PYMODULE'),
|
||||
('argparse',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\argparse.py',
|
||||
'PYMODULE'),
|
||||
('ast',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\ast.py',
|
||||
'PYMODULE'),
|
||||
('base64',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\base64.py',
|
||||
'PYMODULE'),
|
||||
('bisect',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\bisect.py',
|
||||
'PYMODULE'),
|
||||
('bz2',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\bz2.py',
|
||||
'PYMODULE'),
|
||||
('calendar',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\calendar.py',
|
||||
'PYMODULE'),
|
||||
('contextlib',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\contextlib.py',
|
||||
'PYMODULE'),
|
||||
('contextvars',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\contextvars.py',
|
||||
'PYMODULE'),
|
||||
('copy',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\copy.py',
|
||||
'PYMODULE'),
|
||||
('csv',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\csv.py',
|
||||
'PYMODULE'),
|
||||
('dataclasses',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\dataclasses.py',
|
||||
'PYMODULE'),
|
||||
('datetime',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\datetime.py',
|
||||
'PYMODULE'),
|
||||
('decimal',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\decimal.py',
|
||||
'PYMODULE'),
|
||||
('dis',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\dis.py',
|
||||
'PYMODULE'),
|
||||
('email',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\__init__.py',
|
||||
'PYMODULE'),
|
||||
('email._encoded_words',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\_encoded_words.py',
|
||||
'PYMODULE'),
|
||||
('email._header_value_parser',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\_header_value_parser.py',
|
||||
'PYMODULE'),
|
||||
('email._parseaddr',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\_parseaddr.py',
|
||||
'PYMODULE'),
|
||||
('email._policybase',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\_policybase.py',
|
||||
'PYMODULE'),
|
||||
('email.base64mime',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\base64mime.py',
|
||||
'PYMODULE'),
|
||||
('email.charset',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\charset.py',
|
||||
'PYMODULE'),
|
||||
('email.contentmanager',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\contentmanager.py',
|
||||
'PYMODULE'),
|
||||
('email.encoders',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\encoders.py',
|
||||
'PYMODULE'),
|
||||
('email.errors',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\errors.py',
|
||||
'PYMODULE'),
|
||||
('email.feedparser',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\feedparser.py',
|
||||
'PYMODULE'),
|
||||
('email.generator',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\generator.py',
|
||||
'PYMODULE'),
|
||||
('email.header',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\header.py',
|
||||
'PYMODULE'),
|
||||
('email.headerregistry',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\headerregistry.py',
|
||||
'PYMODULE'),
|
||||
('email.iterators',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\iterators.py',
|
||||
'PYMODULE'),
|
||||
('email.message',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\message.py',
|
||||
'PYMODULE'),
|
||||
('email.parser',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\parser.py',
|
||||
'PYMODULE'),
|
||||
('email.policy',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\policy.py',
|
||||
'PYMODULE'),
|
||||
('email.quoprimime',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\quoprimime.py',
|
||||
'PYMODULE'),
|
||||
('email.utils',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\email\\utils.py',
|
||||
'PYMODULE'),
|
||||
('fnmatch',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\fnmatch.py',
|
||||
'PYMODULE'),
|
||||
('fractions',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\fractions.py',
|
||||
'PYMODULE'),
|
||||
('getopt',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\getopt.py',
|
||||
'PYMODULE'),
|
||||
('gettext',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\gettext.py',
|
||||
'PYMODULE'),
|
||||
('gzip',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\gzip.py',
|
||||
'PYMODULE'),
|
||||
('hashlib',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\hashlib.py',
|
||||
'PYMODULE'),
|
||||
('importlib',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\__init__.py',
|
||||
'PYMODULE'),
|
||||
('importlib._abc',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\_abc.py',
|
||||
'PYMODULE'),
|
||||
('importlib._bootstrap',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\_bootstrap.py',
|
||||
'PYMODULE'),
|
||||
('importlib._bootstrap_external',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\_bootstrap_external.py',
|
||||
'PYMODULE'),
|
||||
('importlib.abc',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\abc.py',
|
||||
'PYMODULE'),
|
||||
('importlib.machinery',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\machinery.py',
|
||||
'PYMODULE'),
|
||||
('importlib.metadata',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\metadata\\__init__.py',
|
||||
'PYMODULE'),
|
||||
('importlib.metadata._adapters',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\metadata\\_adapters.py',
|
||||
'PYMODULE'),
|
||||
('importlib.metadata._collections',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\metadata\\_collections.py',
|
||||
'PYMODULE'),
|
||||
('importlib.metadata._functools',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\metadata\\_functools.py',
|
||||
'PYMODULE'),
|
||||
('importlib.metadata._itertools',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\metadata\\_itertools.py',
|
||||
'PYMODULE'),
|
||||
('importlib.metadata._meta',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\metadata\\_meta.py',
|
||||
'PYMODULE'),
|
||||
('importlib.metadata._text',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\metadata\\_text.py',
|
||||
'PYMODULE'),
|
||||
('importlib.readers',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\readers.py',
|
||||
'PYMODULE'),
|
||||
('importlib.util',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\importlib\\util.py',
|
||||
'PYMODULE'),
|
||||
('inspect',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\inspect.py',
|
||||
'PYMODULE'),
|
||||
('logging',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\logging\\__init__.py',
|
||||
'PYMODULE'),
|
||||
('lzma',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\lzma.py',
|
||||
'PYMODULE'),
|
||||
('numbers',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\numbers.py',
|
||||
'PYMODULE'),
|
||||
('opcode',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\opcode.py',
|
||||
'PYMODULE'),
|
||||
('optparse',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\optparse.py',
|
||||
'PYMODULE'),
|
||||
('pathlib',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\pathlib.py',
|
||||
'PYMODULE'),
|
||||
('pickle',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\pickle.py',
|
||||
'PYMODULE'),
|
||||
('pprint',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\pprint.py',
|
||||
'PYMODULE'),
|
||||
('py_compile',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\py_compile.py',
|
||||
'PYMODULE'),
|
||||
('quopri',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\quopri.py',
|
||||
'PYMODULE'),
|
||||
('random',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\random.py',
|
||||
'PYMODULE'),
|
||||
('selectors',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\selectors.py',
|
||||
'PYMODULE'),
|
||||
('shutil',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\shutil.py',
|
||||
'PYMODULE'),
|
||||
('signal',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\signal.py',
|
||||
'PYMODULE'),
|
||||
('socket',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\socket.py',
|
||||
'PYMODULE'),
|
||||
('statistics',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\statistics.py',
|
||||
'PYMODULE'),
|
||||
('string',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\string.py',
|
||||
'PYMODULE'),
|
||||
('stringprep',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\stringprep.py',
|
||||
'PYMODULE'),
|
||||
('subprocess',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\subprocess.py',
|
||||
'PYMODULE'),
|
||||
('tarfile',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\tarfile.py',
|
||||
'PYMODULE'),
|
||||
('textwrap',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\textwrap.py',
|
||||
'PYMODULE'),
|
||||
('threading',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\threading.py',
|
||||
'PYMODULE'),
|
||||
('tkinter',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\tkinter\\__init__.py',
|
||||
'PYMODULE'),
|
||||
('tkinter.commondialog',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\tkinter\\commondialog.py',
|
||||
'PYMODULE'),
|
||||
('tkinter.constants',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\tkinter\\constants.py',
|
||||
'PYMODULE'),
|
||||
('tkinter.messagebox',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\tkinter\\messagebox.py',
|
||||
'PYMODULE'),
|
||||
('token',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\token.py',
|
||||
'PYMODULE'),
|
||||
('tokenize',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\tokenize.py',
|
||||
'PYMODULE'),
|
||||
('tracemalloc',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\tracemalloc.py',
|
||||
'PYMODULE'),
|
||||
('typing',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\typing.py',
|
||||
'PYMODULE'),
|
||||
('urllib',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\urllib\\__init__.py',
|
||||
'PYMODULE'),
|
||||
('urllib.parse',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\urllib\\parse.py',
|
||||
'PYMODULE'),
|
||||
('uu',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\uu.py',
|
||||
'PYMODULE'),
|
||||
('zipfile',
|
||||
'C:\\Users\\Saturn\\AppData\\Local\\Programs\\Python\\Python310\\lib\\zipfile.py',
|
||||
'PYMODULE')])
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
|
||||
This file lists modules PyInstaller was not able to find. This does not
|
||||
necessarily mean this module is required for running your program. Python and
|
||||
Python 3rd-party packages include a lot of conditional or optional modules. For
|
||||
example the module 'ntpath' only exists on Windows, whereas the module
|
||||
'posixpath' only exists on Posix systems.
|
||||
|
||||
Types if import:
|
||||
* top-level: imported at the top-level - look at these first
|
||||
* conditional: imported within an if-statement
|
||||
* delayed: imported within a function
|
||||
* optional: imported within a try-except-statement
|
||||
|
||||
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
|
||||
tracking down the missing module yourself. Thanks!
|
||||
|
||||
missing module named pep517 - imported by importlib.metadata (delayed)
|
||||
missing module named 'org.python' - imported by copy (optional)
|
||||
missing module named org - imported by pickle (optional)
|
||||
missing module named pwd - imported by posixpath (delayed, conditional), subprocess (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional)
|
||||
missing module named grp - imported by subprocess (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional)
|
||||
missing module named posix - imported by os (conditional, optional), shutil (conditional), importlib._bootstrap_external (conditional)
|
||||
missing module named resource - imported by posix (top-level)
|
||||
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional)
|
||||
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional)
|
||||
missing module named _posixsubprocess - imported by subprocess (optional)
|
||||
missing module named fcntl - imported by subprocess (optional)
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
BIN
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 264 KiB |
@@ -6,14 +6,7 @@ a = Analysis(
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
# ad_sessions импортируется внутри try/except, а pywin32 подтягивает часть
|
||||
# модулей динамически — перечисляем явно, иначе их не будет в сборке
|
||||
hiddenimports=[
|
||||
'ad_sessions',
|
||||
'win32timezone',
|
||||
'win32com.client',
|
||||
'win32security',
|
||||
],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
@@ -29,18 +22,17 @@ exe = EXE(
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='1C_Base_Adder',
|
||||
name='Main_v3',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=['download.ico'],
|
||||
)
|
||||
@@ -1,482 +0,0 @@
|
||||
<#
|
||||
Пробник для 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 "Готово."
|
||||
Reference in New Issue
Block a user