From f7a771e2af37382a88f1eb32e42ae4385e83d44d Mon Sep 17 00:00:00 2001 From: Antoni Sawicki Date: Fri, 17 Jul 2026 03:59:22 -0700 Subject: [PATCH 1/2] NAT: use a reserved source port for forwards to rsh/rlogin Inbound port-forwards synthesize the SYN injected into the guest and took the source port from fwd_ephemeral_next (49152+). BSD r-services reject any client whose source port falls outside 512..1023, and they do it before reading the request, so rshd hung up on every forwarded connection while telnet through the same mechanism worked fine. The host client's own source port never reaches the guest, so a client binding a reserved port (or being run as root) made no difference -- which made the failure look unrelated to ports. Allocate from a separate 512..1023 counter when the forward targets 513/514. 512 ports is ample; the NAT key (guest_ip, guest_port, sport) stays unique. Verified on IRIX 5.3 with a 2514 -> 514 forward: $ rsh -p 2514 root@127.0.0.1 uname -a IRIX IRIS 5.3 12200159 IP22 mips --- HELP.md | 22 +++++ .../rsh-forward-needs-reserved-source-port.md | 80 +++++++++++++++++++ src/net.rs | 18 ++++- 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 rules/irix/rsh-forward-needs-reserved-source-port.md diff --git a/HELP.md b/HELP.md index f5a578e..5da7678 100644 --- a/HELP.md +++ b/HELP.md @@ -202,6 +202,28 @@ The emulator prints a line for each rule that binds successfully at startup: iris: TCP port forward 127.0.0.1:2323 → guest:23 ``` +#### rsh / rlogin + +Forwards to guest port 514 (`shell`) or 513 (`login`) are given a source port in +the reserved 512–1023 range, because `rshd`/`rlogind` reject any client outside +it. The guest sees the connection as coming from the **gateway** (`192.168.0.1`), +not from the host's own address, so set up the trust against that: + +```sh +# on IRIX — /etc/inetd.conf must have: shell stream tcp nowait root /usr/etc/rshd rshd +echo '192.168.0.1 gateway' >> /etc/hosts +echo 'gateway yourname' > /.rhosts # ~/.rhosts for non-root; hosts.equiv never applies to root +chmod 600 /.rhosts +``` + +```bash +rsh -p 2514 root@127.0.0.1 uname -a +``` + +The rsh stderr channel (the reverse connection rshd opens back to the client) +does not survive NAT — use a client that sends `0` as the stderr port, as `rcp` +and most modern implementations do. + #### Testing ```bash diff --git a/rules/irix/rsh-forward-needs-reserved-source-port.md b/rules/irix/rsh-forward-needs-reserved-source-port.md new file mode 100644 index 0000000..2b4ba83 --- /dev/null +++ b/rules/irix/rsh-forward-needs-reserved-source-port.md @@ -0,0 +1,80 @@ +# Inbound port-forwards to rsh/rlogin need a reserved source port + +## Symptom + +`rsh` into the guest through a `[[port_forward]]` to guest port 514 always +fails, while telnet through the same mechanism works fine. `rshd` closes the +connection without writing a single byte, so a client that reports the server's +own framing has nothing to show: + +``` +rsh: server closed connection without response +``` + +`/var/adm/SYSLOG` on the guest has the real reason: + +``` +rshd[123]: Connection from 192.168.0.1 on illegal port 49152 +``` + +## Cause + +`poll_tcp_fwd_listeners` synthesizes the SYN it injects into the guest, and it +allocated the source port from `fwd_ephemeral_next`, which starts at 49152. The +guest never sees the host client's real source port — only the one we make up. + +BSD r-services authenticate with `.rhosts`/`hosts.equiv` trust, which is only +meaningful if the client proved it was root by binding a reserved port. So +`rshd` rejects anything outside 512..1023 *before reading the request*: + +```c +if (fromp->sin_port >= IPPORT_RESERVED || fromp->sin_port < IPPORT_RESERVED/2) + exit(1); /* "Connection from %s on illegal port" */ +``` + +A client that binds a reserved port on the host — as a correct rsh does — makes +no difference, because the NAT discards it. This is also why running the client +under `sudo` changes nothing, which makes the failure look unrelated to ports. + +## Fix + +Allocate the injected source port from a separate 512..1023 counter when the +forward targets 513/514. 512 ports is ample; the NAT key +`(guest_ip, guest_port, sport)` stays unique. + +## Guest-side setup this still needs + +The forward makes the connection appear to come from the gateway +(192.168.0.1), and reverse DNS for it fails (queries go to the upstream +resolver, which knows nothing about RFC1918 space). Trust must be granted to +the gateway: + +- `/etc/inetd.conf`: `shell stream tcp nowait root /usr/etc/rshd rshd` +- `/etc/hosts`: `192.168.0.1 gateway` +- `~/.rhosts`, mode 600 — `/.rhosts` for root, since `hosts.equiv` never + applies to root: `gateway ` + +## The stderr channel + +The rsh protocol's second connection is a reverse one: the client passes a port +number and `rshd` dials *back* to it from a `rresvport()`. That direction +already lands correctly — `nfs_remap_dst` maps guest→`192.168.0.1:N` onto host +`127.0.0.1:N` — but the NAT rewrites its source port to an OS-chosen ephemeral, +and `rcmd(3)` clients check that the reverse connection's source port is +reserved too. Clients that send `0` as the stderr port (`rcp` does, as do most +modern implementations) sidestep this entirely. Supporting the stderr channel +would require the outbound NAT connect to bind a reserved port, i.e. root on +the host. + +## Verified + +IRIX 5.3, NAT mode, forward `2514 → 514`: + +``` +$ rsh -p 2514 root@127.0.0.1 uname -a +IRIX IRIS 5.3 12200159 IP22 mips +``` + +Use `127.0.0.1`, not `localhost`: `bind = "localhost"` binds IPv4 only, so a +client that resolves `localhost` to `::1` gets ECONNREFUSED — a failure that +looks exactly like the forward not being there. diff --git a/src/net.rs b/src/net.rs index 60b0a77..3733df8 100644 --- a/src/net.rs +++ b/src/net.rs @@ -1048,6 +1048,8 @@ pub struct NatEngine { tcp_fwd_pending: HashMap<(u32, u16, u16), TcpFwdPending>, // Monotonically increasing counter for generating ephemeral ports for inbound forwards. fwd_ephemeral_next: u16, + // Same, for forwards to BSD r-services, which reject any source port outside 512..1023. + fwd_reserved_next: u16, // Number of configured (static) forwards at the front of `tcp_fwd_listeners`; // anything past this index is a transient FTP-ALG data forward (bounded, FIFO). fwd_static_count: usize, @@ -1157,6 +1159,7 @@ 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, + fwd_reserved_next: 512, guest_mac: None, ip_id: 1, nfs, frag_reasm: HashMap::new(), xdmcp_sessions: HashMap::new() } } @@ -2394,9 +2397,18 @@ impl NatEngine { } } for (stream, guest_port) in accepted { - let ephemeral = self.fwd_ephemeral_next; - self.fwd_ephemeral_next = self.fwd_ephemeral_next.wrapping_add(1); - if self.fwd_ephemeral_next < 49152 { self.fwd_ephemeral_next = 49152; } + // rshd/rlogind reject a client whose source port isn't reserved, and the + // guest only ever sees the port synthesized here, not the host client's. + let ephemeral = if matches!(guest_port, 513 | 514) { + let p = self.fwd_reserved_next; + self.fwd_reserved_next = if p >= 1023 { 512 } else { p + 1 }; + p + } else { + let p = self.fwd_ephemeral_next; + self.fwd_ephemeral_next = self.fwd_ephemeral_next.wrapping_add(1); + if self.fwd_ephemeral_next < 49152 { self.fwd_ephemeral_next = 49152; } + p + }; let client_isn = 0x6000_0000u32.wrapping_add(ephemeral as u32); // Forward to the guest's *actual* IP (learned from its traffic), not From b3b9c2dff0e285c6f99bdc98f6b9e4c6917248d2 Mon Sep 17 00:00:00 2001 From: Antoni Sawicki Date: Fri, 17 Jul 2026 12:16:43 -0700 Subject: [PATCH 2/2] NAT: probe for a free forward source port instead of blindly cycling The reserved (512..=1023) and ephemeral (49152..) source-port counters for inbound forwards advanced blindly. Since the chosen port is part of the tcp_fwd_pending / tcp_nat / tcp_tw keys, a wrapped counter could reuse a port still live for the same guest port and silently overwrite that entry via HashMap::insert, breaking the active connection. Only 512 reserved ports exist, so rsh/rlogin is where this bites first. Add alloc_fwd_sport(), which probes the range and returns the first port with no live entry in any of the three maps, or None if the range is exhausted (the caller then drops the accept). Also reorder so an accept dropped for an unknown guest MAC no longer consumes a port. Verified on IRIX 5.3: 8 concurrent rsh sessions each get a distinct reserved source port with no collision. --- .../rsh-forward-needs-reserved-source-port.md | 8 ++- src/net.rs | 57 +++++++++++++------ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/rules/irix/rsh-forward-needs-reserved-source-port.md b/rules/irix/rsh-forward-needs-reserved-source-port.md index 2b4ba83..5c08c78 100644 --- a/rules/irix/rsh-forward-needs-reserved-source-port.md +++ b/rules/irix/rsh-forward-needs-reserved-source-port.md @@ -39,8 +39,12 @@ under `sudo` changes nothing, which makes the failure look unrelated to ports. ## Fix Allocate the injected source port from a separate 512..1023 counter when the -forward targets 513/514. 512 ports is ample; the NAT key -`(guest_ip, guest_port, sport)` stays unique. +forward targets 513/514. The chosen port becomes part of the +`tcp_fwd_pending` / `tcp_nat` / `tcp_tw` keys, so probe past any port still live +for that guest port rather than blindly cycling — a wrapped counter would +otherwise overwrite an in-flight or established entry and break that connection. +512 ports covers any realistic number of concurrent rsh/rlogin sessions; if the +range is somehow exhausted, drop the accept. ## Guest-side setup this still needs diff --git a/src/net.rs b/src/net.rs index 3733df8..7ace23b 100644 --- a/src/net.rs +++ b/src/net.rs @@ -2381,6 +2381,36 @@ impl NatEngine { // guest's ethernet as if gateway_ip:ephemeral opened a connection to the guest. // The accepted TcpStream is stored in tcp_fwd_pending until the guest replies // with SYN-ACK (handled in handle_tcp above). + // Pick a source port for a synthesized inbound-forward connection that isn't + // already live for this guest port. Forwards to rsh/rlogin (513/514) must use + // a reserved 512..=1023 port or the guest's rshd/rlogind rejects them; all + // others use the 49152.. ephemeral range. The chosen port becomes part of the + // tcp_fwd_pending / tcp_nat / tcp_tw keys, so probe past any still in use — a + // blindly wrapped counter would otherwise overwrite a live entry and break + // that connection. Returns None if every port in the range is occupied. + fn alloc_fwd_sport(&mut self, guest_ip: Ipv4Addr, guest_port: u16) -> Option { + let reserved = matches!(guest_port, 513 | 514); + let (lo, hi) = if reserved { (512u16, 1023u16) } else { (49152u16, 65535u16) }; + let gi = u32::from(guest_ip); + let gw = u32::from(self.config.gateway_ip); + let mut cur = if reserved { self.fwd_reserved_next } else { self.fwd_ephemeral_next }; + if cur < lo || cur > hi { cur = lo; } + let mut chosen = None; + for _ in 0..=(hi - lo) { + let p = cur; + cur = if p >= hi { lo } else { p + 1 }; + if !self.tcp_fwd_pending.contains_key(&(gi, guest_port, p)) + && !self.tcp_nat.contains_key(&(gw, p, guest_port)) + && !self.tcp_tw.contains_key(&(gw, p, guest_port)) + { + chosen = Some(p); + break; + } + } + if reserved { self.fwd_reserved_next = cur; } else { self.fwd_ephemeral_next = cur; } + chosen + } + fn poll_tcp_fwd_listeners(&mut self) { // Collect accepted streams to avoid mut borrow conflict while iterating listeners. let mut accepted: Vec<(TcpStream, u16)> = Vec::new(); @@ -2397,20 +2427,6 @@ impl NatEngine { } } for (stream, guest_port) in accepted { - // rshd/rlogind reject a client whose source port isn't reserved, and the - // guest only ever sees the port synthesized here, not the host client's. - let ephemeral = if matches!(guest_port, 513 | 514) { - let p = self.fwd_reserved_next; - self.fwd_reserved_next = if p >= 1023 { 512 } else { p + 1 }; - p - } else { - let p = self.fwd_ephemeral_next; - self.fwd_ephemeral_next = self.fwd_ephemeral_next.wrapping_add(1); - if self.fwd_ephemeral_next < 49152 { self.fwd_ephemeral_next = 49152; } - p - }; - - let client_isn = 0x6000_0000u32.wrapping_add(ephemeral as u32); // Forward to the guest's *actual* IP (learned from its traffic), not // the assumed NAT client address — a static guest can be on any host. let guest_ip = self.ctl.observed_guest_ip().unwrap_or(self.config.client_ip); @@ -2421,14 +2437,21 @@ impl NatEngine { // IRIX will drop it (broadcast dst MAC with unicast dst IP is rejected). let Some(guest_mac) = self.guest_mac else { dlog_dev!(LogModule::Net, "NAT FWD TCP: guest MAC not yet known, deferring SYN for guest:{}", guest_port); - // Put the stream back — we'll retry next poll cycle once we've seen a frame. - // Simplest approach: re-queue by pushing back into accepted list isn't possible - // here, so just drop this accept and let the host retry. + // Drop this accept without consuming a source port; the host will retry. // In practice the guest MAC is always known by the time port-forward traffic arrives. drop(stream); continue; }; + // Synthesize the client's source port (the guest never sees the host + // client's real one). None means the range is exhausted — drop the accept. + let Some(ephemeral) = self.alloc_fwd_sport(guest_ip, guest_port) else { + dlog_dev!(LogModule::Net, "NAT FWD TCP: no free source port for guest:{}, dropping accept", guest_port); + drop(stream); + continue; + }; + let client_isn = 0x6000_0000u32.wrapping_add(ephemeral as u32); + dlog_dev!(LogModule::Net, "NAT FWD TCP inject SYN {}:{} → {}:{}", gw_ip, ephemeral, guest_ip, guest_port); let seg = tcp_segment(gw_ip, guest_ip, ephemeral, guest_port, client_isn, 0, 0x02, &[]);