cablehead/pai-sho

Forward ports between your own machines, peer to peer over iroh. No account, no public IP, no open inbound port.

87

stars

89

commits

Rust

primary language

Sep 10, 2026

updated

cli
iroh
nat-traversal
networking
p2p
port-forwarding
quic
remote-access
rust
tun
tunneling

README

A forest spirit placing a tile on a pai sho board

pai-sho

Forward ports between your own machines, peer to peer over iroh.
Neither side needs an account, a public IP, or an open inbound port.
A port is reachable only by the peers you grant it to.

CI Crates Discord

Example scenarios

A shared build box

A team runs a long-lived build box. Its dashboard is on localhost:8080. The box invites your laptop and grants the port in the same command:

# build box
pai-sho invite --expose 8080
# 5hc4bjqfp6...7fd25613dd...   one-time, valid 5 minutes

Paste the invitation on your laptop, and give the box a name:

# laptop
pai-sho accept 5hc4bjqfp6...7fd25613dd... --as buildbox
curl http://buildbox.pai-sho:8080

A laptop boots a VM

You boot a dedicated VM per task, a vibenv, with no inbound ports. This time the laptop does the inviting, and names the VM before it exists:

pai-sho invite --as vibenv-ndyg
# 5hc4bjqfp6...7fd25613dd...   one-time, valid 5 minutes

The VM runs an http-nu app on localhost:3001 and stellar on localhost:7331. Its daemon takes the invitation up on startup and exposes both:

pai-sho daemon --accept 5hc4bjqfp6...7fd25613dd... -e 3001,7331

As soon as it connects, the VM gets an address on your laptop's private network, and its two ports are bound there under the name you chose. Only your laptop is admitted; a dial from any other key is refused.

curl http://vibenv-ndyg.pai-sho:3001
open http://vibenv-ndyg.pai-sho:7331

If you close the laptop and reopen it, the connection comes back and the ports rebind.

docs/scenarios.md works both through in full.

Install

cargo install pai-sho
brew install cablehead/tap/pai-sho
eget cablehead/pai-sho

Or grab a binary from releases.

Homebrew also ships a launchd service that creates the private network and points the system at the .pai-sho resolver. It does not start on its own; see Setting up the network.

Setting up the network

--tun puts each peer on a private 10.99.0.0/16 network. The daemon sits at 10.99.0.1, peers land on 10.99.1.x, and the daemon's resolver answers *.pai-sho at 10.99.0.53.

macOS

Homebrew ships a supervised launch. Trust the tap as your user (once), then start the service:

brew trust cablehead/tap
sudo --preserve-env=XDG_CONFIG_HOME brew services start pai-sho

brew trust records trust for your user. sudo brew services is the one root use Homebrew allows; it loads a launchd service and runs no build scripts. sudo brew trust and sudo brew install are refused. --preserve-env=XDG_CONFIG_HOME matters only if you set XDG_CONFIG_HOME: plain sudo strips it, so brew looks for your trust file under $HOME/.homebrew and refuses the tap.

The service creates the utun, points the system at the .pai-sho resolver, and hands you the control socket, so the CLI needs no sudo:

pai-sho key

To run it by hand instead of under the supervisor:

sudo pai-sho daemon --tun utun --socket-owner "$(stat -f%Su /dev/console)"
echo "nameserver 10.99.0.53" | sudo tee /etc/resolver/pai-sho

Linux

Create the interface ahead of time and hand it to the daemon's user, so the daemon itself runs unprivileged:

sudo ip tuntap add dev ps0 mode tun user "$USER"
sudo ip addr add 10.99.0.1/16 dev ps0
sudo ip link set ps0 up
pai-sho daemon --tun ps0

Then send .pai-sho to 10.99.0.53, for example with a dnsmasq server=/pai-sho/10.99.0.53 forward.

Without --tun, peers are bound at loopback addresses (127.0.1.x) and you serve the resolver yourself with --resolver <addr>. Names still resolve, to those addresses.

Usage

pai-sho [--socket <path>] <command>

Commands

CommandDescription
daemon [options]Start the daemon
keyPrint this daemon's key (hand this to a peer)
invite [<key>] [--as <name>] [--expose <port>...]Extend an invitation. With a key, to that key alone (host-attested, no secret). Without one, print a one-time invitation valid 5 minutes.
accept <invite|key> [--as <name>]Take up an invitation, or reach a peer by key
forget <peer>Forget a peer: close it, unbind its ports, revoke its grants
expose <port> (--to <key> | --all)Grant a local port to named peers. Nothing is reachable without a grant
unexpose <port> [--to <key>]Revoke grants for a port, or just one peer's
project <peer> [--ip <addr>] [--as <name>]Bind a peer's ports at a local address
unproject <peer>Take a peer's surface down (unbind its ports)
listPeers, grants, and where their ports are bound (JSON)

--socket is global, not specific to daemon.

Daemon Options

OptionDefaultDescription
--host127.0.0.1Address to forward exposed ports to
-a, --acceptTake up an invitation, or a peer's key, on startup (repeatable)
-e, --exposeExpose port to the --accept peers (repeat or comma-separate)
--key~/.local/state/pai-sho/keySecret key path (created if missing)
--tunPut surfaces on a private TUN network (utun on macOS, a pre-created device like ps0 on Linux); the resolver answers at 10.99.0.53:53
--resolverLoopback mode, an alternative to --tun: serve the *.pai-sho resolver on this UDP address (e.g. 127.0.0.1:5353)
--nameThis node's own name. The resolver answers <name>.pai-sho with --host, so a service reached locally and from a peer has one origin, which is what CORS needs
--socket-ownerUsername to own the control socket, chowned right after bind. Lets the CLI skip sudo when the daemon runs as root
--socket-modeOctal mode for the control socket, e.g. 660

How it works

Identity. Each daemon has a stable key: an iroh endpoint ID, backed by a keypair stored at --key. Because it never changes, whatever boots your VMs can hand each one your laptop's key ahead of time.

Grants. A port is reachable only by the peers a grant names, and is served to them alone. A grant names a key, and iroh proves the connecting peer holds that key, so a peer cannot pass its access on to another machine (ADR 0001).

Invitations. A connection from an unknown key is refused unless it carries a code from invite. The code is spent on use, and the peer it admitted is remembered across restarts. An invitation is <key>.<code>: the key says who to dial, the code admits you. When you already know a peer's key, invite <key> authorizes it with no secret created at all (ADR 0006, ADR 0003).

Connecting. Peers dial by public key over iroh. It punches through NAT, so neither side needs an open inbound port or a public IP. When it can't punch through, an n0 relay forwards the traffic without being able to read it.

Forwarding. Each peer hears only the ports granted to it, and traffic runs over the encrypted QUIC connection. It goes both ways: something on your own :4001 becomes reachable on a peer with pai-sho expose 4001 --to <key>, where the key comes from pai-sho key on the machine you are granting to.

The network. With --tun, the daemon runs its own TCP/IP stack on a private network interface. The daemon sits at 10.99.0.1, peers get addresses on 10.99.1.x, and the resolver answers at 10.99.0.53:53. On Linux the interface is created ahead of time and owned by the daemon's user, so the daemon needs no elevated capability. On macOS the daemon creates a utun itself, which needs root.

Surfaces. A peer's ports all live at one local address, under the name you gave it, or the first eight characters of its key if you gave none. That address with its ports is the peer's surface. It comes up by itself the first time the peer announces a granted port. Each peer has its own address, so two peers can both serve 8080. project overrides the defaults (pin an address with --ip, rename with --as), unproject takes a surface down, and projections survive a restart (ADR 0004).

Resolver. The daemon answers <name>.pai-sho from the live surface table, so vibenv-ndyg.pai-sho reaches that peer's ports and stops resolving when the peer goes away. It is authoritative for the one suffix and never touches the rest of your DNS. Point the OS at it for .pai-sho only: /etc/resolver/pai-sho on macOS, a dnsmasq server=/pai-sho/10.99.0.53 forward on Linux (ADR 0005).

Reconnection. If the connection drops, both sides retry with exponential backoff. Projected surfaces stay put and rebind when the link returns.

Structure. The decisions (who may connect, which grants exist, whether a tunnel is allowed) live in src/core/, which does no IO and is unit tested without a network. peer.rs feeds it events and carries out the actions it returns (ADR 0007).

The invariants in docs/scenarios.md are the five things that hold no matter which commands you run.

See also

ngrok and Cloudflare Tunnel are great when you need a public URL anyone can reach. pai-sho is for connecting your own machines, or sending an invitation to a friend so they can see something you're working on.

SSH tunnels need inbound access on at least one side. pai-sho works when neither machine has open inbound ports.

WireGuard only goes direct: a peer entry in the config file needs an Endpoint with a routable address, and there is no hole punching or relay to fall back to. If both machines are behind NAT you need a bounce host. Tailscale adds that machinery around WireGuard; pai-sho gets it from iroh.

dumbpipe is the direct inspiration. pigeons, SSH over iroh from the same team, is where pai-sho's connection handling comes from.

Why not Tailscale?

You probably should use Tailscale. It solves this problem well, and there is a company behind it.

No account

The connection machinery is the same. Servers negotiate the initial connection, then hole punching gets a direct path. When it can't, a relay carries the traffic: DERP for Tailscale, iroh's relays for pai-sho, run by n0. That whole layer comes from iroh. Tailscale has one more layer above it, the top row here:

Tailscale
  box ------->  controlplane.tailscale.com  <------- laptop   membership
  box <~ ~ ~ ~  derp*.tailscale.com         ~ ~ ~ ~> laptop   negotiate, relay
  box <--------------------------------------------> laptop   direct

pai-sho
  box <~ ~ ~ ~  *.relay.iroh.network        ~ ~ ~ ~> laptop   negotiate, relay
  box <--------------------------------------------> laptop   direct

A Tailscale node registers with the coordination server, which decides membership and hands it a filtered list of the peers it may see. A pai-sho box dials your laptop by public key, resolved by iroh's address lookup. Address lookup only answers where a key is reachable; it cannot add a peer to your set, and it needs no sign-up.

One port at a time

Tailscale gives a peer an IP, and everything listening on it is reachable unless an ACL says otherwise. pai-sho starts with nothing reachable, and you grant one port at a time to one key. Day to day the two feel much the same, since you type a name and a port either way.

Less to install

Without --tun, pai-sho binds loopback addresses. On Linux that needs no network device and no privilege, because 127.0.0.0/8 already routes to lo. Tailscale needs a tun device, or its userspace mode, which gives you a proxy rather than real listeners. --tun puts pai-sho in the same position, so this only holds on loopback.

Tailscale's ops story is much nicer

Tailscale has one policy file for the whole tailnet, so you can read who can reach what in one place. In pai-sho that information is spread across whichever invite and expose commands ran on which machine, and list shows one daemon's view of it; nothing yet shows the whole picture.

More

docs/scenarios.md works two flows end to end: a shared build box reached from a laptop, and a laptop booting a vibenv. Each says what has to be true and what travels between the machines, and why the commands took the shape they did.

The ADRs record the decisions and which ones superseded which: directed grants, two passes at enrollment before invitations landed, peer surfaces, the owned resolver, and the pure core.

Questions or ideas: come by the Discord.

Contributors

cablehead

89 commits

cablehead/pai-sho

Forward ports between your own machines, peer to peer over iroh. No account, no public IP, no open inbound port.

87

stars

89

commits

Rust

primary language

Sep 10, 2026

updated

cli
iroh
nat-traversal
networking
p2p
port-forwarding
quic
remote-access
rust
tun
tunneling

README

A forest spirit placing a tile on a pai sho board

pai-sho

Forward ports between your own machines, peer to peer over iroh.
Neither side needs an account, a public IP, or an open inbound port.
A port is reachable only by the peers you grant it to.

CI Crates Discord

Example scenarios

A shared build box

A team runs a long-lived build box. Its dashboard is on localhost:8080. The box invites your laptop and grants the port in the same command:

# build box
pai-sho invite --expose 8080
# 5hc4bjqfp6...7fd25613dd...   one-time, valid 5 minutes

Paste the invitation on your laptop, and give the box a name:

# laptop
pai-sho accept 5hc4bjqfp6...7fd25613dd... --as buildbox
curl http://buildbox.pai-sho:8080

A laptop boots a VM

You boot a dedicated VM per task, a vibenv, with no inbound ports. This time the laptop does the inviting, and names the VM before it exists:

pai-sho invite --as vibenv-ndyg
# 5hc4bjqfp6...7fd25613dd...   one-time, valid 5 minutes

The VM runs an http-nu app on localhost:3001 and stellar on localhost:7331. Its daemon takes the invitation up on startup and exposes both:

pai-sho daemon --accept 5hc4bjqfp6...7fd25613dd... -e 3001,7331

As soon as it connects, the VM gets an address on your laptop's private network, and its two ports are bound there under the name you chose. Only your laptop is admitted; a dial from any other key is refused.

curl http://vibenv-ndyg.pai-sho:3001
open http://vibenv-ndyg.pai-sho:7331

If you close the laptop and reopen it, the connection comes back and the ports rebind.

docs/scenarios.md works both through in full.

Install

cargo install pai-sho
brew install cablehead/tap/pai-sho
eget cablehead/pai-sho

Or grab a binary from releases.

Homebrew also ships a launchd service that creates the private network and points the system at the .pai-sho resolver. It does not start on its own; see Setting up the network.

Setting up the network

--tun puts each peer on a private 10.99.0.0/16 network. The daemon sits at 10.99.0.1, peers land on 10.99.1.x, and the daemon's resolver answers *.pai-sho at 10.99.0.53.

macOS

Homebrew ships a supervised launch. Trust the tap as your user (once), then start the service:

brew trust cablehead/tap
sudo --preserve-env=XDG_CONFIG_HOME brew services start pai-sho

brew trust records trust for your user. sudo brew services is the one root use Homebrew allows; it loads a launchd service and runs no build scripts. sudo brew trust and sudo brew install are refused. --preserve-env=XDG_CONFIG_HOME matters only if you set XDG_CONFIG_HOME: plain sudo strips it, so brew looks for your trust file under $HOME/.homebrew and refuses the tap.

The service creates the utun, points the system at the .pai-sho resolver, and hands you the control socket, so the CLI needs no sudo:

pai-sho key

To run it by hand instead of under the supervisor:

sudo pai-sho daemon --tun utun --socket-owner "$(stat -f%Su /dev/console)"
echo "nameserver 10.99.0.53" | sudo tee /etc/resolver/pai-sho

Linux

Create the interface ahead of time and hand it to the daemon's user, so the daemon itself runs unprivileged:

sudo ip tuntap add dev ps0 mode tun user "$USER"
sudo ip addr add 10.99.0.1/16 dev ps0
sudo ip link set ps0 up
pai-sho daemon --tun ps0

Then send .pai-sho to 10.99.0.53, for example with a dnsmasq server=/pai-sho/10.99.0.53 forward.

Without --tun, peers are bound at loopback addresses (127.0.1.x) and you serve the resolver yourself with --resolver <addr>. Names still resolve, to those addresses.

Usage

pai-sho [--socket <path>] <command>

Commands

CommandDescription
daemon [options]Start the daemon
keyPrint this daemon's key (hand this to a peer)
invite [<key>] [--as <name>] [--expose <port>...]Extend an invitation. With a key, to that key alone (host-attested, no secret). Without one, print a one-time invitation valid 5 minutes.
accept <invite|key> [--as <name>]Take up an invitation, or reach a peer by key
forget <peer>Forget a peer: close it, unbind its ports, revoke its grants
expose <port> (--to <key> | --all)Grant a local port to named peers. Nothing is reachable without a grant
unexpose <port> [--to <key>]Revoke grants for a port, or just one peer's
project <peer> [--ip <addr>] [--as <name>]Bind a peer's ports at a local address
unproject <peer>Take a peer's surface down (unbind its ports)
listPeers, grants, and where their ports are bound (JSON)

--socket is global, not specific to daemon.

Daemon Options

OptionDefaultDescription
--host127.0.0.1Address to forward exposed ports to
-a, --acceptTake up an invitation, or a peer's key, on startup (repeatable)
-e, --exposeExpose port to the --accept peers (repeat or comma-separate)
--key~/.local/state/pai-sho/keySecret key path (created if missing)
--tunPut surfaces on a private TUN network (utun on macOS, a pre-created device like ps0 on Linux); the resolver answers at 10.99.0.53:53
--resolverLoopback mode, an alternative to --tun: serve the *.pai-sho resolver on this UDP address (e.g. 127.0.0.1:5353)
--nameThis node's own name. The resolver answers <name>.pai-sho with --host, so a service reached locally and from a peer has one origin, which is what CORS needs
--socket-ownerUsername to own the control socket, chowned right after bind. Lets the CLI skip sudo when the daemon runs as root
--socket-modeOctal mode for the control socket, e.g. 660

How it works

Identity. Each daemon has a stable key: an iroh endpoint ID, backed by a keypair stored at --key. Because it never changes, whatever boots your VMs can hand each one your laptop's key ahead of time.

Grants. A port is reachable only by the peers a grant names, and is served to them alone. A grant names a key, and iroh proves the connecting peer holds that key, so a peer cannot pass its access on to another machine (ADR 0001).

Invitations. A connection from an unknown key is refused unless it carries a code from invite. The code is spent on use, and the peer it admitted is remembered across restarts. An invitation is <key>.<code>: the key says who to dial, the code admits you. When you already know a peer's key, invite <key> authorizes it with no secret created at all (ADR 0006, ADR 0003).

Connecting. Peers dial by public key over iroh. It punches through NAT, so neither side needs an open inbound port or a public IP. When it can't punch through, an n0 relay forwards the traffic without being able to read it.

Forwarding. Each peer hears only the ports granted to it, and traffic runs over the encrypted QUIC connection. It goes both ways: something on your own :4001 becomes reachable on a peer with pai-sho expose 4001 --to <key>, where the key comes from pai-sho key on the machine you are granting to.

The network. With --tun, the daemon runs its own TCP/IP stack on a private network interface. The daemon sits at 10.99.0.1, peers get addresses on 10.99.1.x, and the resolver answers at 10.99.0.53:53. On Linux the interface is created ahead of time and owned by the daemon's user, so the daemon needs no elevated capability. On macOS the daemon creates a utun itself, which needs root.

Surfaces. A peer's ports all live at one local address, under the name you gave it, or the first eight characters of its key if you gave none. That address with its ports is the peer's surface. It comes up by itself the first time the peer announces a granted port. Each peer has its own address, so two peers can both serve 8080. project overrides the defaults (pin an address with --ip, rename with --as), unproject takes a surface down, and projections survive a restart (ADR 0004).

Resolver. The daemon answers <name>.pai-sho from the live surface table, so vibenv-ndyg.pai-sho reaches that peer's ports and stops resolving when the peer goes away. It is authoritative for the one suffix and never touches the rest of your DNS. Point the OS at it for .pai-sho only: /etc/resolver/pai-sho on macOS, a dnsmasq server=/pai-sho/10.99.0.53 forward on Linux (ADR 0005).

Reconnection. If the connection drops, both sides retry with exponential backoff. Projected surfaces stay put and rebind when the link returns.

Structure. The decisions (who may connect, which grants exist, whether a tunnel is allowed) live in src/core/, which does no IO and is unit tested without a network. peer.rs feeds it events and carries out the actions it returns (ADR 0007).

The invariants in docs/scenarios.md are the five things that hold no matter which commands you run.

See also

ngrok and Cloudflare Tunnel are great when you need a public URL anyone can reach. pai-sho is for connecting your own machines, or sending an invitation to a friend so they can see something you're working on.

SSH tunnels need inbound access on at least one side. pai-sho works when neither machine has open inbound ports.

WireGuard only goes direct: a peer entry in the config file needs an Endpoint with a routable address, and there is no hole punching or relay to fall back to. If both machines are behind NAT you need a bounce host. Tailscale adds that machinery around WireGuard; pai-sho gets it from iroh.

dumbpipe is the direct inspiration. pigeons, SSH over iroh from the same team, is where pai-sho's connection handling comes from.

Why not Tailscale?

You probably should use Tailscale. It solves this problem well, and there is a company behind it.

No account

The connection machinery is the same. Servers negotiate the initial connection, then hole punching gets a direct path. When it can't, a relay carries the traffic: DERP for Tailscale, iroh's relays for pai-sho, run by n0. That whole layer comes from iroh. Tailscale has one more layer above it, the top row here:

Tailscale
  box ------->  controlplane.tailscale.com  <------- laptop   membership
  box <~ ~ ~ ~  derp*.tailscale.com         ~ ~ ~ ~> laptop   negotiate, relay
  box <--------------------------------------------> laptop   direct

pai-sho
  box <~ ~ ~ ~  *.relay.iroh.network        ~ ~ ~ ~> laptop   negotiate, relay
  box <--------------------------------------------> laptop   direct

A Tailscale node registers with the coordination server, which decides membership and hands it a filtered list of the peers it may see. A pai-sho box dials your laptop by public key, resolved by iroh's address lookup. Address lookup only answers where a key is reachable; it cannot add a peer to your set, and it needs no sign-up.

One port at a time

Tailscale gives a peer an IP, and everything listening on it is reachable unless an ACL says otherwise. pai-sho starts with nothing reachable, and you grant one port at a time to one key. Day to day the two feel much the same, since you type a name and a port either way.

Less to install

Without --tun, pai-sho binds loopback addresses. On Linux that needs no network device and no privilege, because 127.0.0.0/8 already routes to lo. Tailscale needs a tun device, or its userspace mode, which gives you a proxy rather than real listeners. --tun puts pai-sho in the same position, so this only holds on loopback.

Tailscale's ops story is much nicer

Tailscale has one policy file for the whole tailnet, so you can read who can reach what in one place. In pai-sho that information is spread across whichever invite and expose commands ran on which machine, and list shows one daemon's view of it; nothing yet shows the whole picture.

More

docs/scenarios.md works two flows end to end: a shared build box reached from a laptop, and a laptop booting a vibenv. Each says what has to be true and what travels between the machines, and why the commands took the shape they did.

The ADRs record the decisions and which ones superseded which: directed grants, two passes at enrollment before invitations landed, peer surfaces, the owned resolver, and the pure core.

Questions or ideas: come by the Discord.

Contributors

cablehead

89 commits

Languages

Rust

96.8%

Go

3.2%