fix: parse Disc qwinsta rows so RDG flap auto-logoff works (0.5.14)

Empty SESSIONNAME broke session matching; auto-disconnect failed with 'No matching Windows session'.
This commit is contained in:
2026-07-14 10:30:13 +10:00
parent d20517804d
commit d95b93992d
7 changed files with 86 additions and 22 deletions
+40 -14
View File
@@ -409,28 +409,54 @@ def filter_windows_sessions_for_user(
return [s for s in sessions if windows_user_matches_session(user, s.user)]
def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]:
rows: list[HostSessionRow] = []
norm_filter = (filter_user or "").strip().lower()
def _qwinsta_sam_account(value: str) -> str:
text = (value or "").strip()
if "\\" in text:
text = text.split("\\")[-1]
if "@" in text:
text = text.split("@")[0]
return text.casefold()
def norm_user(value: str) -> str:
return value.replace("B26\\", "").replace("b26\\", "").lower()
def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]:
"""Parse ``qwinsta`` output.
Disconnected sessions often have an empty SESSIONNAME column, so the line
becomes ``USERNAME ID STATE`` (3 tokens). Older parsing required 4 tokens
and skipped those rows — that broke RDG flap auto-logoff for Disc sessions.
"""
rows: list[HostSessionRow] = []
filter_sam = _qwinsta_sam_account(filter_user or "")
skip_users = frozenset({"services"})
for line in stdout.splitlines():
text = line.strip()
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
continue
parts = text.split()
if len(parts) < 4:
id_idx: int | None = None
sid = 0
for i, part in enumerate(parts):
token = part.lstrip(">")
if token.isdigit():
id_idx = i
sid = int(token)
break
if id_idx is None or id_idx < 1:
continue
session_name = parts[0].lstrip(">")
user_name = parts[1]
try:
sid = int(parts[2])
except ValueError:
state = " ".join(parts[id_idx + 1 :]) if id_idx + 1 < len(parts) else ""
if not state or state.casefold().startswith("listen"):
continue
state = " ".join(parts[3:])
if norm_filter and norm_filter not in norm_user(user_name):
before = parts[:id_idx]
if len(before) == 1:
session_name = ""
user_name = before[0].lstrip(">")
else:
session_name = before[0].lstrip(">")
user_name = before[1]
if user_name.casefold() in skip_users:
continue
if filter_sam and filter_sam not in _qwinsta_sam_account(user_name):
continue
rows.append(
HostSessionRow(
@@ -441,7 +467,7 @@ def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> li
)
)
if rows or not norm_filter:
if rows or not filter_sam:
return rows
return parse_qwinsta_sessions(stdout, filter_user=None)
@@ -155,13 +155,23 @@ def _disconnect_on_workstation(
login_event_id=login_event.id if login_event else None,
)
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout, filter_user=user), user)
parsed = parse_qwinsta_sessions(qwinsta.stdout, filter_user=user)
matched = _sessions_for_user(parsed, user)
if not matched and qwinsta.stdout.strip():
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout), user)
parsed = parse_qwinsta_sessions(qwinsta.stdout)
matched = _sessions_for_user(parsed, user)
if not matched:
snippet = " ".join(qwinsta.stdout.split())[:240]
parsed_note = f", parsed={len(parsed)} session(s)" if parsed else ""
message = (
f"No matching Windows session for user {user} on {workstation.hostname}"
f"{parsed_note}"
)
if snippet:
message = f"{message}; qwinsta: {snippet}"
return AutoDisconnectResult(
ok=False,
message="No matching Windows session for user",
message=message,
trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None,
@@ -224,11 +234,13 @@ def _auto_disconnect_rdg_flap(db: Session, event: Event) -> AutoDisconnectResult
try:
workstation = resolve_client_workstation(db, rdg_success)
except ClientWorkstationNotFoundError as exc:
return AutoDisconnectResult(
result = AutoDisconnectResult(
ok=False,
message=str(exc),
trigger_event_id=event.id,
)
_mark_auto_disconnect(event, result=result)
return result
rdg_end = event if event.type in RDG_END_TYPES else db.get(Event, _stored_flap_pair_id(event) or -1)
login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None