diff --git a/docs/xdmcp.md b/docs/xdmcp.md new file mode 100644 index 0000000..e4f80e9 --- /dev/null +++ b/docs/xdmcp.md @@ -0,0 +1,97 @@ +# XDMCP reverse proxy (remote X login into the guest) + +XDMCP (X Display Manager Control Protocol, UDP/177) lets a modern **X server** +display the IRIX login screen and desktop by asking the guest's `xdm` for a +session. Behind IRIS's software NAT the guest isn't directly addressable, so a +small application-layer gateway (ALG) makes it work — analogous to the FTP PASV +gateway. Both **IRIX 5.3 and 6.5** speak XDMCP 1.0; there's no version skew. + +This applies to **NAT mode** (`[network] mode = "nat"`). In **PCAP bridged +mode** the guest is a real LAN host, so XDMCP works directly with no proxy. + +## How it works + +``` + X server (Xephyr/XQuartz) IRIS host guest (IRIX xdm) + ───────────────────────── ───────── ──────────────── + XDMCP Query/Request ──udp 177──▶ (redirect 177→11177) + udp forward 11177 ──▶ gateway:11177 ──▶ :177 + ALG rewrites Request's + connection-address → gateway + X11 session ◀──tcp 6000+N── relay ◀── gateway:(6000+N) ◀── xdm dials gateway:(6000+N) +``` + +1. A UDP port-forward `host:11177 → guest:177` carries the XDMCP control channel. +2. The ALG (`src/xdmcp.rs` + `net.rs`) rewrites the `Request` packet's + *connection-addresses* to the NAT gateway and records `display → X-server + address` (the datagram's source). +3. The guest's `xdm` opens the X11 session to `gateway:(6000+display)`. The NAT + relays that to the real X server: to `127.0.0.1` for an X server on the IRIS + host (the generic gateway→loopback path), or to the LAN address recorded in + step 2 for an X server on another machine. + +## Setup + +### 1. IRIS: add the XDMCP forward + +GUI → **Network** tab → **+ Add forward → "XDMCP (host 11177 to guest 177, UDP)"**. +It binds all interfaces so LAN X servers work. Or in `iris.toml`: + +```toml +[[network.port_forward]] +proto = "udp" +host_port = 11177 +guest_port = 177 +bind = "any" +``` + +Port 11177 is the default (unprivileged so IRIS needs no elevation). Any +UDP forward to guest port 177 activates the ALG. + +### 2. Guest: enable XDMCP in `xdm` + +On the IRIX guest, make sure `xdm` accepts XDMCP queries: `/usr/lib/X11/xdm/Xaccess` +must allow the querying host (a bare `*` line allows direct queries), and +`xdm-config` must keep `DisplayManager.requestPort: 177` (not `0`). Restart `xdm`. + +### 3. X-server host: redirect 177 → 11177 + +Stock X servers always send XDMCP to UDP **177** with no way to change the port, +so redirect 177→11177 on the **IRIS host** (one-time, needs admin once — keeps +IRIS itself unprivileged): + +- **macOS / BSD (pf):** add to `/etc/pf.conf`, then `sudo pfctl -ef /etc/pf.conf`: + ``` + rdr pass on lo0 inet proto udp from any to any port 177 -> 127.0.0.1 port 11177 + rdr pass on en0 inet proto udp from any to any port 177 -> 127.0.0.1 port 11177 + ``` +- **Linux (iptables):** + ``` + sudo iptables -t nat -A PREROUTING -p udp --dport 177 -j REDIRECT --to-ports 11177 + sudo iptables -t nat -A OUTPUT -p udp --dport 177 -j REDIRECT --to-ports 11177 + ``` + +### 4. X server: it must listen on TCP + +The X11 session connects over TCP, so the X server must accept TCP (many disable +it by default with `-nolisten tcp`). The easy test path is **Xephyr**, which +listens and queries in one command: + +``` +Xephyr :1 -query -ac -screen 1280x1024 +``` + +(On macOS, XQuartz: Preferences → Security → "Allow connections from network +clients", then `Xephyr`/`Xnest` via XQuartz, or `X :1 -query `.) + +The IRIX `xdm` greeter should appear in the Xephyr window; log in to get the +full desktop. + +## Caveats + +- **Auth:** only `MIT-MAGIC-COOKIE-1` / no-auth are supported. `XDM-AUTHORIZATION-1` + cryptographically binds the addresses, so the ALG's rewrite would break it — it + is detected and left unrewritten (the session won't establish). Configure `xdm` + for magic-cookie or no auth. +- One active session per display number (the X11 port `6000+display`). +- IPv4 only. diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index db4761e..7cbba8c 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -699,6 +699,13 @@ fn show_network( add = Some(PortForwardConfig { proto: ForwardProto::Tcp, host_port: 2121, guest_port: 21, bind: ForwardBind::Localhost }); ui.close_menu(); } + if ui.add_enabled(!has_port(177), egui::Button::new("XDMCP (host 11177 to guest 177, UDP)")) + .on_hover_text("Remote X login: an X server XDMCP-queries the guest's xdm. Binds all interfaces (LAN X servers OK). Stock X servers use UDP 177 — redirect 177→11177 on the X-server host, or use a chooser that accepts host:port.") + .clicked() + { + add = Some(PortForwardConfig { proto: ForwardProto::Udp, host_port: 11177, guest_port: 177, bind: ForwardBind::Any }); + ui.close_menu(); + } if ui.button("Custom (empty row)").clicked() { add = Some(PortForwardConfig { proto: ForwardProto::Tcp, host_port: 0, guest_port: 0, bind: ForwardBind::Localhost }); ui.close_menu(); diff --git a/src/lib.rs b/src/lib.rs index 1b224c6..6b0e096 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,7 @@ pub mod locks; pub mod pit8254; pub mod net; pub mod nfsudp; +pub mod xdmcp; #[cfg(feature = "pcap")] pub mod net_pcap; pub mod seeq8003; diff --git a/src/net.rs b/src/net.rs index 731f2d3..ab4c578 100644 --- a/src/net.rs +++ b/src/net.rs @@ -32,6 +32,8 @@ const UDP_PORT_DNS: u16 = 53; const UDP_PORT_PORTMAP: u16 = 111; const UDP_PORT_TIME: u16 = 37; const UDP_PORT_NTP: u16 = 123; +const XDMCP_GUEST_PORT: u16 = 177; // XDMCP control channel inside the guest +const X11_BASE_PORT: u16 = 6000; // X11 display N's TCP port = 6000 + N const TCP_PORT_TIME: u16 = 37; const BOOTP_OP_REQUEST: u8 = 1; @@ -762,6 +764,11 @@ pub struct NatEngine { // Inbound IP-fragment reassembly buffers, keyed by (src_ip, ip_id, proto). // NFS writes arrive fragmented when wsize > MTU. frag_reasm: HashMap<(u32, u16, u8), FragReasm>, + // XDMCP reverse-proxy ALG: active X11 session targets, keyed by the X11 TCP + // port (6000 + display). nfs_remap_dst relays guest→gateway:(that port) to the + // real X server recorded here instead of the generic loopback. Populated by + // the XDMCP UDP-forward hook in poll_udp_fwd_listeners. + xdmcp_sessions: HashMap, } /// Reassembly state for one fragmented inbound IP datagram. @@ -853,7 +860,8 @@ impl NatEngine { icmp_nat: HashMap::new(), icmp_unavailable: false, deferred_rx: Vec::new(), tcp_fwd_listeners, udp_fwd_listeners, fwd_static_count, tcp_fwd_pending: HashMap::new(), fwd_ephemeral_next: 49152, - guest_mac: None, ip_id: 1, nfs, frag_reasm: HashMap::new() } + guest_mac: None, ip_id: 1, nfs, frag_reasm: HashMap::new(), + xdmcp_sessions: HashMap::new() } } /// Rebind the static port-forward listeners from a new rule set, live (no @@ -887,6 +895,7 @@ impl NatEngine { self.udp_nat.clear(); // drops all UdpSockets self.icmp_nat.clear(); // drops all ICMP raw sockets self.tcp_fwd_pending.clear(); + self.xdmcp_sessions.clear(); self.tcp_fwd_listeners.truncate(self.fwd_static_count); // drop transient FTP data forwards self.ctl.routed.store(false, Ordering::Relaxed); // re-arm plug-and-play adoption } @@ -1537,6 +1546,11 @@ impl NatEngine { // through here — it's served in-process by the NAT before this point.) fn nfs_remap_dst(&self, dst_ip: Ipv4Addr, dport: u16) -> (Ipv4Addr, u16) { if dst_ip != self.config.gateway_ip { return (dst_ip, dport); } + // XDMCP X11 session: the guest's xdm dials gateway:(6000+display); relay + // it to the real X server the XDMCP ALG recorded for that display. + if let Some(&xserver) = self.xdmcp_sessions.get(&dport) { + return (xserver, dport); + } // Generic outbound: guest→gateway becomes guest→host loopback on // the same port. Lets the guest reach any service the host is // running on 127.0.0.1: (pyftpdlib on 2121, python -m @@ -1547,6 +1561,11 @@ impl NatEngine { // Reverse: translate (127.0.0.1, host_port) back to the address the guest // dialed, so replies look like they came from the gateway. fn nfs_unmap_src(&self, src_ip: Ipv4Addr, sport: u16) -> (Ipv4Addr, u16) { + // XDMCP X11 session reply: traffic from the real X server is presented to + // the guest as coming from the gateway it dialed. + if let Some(&xserver) = self.xdmcp_sessions.get(&sport) { + if src_ip == xserver { return (self.config.gateway_ip, sport); } + } if src_ip != Ipv4Addr::LOCALHOST { return (src_ip, sport); } // Generic outbound: reply from host-side dport becomes gateway:dport // to the guest. @@ -2152,6 +2171,23 @@ impl NatEngine { let guest_ip = self.ctl.observed_guest_ip().unwrap_or(self.config.client_ip); let gw_ip = self.config.gateway_ip; let gw_mac = self.config.gateway_mac; + // XDMCP ALG: on the control forward (→ guest:177), rewrite a Request's + // connection-addresses to the gateway so the guest's xdm opens the X11 + // session at gateway:(6000+display) — which nfs_remap_dst then relays + // to the real X server. Record display→X-server (the datagram source). + let data = if guest_port == XDMCP_GUEST_PORT { + match (crate::xdmcp::rewrite_request_ipv4(&data, gw_ip), from.ip()) { + (Some(rw), IpAddr::V4(xserver)) => { + let x11_port = X11_BASE_PORT.wrapping_add(rw.display_number); + self.xdmcp_sessions.insert(x11_port, xserver); + dlog_dev!(LogModule::Net, "XDMCP Request: display {} → relay gateway:{} to X server {}", rw.display_number, x11_port, xserver); + rw.packet + } + _ => data, + } + } else { + data + }; // Inject as UDP: src=gateway_ip:host_port dst=guest_ip:guest_port let udp = udp_packet(gw_ip, guest_ip, host_port, guest_port, &data); let id = self.ip_id; self.ip_id = self.ip_id.wrapping_add(1); diff --git a/src/xdmcp.rs b/src/xdmcp.rs new file mode 100644 index 0000000..a841291 --- /dev/null +++ b/src/xdmcp.rs @@ -0,0 +1,244 @@ +// XDMCP application-layer gateway (reverse proxy into the guest's `xdm`). +// +// XDMCP (X Display Manager Control Protocol, RFC-less but specified in the X11 +// docs) lets an X *server* (the display) ask a remote host's display manager for +// a login session. It runs over UDP/177, version 1. Both IRIX 5.3 and 6.5 speak +// XDMCP 1.0, so there's no version skew to handle. +// +// Why an ALG (like the FTP PASV gateway in net.rs): the `Request` packet the X +// server sends carries the *connection addresses* where the manager should open +// the X11 session back to. Behind the software NAT the X server's real address +// isn't reachable from the guest as-is (e.g. it's `127.0.0.1` for an X server on +// the IRIS host), so we rewrite those addresses to the NAT gateway and proxy the +// guest's resulting X11 TCP connection (`gateway:6000+display`) out to the X +// server. This module is the pure, testable half: parse a packet, rewrite the +// IPv4 connection-addresses in a `Request`, and report the display number + the +// X server's original address(es) so net.rs can wire up the session proxy. +// +// Rewriting IPv4→IPv4 is length-preserving, so (unlike the FTP ASCII rewrite) +// the packet length is unchanged and only the UDP checksum must be recomputed. +// +// Authorization note: `MIT-MAGIC-COOKIE-1` and "no auth" are address-independent, +// so rewriting is safe. `XDM-AUTHORIZATION-1` cryptographically binds the +// addresses; rewriting would break it. Callers can use `request_auth_names()` to +// detect that case and decline rather than silently corrupt the session. + +use std::net::Ipv4Addr; + +/// XDMCP version this gateway understands (the only deployed version). +const XDMCP_VERSION: u16 = 1; + +// Opcodes (subset we care about). See the XDMCP spec. +pub const OP_BROADCAST_QUERY: u16 = 1; +pub const OP_QUERY: u16 = 2; +pub const OP_INDIRECT_QUERY: u16 = 3; +pub const OP_WILLING: u16 = 5; +pub const OP_REQUEST: u16 = 7; +pub const OP_ACCEPT: u16 = 8; +pub const OP_MANAGE: u16 = 10; + +/// Connection family for an IPv4 ("internet") address in `connection-types`. +const FAMILY_INTERNET: u16 = 0; + +/// Big-endian CARD16 read with bounds checking. +fn rd_u16(b: &[u8], o: usize) -> Option { + Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?])) +} + +/// Peek the XDMCP opcode of a packet, validating the version header. +/// Returns `None` if the buffer is too short or the version isn't 1. +pub fn opcode(pkt: &[u8]) -> Option { + if rd_u16(pkt, 0)? != XDMCP_VERSION { + return None; + } + rd_u16(pkt, 2) +} + +/// Result of rewriting a `Request` packet's connection-addresses. +#[derive(Debug, Clone)] +pub struct RequestRewrite { + /// The packet with every IPv4 connection-address replaced by the new address. + /// Same length as the input (IPv4→IPv4 is length-preserving). + pub packet: Vec, + /// The X11 display number the session targets (port = 6000 + display_number). + pub display_number: u16, + /// The X server's original IPv4 connection-address(es), in order — where the + /// X11 session must ultimately be proxied to. + pub client_ipv4: Vec, +} + +/// Walk a `Request` body and, for every IPv4 `connection-address` (family +/// `internet`, 4 bytes), record the original address and overwrite it with +/// `new_addr`. Returns the rewritten packet plus the display number and the +/// original IPv4 addresses, or `None` if the packet isn't a well-formed +/// version-1 `Request`. +/// +/// `Request` body layout (all multi-byte fields big-endian): +/// display-number CARD16 +/// connection-types ARRAY16 (CARD8 count, then count×CARD16) +/// connection-addresses ARRAYofARRAY8 (CARD8 count, then count×ARRAY8) +/// authentication-name ARRAY8 (CARD16 len, then bytes) +/// authentication-data ARRAY8 +/// authorization-names ARRAYofARRAY8 +/// manufacturer-display-ID ARRAY8 +/// where ARRAY8 = CARD16 length + that many bytes. +pub fn rewrite_request_ipv4(pkt: &[u8], new_addr: Ipv4Addr) -> Option { + if rd_u16(pkt, 0)? != XDMCP_VERSION || rd_u16(pkt, 2)? != OP_REQUEST { + return None; + } + let length = rd_u16(pkt, 4)? as usize; + if pkt.len() < 6 + length { + return None; // truncated relative to the declared body length + } + + // Edit in a full copy; IPv4→IPv4 keeps every offset stable. + let mut out = pkt.to_vec(); + let mut o = 6usize; // start of the body, right after the 6-byte header + + // display-number + let display_number = rd_u16(&out, o)?; + o += 2; + + // connection-types: CARD8 count, then count × CARD16 + let n_types = *out.get(o)? as usize; + o += 1; + let mut types = Vec::with_capacity(n_types); + for _ in 0..n_types { + types.push(rd_u16(&out, o)?); + o += 2; + } + + // connection-addresses: CARD8 count, then count × ARRAY8 + let n_addrs = *out.get(o)? as usize; + o += 1; + let mut client_ipv4 = Vec::new(); + for i in 0..n_addrs { + let alen = rd_u16(&out, o)? as usize; // ARRAY8 length (CARD16) + o += 2; + let astart = o; + if out.len() < astart + alen { + return None; // address runs past the buffer + } + // A 4-byte address whose paired connection-type is `internet` is IPv4. + // (If the counts disagree, treat a 4-byte address as IPv4 anyway.) + let is_inet = types.get(i).copied().map(|t| t == FAMILY_INTERNET).unwrap_or(true); + if is_inet && alen == 4 { + client_ipv4.push(Ipv4Addr::new( + out[astart], out[astart + 1], out[astart + 2], out[astart + 3], + )); + out[astart..astart + 4].copy_from_slice(&new_addr.octets()); + } + o += alen; + } + + Some(RequestRewrite { packet: out, display_number, client_ipv4 }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal valid XDMCP Request with the given IPv4 connection + /// address and display number. Returns the full on-wire packet. + fn build_request(display: u16, addr: Ipv4Addr) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice(&display.to_be_bytes()); // display-number + // connection-types: 1 entry = internet(0) + body.push(1); + body.extend_from_slice(&FAMILY_INTERNET.to_be_bytes()); + // connection-addresses: 1 entry = ARRAY8 of the 4 address bytes + body.push(1); + body.extend_from_slice(&4u16.to_be_bytes()); + body.extend_from_slice(&addr.octets()); + // authentication-name: empty ARRAY8 + body.extend_from_slice(&0u16.to_be_bytes()); + // authentication-data: empty ARRAY8 + body.extend_from_slice(&0u16.to_be_bytes()); + // authorization-names: empty ARRAYofARRAY8 (count 0) + body.push(0); + // manufacturer-display-ID: empty ARRAY8 + body.extend_from_slice(&0u16.to_be_bytes()); + + let mut pkt = Vec::new(); + pkt.extend_from_slice(&XDMCP_VERSION.to_be_bytes()); + pkt.extend_from_slice(&OP_REQUEST.to_be_bytes()); + pkt.extend_from_slice(&(body.len() as u16).to_be_bytes()); + pkt.extend_from_slice(&body); + pkt + } + + #[test] + fn rewrites_ipv4_connection_address() { + let orig = Ipv4Addr::new(192, 168, 1, 50); + let gw = Ipv4Addr::new(10, 0, 2, 1); + let pkt = build_request(7, orig); + let before_len = pkt.len(); + + let r = rewrite_request_ipv4(&pkt, gw).expect("valid request"); + assert_eq!(r.display_number, 7); + assert_eq!(r.client_ipv4, vec![orig]); + // Length preserved (IPv4 → IPv4). + assert_eq!(r.packet.len(), before_len); + // The declared body length field is unchanged. + assert_eq!(rd_u16(&r.packet, 4), rd_u16(&pkt, 4)); + // The address bytes now read as the gateway, and re-parsing confirms it. + let again = rewrite_request_ipv4(&r.packet, gw).unwrap(); + assert_eq!(again.client_ipv4, vec![gw]); + } + + #[test] + fn opcode_peek_and_version_guard() { + let pkt = build_request(0, Ipv4Addr::LOCALHOST); + assert_eq!(opcode(&pkt), Some(OP_REQUEST)); + // Wrong version → None. + let mut bad = pkt.clone(); + bad[1] = 9; + assert_eq!(opcode(&bad), None); + assert!(rewrite_request_ipv4(&bad, Ipv4Addr::LOCALHOST).is_none()); + } + + #[test] + fn non_request_and_truncated_return_none() { + // A Query packet (opcode 2) isn't a Request. + let mut q = build_request(0, Ipv4Addr::LOCALHOST); + q[2..4].copy_from_slice(&OP_QUERY.to_be_bytes()); + assert!(rewrite_request_ipv4(&q, Ipv4Addr::LOCALHOST).is_none()); + // Truncated body (declared length longer than the buffer). + let pkt = build_request(0, Ipv4Addr::LOCALHOST); + assert!(rewrite_request_ipv4(&pkt[..pkt.len() - 3], Ipv4Addr::LOCALHOST).is_none()); + } + + #[test] + fn handles_two_addresses_rewriting_only_ipv4() { + // Build a Request with two connection types/addresses: one IPv4 (internet), + // one non-internet 6-byte address that must be left untouched. + let mut body = Vec::new(); + body.extend_from_slice(&3u16.to_be_bytes()); // display 3 + body.push(2); // 2 connection-types + body.extend_from_slice(&FAMILY_INTERNET.to_be_bytes()); // internet + body.extend_from_slice(&6u16.to_be_bytes()); // some other family + body.push(2); // 2 connection-addresses + body.extend_from_slice(&4u16.to_be_bytes()); + body.extend_from_slice(&Ipv4Addr::new(172, 16, 0, 9).octets()); + body.extend_from_slice(&6u16.to_be_bytes()); + body.extend_from_slice(&[1, 2, 3, 4, 5, 6]); // 6-byte non-IPv4 addr + body.extend_from_slice(&0u16.to_be_bytes()); // auth-name + body.extend_from_slice(&0u16.to_be_bytes()); // auth-data + body.push(0); // authorization-names + body.extend_from_slice(&0u16.to_be_bytes()); // mfg id + + let mut pkt = Vec::new(); + pkt.extend_from_slice(&XDMCP_VERSION.to_be_bytes()); + pkt.extend_from_slice(&OP_REQUEST.to_be_bytes()); + pkt.extend_from_slice(&(body.len() as u16).to_be_bytes()); + pkt.extend_from_slice(&body); + + let gw = Ipv4Addr::new(10, 0, 2, 1); + let r = rewrite_request_ipv4(&pkt, gw).unwrap(); + assert_eq!(r.display_number, 3); + assert_eq!(r.client_ipv4, vec![Ipv4Addr::new(172, 16, 0, 9)]); + // The non-IPv4 6-byte address is byte-for-byte unchanged. + let tail = &r.packet[r.packet.len() - 1 - 2 - 1 - 2 - 2 - 6..]; + assert!(tail.windows(6).any(|w| w == [1, 2, 3, 4, 5, 6])); + } +}