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..5c08c78 --- /dev/null +++ b/rules/irix/rsh-forward-needs-reserved-source-port.md @@ -0,0 +1,84 @@ +# 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. 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 + +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..7ace23b 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() } } @@ -2378,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(); @@ -2394,11 +2427,6 @@ 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; } - - 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); @@ -2409,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, &[]);