Minimal Rust SSH jump server for exposing an SSH server behind NAT and reaching
it through standard OpenSSH ProxyJump.
You can use the public service directly by SSHing into pipa.sh. The relay
prints the next step when you register a host, when you publish it, and when a
new client lands on the service.
The relay never starts a shell and never dials arbitrary TCP destinations. It exists only to connect a public SSH client to a live SSH server somewhere else.
There are three normal steps:
- SSH into
pipa.shto get a hostname and publish token. - Run the printed reverse SSH command on the machine behind NAT.
- Connect as a client with normal SSH.
ssh pipa.shThe relay allocates a random hostname under *.pipa.sh and prints the exact
publish command for the next step.
Example:
Your relay hostname is ready:
x7k2m4q9pa.pipa.sh
Run this on the machine that has the SSH server you want to publish:
ssh -R 22:localhost:22 fz6rvtz2w6aj76my5gjzqqum@pipa.sh
Run the printed command on the machine that has the SSH server you want to expose:
ssh -R 22:localhost:22 <token>@pipa.shIf the session stays open, the hostname is live. The relay prints a short status message and then one line per client connection.
If you want this to persist in the background, see
examples/systemd/pipa-publisher.service.
After the publisher is live, clients connect normally:
ssh <hostname>.pipa.shThe first time a client hits the service directly, it prints the ProxyJump
setup it expects:
Host *.pipa.sh
HostName %h
ProxyJump pipa.shAfter that first setup, the only command most users need to remember is
ssh <hostname>.pipa.sh.
You can point your own hostname at a published *.pipa.sh hostname with a
CNAME:
ssh.example.com. 300 IN CNAME x7k2m4q9pa.pipa.sh.
The relay follows CNAMEs and routes only if the final hostname is a registered, live publisher. The same rule applies to publisher authorization: the publisher may use either the registered hostname or a CNAME that terminates there.
Client config for a custom hostname:
Host ssh.example.com
HostName %h
ProxyJump pipa.shThe CNAME chain limit defaults to 8 and can be changed with --cname-depth.
- No shell, PTY, exec, SFTP, or agent forwarding on the relay.
- Normal shell attempts on
pipa.shallocate a random hostname, print a publish command, and close. - Client-setup sessions print usage directions and close.
- Registrations are persisted in SQLite.
- Publishing is authorized by the generated bearer token.
- Client routing is limited to port
22. - Client routing is limited to registered hostnames under the relay namespace.
- Client routing stays live only while the matching publisher session is live.
- Per-publisher tunnel limits are enforced in process.
This is an SSH relay for hosts that are intentionally public. The published SSH server remains responsible for host authentication, user authentication, authorization, logging, and account policy.
Relay authentication is intentionally minimal in this prototype. Registration
and client-setup sessions may authenticate with SSH none, password,
keyboard-interactive, or public key. Those methods are only used to get a user
far enough to print instructions or allocate a hostname.
Publishing uses a generated bearer capability, not a normal account login. The token itself is the SSH username, and possession of that token is sufficient to publish the registered hostname. Treat the full publish command as a secret.
Defaults:
- Relay hostname:
pipa.sh - Published hostnames:
<random>.pipa.sh - SQLite database:
pipa.sqlite3 - Relay host key:
pipa_host_ed25519_key
Relevant options:
--relay-hostname pipa.sh
--database ./pipa.sqlite3
--host-key ./pipa_host_ed25519_key--relay-hostname controls both the relay login hostname and the suffix used
for allocated hostnames. --host-key points at the relay SSH host key; the
server loads it if present or generates an Ed25519 key if it does not exist.
In deployment, point pipa.sh at the relay listener IP and point *.pipa.sh
at the usage listener IP. During routing, the relay only cares about the final
hostname after following any CNAMEs.
This workspace uses current stable Rust for dependency compatibility:
cargo build
cargo testRun locally:
cargo run -- \
--relay-listen 127.0.0.1:2222 \
--usage-listen 127.0.0.1:2223 \
--relay-hostname pipa.sh \
--max-tunnels-per-publisher 10 \
--database ./pipa.sqlite3 \
--host-key ./pipa_host_ed25519_keyBind both IPv4 and IPv6 addresses by repeating the flag:
cargo run -- \
--relay-listen 203.0.113.10:22 \
--relay-listen '[2001:db8::10]:22' \
--usage-listen 203.0.113.11:22 \
--usage-listen '[2001:db8::11]:22' \
--relay-hostname pipa.shDefault in-process limit:
--max-tunnels-per-publisher 10
The server does not enforce a global connection cap, global tunnel cap, or
bandwidth throttle. Handle those with firewall, traffic control, load balancer,
or host-level policy. Set a high file descriptor limit for production, for
example systemd LimitNOFILE=1048576.
For structured logs:
RUST_LOG=pipa=debug,russh=warn cargo run -- --json-logsEach successful bridged tunnel emits a tunnel connected log event with
client_ip, client_peer, publisher_ip, publisher_peer,
requested_hostname, and registered_hostname.
Two example units are included:
examples/systemd/pipa.service: hardened server-side unit for the relay hostexamples/systemd/pipa-publisher.service: user-level unit for keeping a publish session alive on the publishing machine
Install the binary and unit:
make
sudo make install
sudo install -o root -g root -m 0644 examples/systemd/pipa.service /etc/systemd/system/pipa.service
sudo systemctl daemon-reloadBefore starting it, edit the unit and replace the example listener addresses. The listener env vars accept comma-separated address lists:
Environment=JUMPSRV_RELAY_LISTEN=203.0.113.10:22,[2001:db8::10]:22
Environment=JUMPSRV_USAGE_LISTEN=203.0.113.11:22,[2001:db8::11]:22The unit runs with DynamicUser=true, stores state under /var/lib/pipa,
grants only CAP_NET_BIND_SERVICE for binding port 22, and uses a strict
filesystem, kernel, and device sandbox.
The example publisher unit is meant for systemd --user, not root. Install it
on the machine that is publishing its local SSH server:
mkdir -p ~/.config/systemd/user
cp examples/systemd/pipa-publisher.service ~/.config/systemd/user/
systemctl --user daemon-reloadEdit the unit and replace PIPA_PUBLISH_TOKEN=replace-me with the token from
the registration step. Then enable it:
systemctl --user enable --now pipa-publisher.serviceIf you want the publisher to stay up without an active login session, enable lingering for that user:
loginctl enable-linger <user>russh handles SSH protocol framing, authentication, channels, and flow
control. The pipa application adds policy, registration, and routing:
- A user connects to the relay listener, normally
pipa.sh. - The server allocates a random hostname under the relay hostname and stores it in SQLite.
- The registration output includes a bearer-token publish command.
- The publisher requests remote forwarding for port
22. - The server validates the hostname, port, and token ownership, then stores the active route in memory.
- A client connects through
ProxyJump. - If necessary, the relay resolves CNAMEs until it reaches the registered hostname.
- The relay opens a
forwarded-tcpipchannel back to the publisher and bridges traffic in both directions. - When the publisher disconnects, the active route is removed immediately. The registration remains in SQLite.
Code layout:
src/app.rs: process bootstrap, logging, and listener startupsrc/ssh.rs: relay and usage SSH servers plus channel bridgingsrc/registry.rs: SQLite registrations and in-memory active route trackingsrc/dns.rs: CNAME following and registered-host resolutionsrc/messages.rs: user-facing textsrc/util.rs: shared constants and helperssrc/host_key.rs: persistent SSH host-key loading and generationsrc/tests.rs: core unit and regression tests
Automated:
cargo fmt --check
cargo test
cargo clippy --all-targets -- -D warningsLocal integration smoke test:
./scripts/smoke-local.shThe smoke test expects:
ssh,ssh-keyscan, andtimeout- a reachable SSH server on
localhost:22 - localhost SSH auth configured for your user or agent
The GitHub Actions workflow provisions a temporary local sshd before running
the smoke test. For local runs, provide your own SSH server on localhost:22.