use std::net::IpAddr;
use chrono::{DateTime, Utc};
use miette::{IntoDiagnostic, Result, WrapErr, miette};
use serde::{Deserialize, Serialize};
use tracing::{debug, trace};
#[derive(Debug, Clone, Serialize)]
pub struct Event {
pub kind: EventKind,
pub session_id: u32,
pub user: String,
pub address: Option<String>,
pub time: DateTime<Utc>,
pub record_id: u64,
}
impl Event {
pub fn ip(&self) -> Option<IpAddr> {
let raw = self.address.as_deref()?;
parse_address(raw)
}
}
pub(crate) fn parse_address(raw: &str) -> Option<IpAddr> {
if raw.is_empty() || raw.eq_ignore_ascii_case("LOCAL") {
return None;
}
raw.split('%').next().unwrap_or(raw).parse().ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
Logon,
ShellStart,
Logoff,
Disconnect,
Reconnect,
}
impl EventKind {
fn from_id(id: u32) -> Option<Self> {
Some(match id {
21 => Self::Logon,
22 => Self::ShellStart,
23 => Self::Logoff,
24 => Self::Disconnect,
25 => Self::Reconnect,
_ => return None,
})
}
pub fn as_str(self) -> &'static str {
match self {
Self::Logon => "logon",
Self::ShellStart => "shell_start",
Self::Logoff => "logoff",
Self::Disconnect => "disconnect",
Self::Reconnect => "reconnect",
}
}
}
pub async fn poll_events(since: DateTime<Utc>) -> Result<Vec<Event>> {
let since_str = since.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let query = format!(
"*[System[(EventID=21 or EventID=22 or EventID=23 or EventID=24 or EventID=25) and TimeCreated[@SystemTime>='{since_str}']]]"
);
let xml = run_wevtutil(&query).await?;
parse_events(&xml)
}
#[cfg(windows)]
async fn run_wevtutil(query: &str) -> Result<String> {
let log = "Microsoft-Windows-TerminalServices-LocalSessionManager/Operational";
let query_owned = query.to_owned();
let output = tokio::task::spawn_blocking(move || {
duct::cmd!(
"wevtutil.exe",
"qe",
log,
format!("/q:{query_owned}"),
"/f:xml",
"/e:Events",
)
.stdout_capture()
.stderr_capture()
.run()
})
.await
.into_diagnostic()?
.into_diagnostic()
.wrap_err("running wevtutil")?;
String::from_utf8(output.stdout)
.into_diagnostic()
.wrap_err("wevtutil output was not utf-8")
}
#[cfg(not(windows))]
async fn run_wevtutil(_query: &str) -> Result<String> {
Err(miette!("rdp monitor is only available on Windows"))
}
fn parse_events(xml: &str) -> Result<Vec<Event>> {
#[derive(Debug, Deserialize)]
struct Events {
#[serde(default, rename = "Event")]
event: Vec<RawEvent>,
}
let trimmed = xml.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
let events: Events = quick_xml::de::from_str(trimmed)
.into_diagnostic()
.wrap_err("parsing event XML")?;
let mut out = Vec::with_capacity(events.event.len());
for raw in events.event {
match decode(raw) {
Ok(ev) => out.push(ev),
Err(err) => debug!(?err, "skipping unparsable event"),
}
}
out.sort_by_key(|e| e.record_id);
trace!(count = out.len(), "decoded events");
Ok(out)
}
#[derive(Debug, Deserialize)]
struct RawEvent {
#[serde(rename = "System")]
system: RawSystem,
#[serde(rename = "UserData")]
user_data: Option<RawUserData>,
}
#[derive(Debug, Deserialize)]
struct RawSystem {
#[serde(rename = "EventID")]
event_id: RawEventId,
#[serde(rename = "TimeCreated")]
time_created: RawTimeCreated,
#[serde(rename = "EventRecordID")]
event_record_id: RawScalar<u64>,
}
#[derive(Debug, Deserialize)]
struct RawEventId {
#[serde(rename = "$text")]
text: u32,
}
#[derive(Debug, Deserialize)]
struct RawScalar<T> {
#[serde(rename = "$text")]
text: T,
}
#[derive(Debug, Deserialize)]
struct RawTimeCreated {
#[serde(rename = "@SystemTime")]
system_time: String,
}
#[derive(Debug, Deserialize)]
struct RawUserData {
#[serde(rename = "EventXML")]
event_xml: RawEventXml,
}
#[derive(Debug, Deserialize)]
struct RawEventXml {
#[serde(rename = "User")]
user: Option<RawScalar<String>>,
#[serde(rename = "SessionID")]
session_id: Option<RawScalar<u32>>,
#[serde(rename = "Address")]
address: Option<RawScalar<String>>,
}
fn decode(raw: RawEvent) -> Result<Event> {
let id = raw.system.event_id.text;
let kind = EventKind::from_id(id).ok_or_else(|| miette!("unexpected event id {id}"))?;
let inner = raw
.user_data
.ok_or_else(|| miette!("event {id} missing UserData"))?
.event_xml;
let session_id = inner
.session_id
.ok_or_else(|| miette!("event {id} missing SessionID"))?
.text;
let user = inner.user.map(|u| u.text).unwrap_or_default();
let address = inner.address.map(|a| a.text).filter(|s| !s.is_empty());
let time = raw
.system
.time_created
.system_time
.parse::<DateTime<Utc>>()
.into_diagnostic()
.wrap_err("parsing event timestamp")?;
Ok(Event {
kind,
session_id,
user,
address,
time,
record_id: raw.system.event_record_id.text,
})
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"<Events>
<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'>
<System>
<Provider Name='Microsoft-Windows-TerminalServices-LocalSessionManager'/>
<EventID>24</EventID>
<TimeCreated SystemTime='2026-04-22T10:00:00.000Z'/>
<EventRecordID>100</EventRecordID>
</System>
<UserData>
<EventXML xmlns='Event_NS'>
<User>CORP\alice</User>
<SessionID>2</SessionID>
<Address>100.64.1.5</Address>
</EventXML>
</UserData>
</Event>
<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'>
<System>
<Provider Name='Microsoft-Windows-TerminalServices-LocalSessionManager'/>
<EventID>21</EventID>
<TimeCreated SystemTime='2026-04-22T10:00:05.000Z'/>
<EventRecordID>101</EventRecordID>
</System>
<UserData>
<EventXML xmlns='Event_NS'>
<User>CORP\bob</User>
<SessionID>3</SessionID>
<Address>100.64.2.9</Address>
</EventXML>
</UserData>
</Event>
</Events>"#;
#[test]
fn parses_sample_events() {
let events = parse_events(SAMPLE).unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].kind, EventKind::Disconnect);
assert_eq!(events[0].session_id, 2);
assert_eq!(events[0].user, r"CORP\alice");
assert_eq!(events[0].address.as_deref(), Some("100.64.1.5"));
assert_eq!(events[0].ip(), "100.64.1.5".parse::<IpAddr>().ok());
assert_eq!(events[1].kind, EventKind::Logon);
assert_eq!(events[1].session_id, 3);
assert_eq!(events[1].user, r"CORP\bob");
}
#[test]
fn parses_ipv6_with_zone_suffix() {
let xml = r#"<Events>
<Event><System><EventID>25</EventID><TimeCreated SystemTime='2026-04-22T10:00:00Z'/><EventRecordID>1</EventRecordID></System><UserData><EventXML><User>CORP\alice</User><SessionID>2</SessionID><Address>0:0:fd7a:115c:a1e0::%2318139703</Address></EventXML></UserData></Event>
</Events>"#;
let events = parse_events(xml).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(
events[0].address.as_deref(),
Some("0:0:fd7a:115c:a1e0::%2318139703")
);
assert!(events[0].ip().is_some());
}
#[test]
fn local_address_parses_to_none() {
assert_eq!(parse_address("LOCAL"), None);
assert_eq!(parse_address(""), None);
}
#[test]
fn empty_output_yields_no_events() {
assert!(parse_events("").unwrap().is_empty());
}
#[test]
fn logoff_without_address() {
let xml = r#"<Events>
<Event>
<System>
<EventID>23</EventID>
<TimeCreated SystemTime='2026-04-22T10:00:00.000Z'/>
<EventRecordID>50</EventRecordID>
</System>
<UserData>
<EventXML>
<User>CORP\alice</User>
<SessionID>2</SessionID>
</EventXML>
</UserData>
</Event>
</Events>"#;
let events = parse_events(xml).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, EventKind::Logoff);
assert!(events[0].address.is_none());
}
}