Access Remote Machines over SSH with rstream

Use private bytestream tunnels and OpenSSH ProxyCommand to reach remote machines without exposing SSH publicly.


This guide explains how to reach a remote machine over SSH when the machine is not publicly reachable, or when exposing SSH directly would create unnecessary exposure. The same pattern applies to homelabs, embedded devices, personal computers, and administrative hosts that sit behind NAT, private addressing, restrictive firewalls, or any other network boundary that makes inbound connectivity impractical.

rstream solves the transport problem by carrying the SSH stream through a private tunnel. The remote machine establishes the outbound connection to the rstream edge network, while the SSH client remains local. OpenSSH contributes ProxyCommand, which lets a helper process supply the network path. In this workflow, rstream nc plays that role. It dials the tunnel and hands the resulting byte stream to ssh.

That separation matters for the security model. rstream provides the transport path and access control around the tunnel, but it does not terminate SSH. Host key verification, user authentication, and session encryption remain end-to-end between the SSH client and the remote sshd.

Private tunnel dialing is available on Pro, Enterprise, and self-hosted deployments. When SSH is not required, WebTTY can address the same general access problem on all plans through either the browser interface or the CLI workflow.

The walkthrough begins with the minimal project preparation required on both machines. Background on installation, authentication, and context management is covered in Installation, CLI Login, and CLI Workflow.

Prepare access to the project

For this walkthrough, both machines need an rstream CLI configuration that resolves to the same project. For a quick hosted validation, the simplest preparation on each machine is:

rstream login
rstream project use <project-endpoint> --default

That setup is convenient for an initial test. For long-lived servers and devices, a dedicated project-scoped context is generally preferable so the machine only holds the project-scoped access it actually needs.

Validate the SSH path on one machine

On the remote machine, open a private bytestream tunnel to the local SSH daemon:

rstream forward 22 --bytestream --no-publish --name ssh-server

This command keeps the process attached to the terminal and forwards the private tunnel to 127.0.0.1:22. The name ssh-server is only an example. Any stable tunnel name can be used, and that name becomes the identifier dialed later by the SSH client.

On the client machine, validate the path with an SSH command that delegates transport to rstream nc:

ssh -o 'ProxyCommand rstream nc rstrm://ssh-server' admin@ssh-server "printf 'hello world\n'"

This command asks ssh to delegate transport to rstream nc through ProxyCommand. In this example, ssh-server is the tunnel name and admin is the SSH account on the remote machine; both should be adjusted to match the environment. When the command starts, ssh launches the helper locally and connects its input and output streams to that process. rstream nc dials rstrm://ssh-server, relays the resulting byte stream back to ssh, and ssh continues the handshake on top of that transport exactly as if it had opened a direct TCP connection itself. The trailing printf command keeps the first check short and confirms immediately that the path works. Removing the trailing command opens a regular interactive shell instead:

ssh -o 'ProxyCommand rstream nc rstrm://ssh-server' admin@ssh-server

The first connection still performs normal SSH host key verification. SSH continues to verify the remote host identity itself, and the session remains encrypted end-to-end between the local SSH client and the remote daemon.

Use SSH config for regular access

After the initial validation, add a host entry to the SSH client configuration. This file is usually ~/.ssh/config on Linux and macOS, and %USERPROFILE%\.ssh\config on Windows:

Host ssh-server
  HostName ssh-server
  User admin
  ProxyCommand rstream nc rstrm://%h

The operational command then becomes a normal SSH invocation:

ssh ssh-server

The %h token expands to the SSH host name. Keeping the SSH host name and the tunnel name identical makes the configuration compact and predictable. Replace admin with the actual SSH user on the remote machine. When the SSH host name and the tunnel name differ, replace %h with a fixed tunnel name such as rstrm://prod-admin-01. If a specific SSH key is required, add the usual IdentityFile directive. If the local SSH alias must differ from the remote host identity, HostKeyAlias can be used exactly as it would in a direct SSH deployment.

Organize discovery with names and labels

With more than one machine, names and labels quickly become part of day-to-day operations. A convention such as ssh-homelab-01, ssh-prod-01, or ssh-router-01 keeps the same identifier usable in the tunnel list, in ssh, and in scripts. Labels remain entirely operator-defined. The example below uses service, env, and role, but the actual taxonomy should follow the environment and automation model already in use:

rstream forward 22 \
  --bytestream --no-publish --name ssh-prod-01 \
  --label service=ssh --label env=prod \
  --label role=admin

The tunnel list then becomes an operational inventory. A broad view of the fleet can be obtained with:

rstream tunnel list --filter 'labels.service=ssh'

And a narrower view can be derived from the same label set:

rstream tunnel list --filter 'labels.service=ssh,labels.env=prod' -o json

The table output is useful for quick inspection. JSON output is more suitable when labels are consumed by scripts or operational tooling.

Generalize the client configuration to a fleet

Once tunnel names follow a consistent convention, the client-side SSH configuration can be generalized with a single block:

Host ssh-*
  User admin
  ProxyCommand rstream nc rstrm://%h

The same SSH client can then reach multiple machines without adding one stanza per server:

ssh ssh-homelab-01
ssh ssh-prod-01

This pattern works when the SSH host name and the tunnel name are aligned. When different groups require different usernames, keys, or local policies, narrower Host blocks can be layered on top of the same model. The single-machine form remains useful for exceptions, while the wildcard form scales better for a fleet.

Move the remote side from forward to run

rstream forward defines the tunnel directly on the command line and keeps it online while the process runs. That makes it well suited to interactive setup, quick validation, and situations where the output is primarily meant for a human operator. rstream run reads the tunnel definition from a file, which is easier to reuse in scripts, service managers, and repeatable machine setup.

On Linux and macOS, the default CLI state lives under ~/.rstream, so keeping the SSH tunnel file there avoids scattering operational files. Create ~/.rstream/ssh-tunnels.yaml on the remote machine:

mkdir -p ~/.rstream
cat > ~/.rstream/ssh-tunnels.yaml <<'EOF'
version: 1
tunnels:
  - name: ssh-server
    forward: 127.0.0.1:22
    tunnel:
      publish: false
      type: bytestream
      labels:
        service: ssh
        env: prod
        role: admin
EOF

The same setup can then be started manually with:

rstream run --apply ~/.rstream/ssh-tunnels.yaml

On Windows, the same file can be stored under the current user profile:

New-Item -ItemType Directory -Force "$env:USERPROFILE\.rstream" | Out-Null
@'
version: 1
tunnels:
  - name: ssh-server
    forward: 127.0.0.1:22
    tunnel:
      publish: false
      type: bytestream
      labels:
        service: ssh
        env: prod
        role: admin
'@ | Set-Content "$env:USERPROFILE\.rstream\ssh-tunnels.yaml"

And started manually with:

rstream run --apply "$env:USERPROFILE\.rstream\ssh-tunnels.yaml"

The declarative tunnel model is covered in YAML.

Start the tunnel automatically

The startup mechanism depends on the operating system and on whether the tunnel must start after user login or at machine boot without an interactive session. On Linux, the user-scoped systemd service below can stay active across logout and reboot once linger is enabled. On macOS, the example uses a LaunchAgent and starts when that user logs in. On Windows, the scheduled task below starts when that user signs in. If the tunnel must start at machine boot without an interactive login, use a system-level service model instead.

Linux

On Linux, the most direct option is a user-scoped systemd service. This keeps the service tied to the same user account that already owns ~/.rstream/config.yaml and ~/.rstream/ssh-tunnels.yaml:

RSTREAM_BIN="$(command -v rstream)"
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/rstream-ssh.service <<EOF
[Unit]
Description=rstream SSH tunnel
After=network-online.target
Wants=network-online.target
 
[Service]
Type=simple
ExecStart=${RSTREAM_BIN} run --apply ${HOME}/.rstream/ssh-tunnels.yaml
Restart=always
RestartSec=5
 
[Install]
WantedBy=default.target
EOF

Enable it with:

systemctl --user daemon-reload
systemctl --user enable --now rstream-ssh.service
loginctl enable-linger "$(id -un)"

loginctl enable-linger allows the user service to stay active after logout and across reboots.

macOS

On macOS, a per-user LaunchAgent matches the same user-owned CLI setup as ~/.rstream. It starts when that user logs in rather than as a system daemon at machine boot:

RSTREAM_BIN="$(command -v rstream)"
mkdir -p ~/Library/LaunchAgents
cat > ~/Library/LaunchAgents/io.rstream.ssh.plist <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>io.rstream.ssh</string>
    <key>ProgramArguments</key>
    <array>
      <string>${RSTREAM_BIN}</string>
      <string>run</string>
      <string>--apply</string>
      <string>${HOME}/.rstream/ssh-tunnels.yaml</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
  </dict>
</plist>
EOF
launchctl bootout "gui/$(id -u)" ~/Library/LaunchAgents/io.rstream.ssh.plist 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/io.rstream.ssh.plist
launchctl kickstart -k "gui/$(id -u)/io.rstream.ssh"

Windows

On Windows, a scheduled task tied to the current user matches the same user-scoped setup while reusing %USERPROFILE%\.rstream. This example starts the tunnel when that user signs in. If the tunnel must start at machine boot without an interactive sign-in, use a task principal that can run without an interactive session, or switch to a system-level service model.

$tunnels = "$env:USERPROFILE\.rstream\ssh-tunnels.yaml"
$rstream = (Get-Command rstream).Source
$action = New-ScheduledTaskAction -Execute $rstream -Argument "run --apply `"$tunnels`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
Register-ScheduledTask `
  -TaskName "rstream SSH Tunnel" `
  -Action $action `
  -Trigger $trigger `
  -User $env:USERNAME `
  -Force
Start-ScheduledTask -TaskName "rstream SSH Tunnel"

Published TLS tunnels with standard client tools

An alternative is to publish the SSH tunnel as a TLS bytestream endpoint and let clients connect with a standard TLS-capable helper such as ncat, rather than rstream nc. This can be useful when client workstations should rely on standard client tools and should not require an rstream CLI configuration.

For direct operator SSH access, the standard and recommended workflow for this use case remains the private unpublished bytestream tunnel shown above. It avoids publishing an edge entrypoint, keeps access tied to authenticated rstream clients, and removes the need to resolve a public tunnel address before connecting.

On the remote machine, the published TLS variant can be created with:

rstream forward 22 \
  --bytestream --publish \
  --tls --tls-mode terminated \
  --name ssh-published \
  --label service=ssh --label env=prod

The client side cannot dial this variant by tunnel name alone. It first needs to resolve the current published address from tunnel inventory or the API, since that edge address is dynamically allocated by the rstream edge network.

That resolution step can be handled through the CLI or through the API, depending on how client access is distributed in the environment. From the CLI, resolve the published address with:

rstream tunnel list --filter 'name=ssh-published,labels.service=ssh' -o json | jq -r '.[0].host'

For a TLS tunnel, the host field already contains both the domain and the port.

Use that domain and port in the SSH command:

ssh -o 'ProxyCommand ncat --ssl %h %p' -p <published-port> admin@<published-host>

Replace <published-host> and <published-port> with the domain and port returned in host, and replace admin with the SSH account that exists on the remote machine.

Once the published address has been resolved, the same pattern can be moved into SSH config:

Host ssh-published
  HostName <published-host>
  Port <published-port>
  User admin
  ProxyCommand ncat --ssl %h %p

SSH itself continues to behave the same way. rstream does not terminate SSH, so host key verification, SSH user authentication, and session encryption remain end-to-end between the SSH client and the remote sshd.

The difference is where exposure and access control sit. With the private unpublished workflow, there is no public edge address and only authenticated rstream clients can dial the tunnel. With the published TLS workflow, the edge address is public, the SSH service can receive public connection attempts through that entrypoint, and the remote sshd remains responsible for SSH authentication and authorization.

At the same time, the published TLS workflow can enforce controls at the edge before traffic reaches SSH. On terminated TLS tunnels, that can include mTLS, GeoIP restrictions, and trusted IP ranges. Those controls are useful when standard client tools are required, but for direct SSH access to remote machines the better default remains the private unpublished workflow described throughout the rest of this guide.

Operational notes

This guide starts with a single machine and then extends the same pattern to a fleet. The underlying model stays the same. Each machine gets one private bytestream tunnel, the SSH client remains standard, and rstream nc provides the transport bridge behind ProxyCommand.

For long-running hosts, rstream run behind the native service manager is usually more appropriate, while rstream forward remains useful for initial setup, incident response, and temporary maintenance windows.

SSH keys, host keys, local account policies, and server-side hardening continue to apply exactly as before. rstream provides the private transport path and tunnel access layer without requiring port 22 to be published publicly.

Taken together, these pieces show that rstream tunnels remain useful even for protocols that are not exposed as first-class public endpoints. SSH is the example covered here, but the same reasoning applies whenever a client can delegate transport to a helper command, a proxy hook, or another byte-stream bridge. In that context, rstream nc provides the missing link between an existing client workflow and a private tunnel, while the remote side can still be managed with the same forward and run patterns used elsewhere in rstream.