Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions HELP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions rules/irix/rsh-forward-needs-reserved-source-port.md
Original file line number Diff line number Diff line change
@@ -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 <local-username>`

## 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.
51 changes: 43 additions & 8 deletions src/net.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() }
}
Expand Down Expand Up @@ -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<u16> {
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();
Expand All @@ -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);
Expand All @@ -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, &[]);
Expand Down
Loading