Spin up a box in the middle of nowhere, with no way in. Drop one binary on it.
No open ports, no public IP: it dials home and punches through.
Reach your boxes from your laptop, each under its own name like vibenv-ndyg.pai-sho.
pai-sho forwards specific TCP ports between your machines over an encrypted peer-to-peer QUIC connection, built on iroh. Neither machine needs an open inbound port, a public IP, or a relay you run. iroh handles discovery, NAT traversal, and relay fallback.
Access is default deny and per peer. Each machine runs one long-lived daemon with a stable identity, a keypair. You grant a specific port to a specific peer's key, and that peer alone can reach it. A machine you have not met enrolls with a one-time token, so you can boot a fleet of untrusted workloads that phone home, each with exactly the access you granted and none aware of its siblings.
The peers you can reach live on a private network the daemon runs for you. Each
one gets its own address on that network and a name to match, so you reach its
ports at peer.pai-sho:<port>. Two peers can serve the same port without
clashing, because each has its own address. None of it is published to the rest
of your machine or the rest of DNS.
The case it was built for is a dedicated VM per task, a
vibenv, with no inbound ports. Boot it,
it dials your laptop, and the ports you care about (say a web app and a
live-reload server) come up at vibenv-ndyg.pai-sho:3001 and vibenv-ndyg.pai-sho:7331, reachable
by you alone.
On my laptop the daemon is already running on its own network interface (the Homebrew install sets that up; Setting up the network covers doing it by hand). I print its ticket and mint a one-time token for the VM I'm about to boot:
pai-sho ticket
# 5hc4bjqfp6booceusm3jrfebbegyfi6aiqwbgx4xxqmpvg5usoyq
pai-sho grant-token --label vibenv-ndyg
# 7fd25613dd5e17cb... (one-time, valid 5 minutes)The VM runs an http-nu app on :3001 and
stellar on :7331 for live CSS editing.
Its daemon dials home and exposes both ports to my laptop:
pai-sho daemon -a 5hc4bjqfp6booceusm3jrfebbegyfi6aiqwbgx4xxqmpvg5usoyq \
-e 3001,7331 --enroll 7fd25613dd5e17cb...The VM enrolls under the label vibenv-ndyg, and only my laptop can reach it. Anyone else
who dials the VM is refused.
On enrollment the VM is projected onto my network on its own: it gets an address
like 10.99.1.2, and its ports bind there under the name vibenv-ndyg. Both answer by
name, with no manual step:
curl http://vibenv-ndyg.pai-sho:3001
open http://vibenv-ndyg.pai-sho:7331Spin up something new on the VM and expose it live:
http-nu :3002 -c '{|req| "hello from a new experiment"}'
pai-sho expose 3002vibenv-ndyg is already on my network, so 3002 binds under it too, reachable at
http://vibenv-ndyg.pai-sho:3002 right away. Done with it? pai-sho unexpose 3002.
Close the laptop and reopen it: the connection restores on its own, the surface rebinds, and no new token is needed.
cargo install pai-shobrew install cablehead/tap/pai-shoeget cablehead/pai-shoOr grab a binary from releases.
Homebrew also ships a supervised launch for the operator: 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.
--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 in-stack on 10.99.0.53.
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-shobrew trust records trust for your user. sudo brew services is the one root
use Homebrew allows (it loads a launchd service, runs no build scripts); sudo brew trust and sudo brew install are refused, and that refusal is correct.
--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
instead of your real config home and refuses the tap. Preserving it points brew
back where brew trust wrote. (Harmless if you don't set XDG_CONFIG_HOME.)
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 ticketTo 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-shoCreate 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 ps0Then send .pai-sho to 10.99.0.53, for example with a dnsmasq
server=/pai-sho/10.99.0.53 forward.
Without --tun, surfaces fall back to loopback addresses (127.0.1.x) and you
serve the resolver with --resolver <addr>. You lose the private network but keep
the names.
pai-sho [--socket <path>] <command>
daemon [options] Start the daemon
ticket Print the daemon's ticket
grant-token --label <l> Mint a one-time enrollment token (valid 5 min)
pin <key> --label <l> Enroll a peer by key, no token (host-attested)
add-peer <ticket> Connect to a peer
remove-peer <ticket> Disconnect from a peer (and drop its pin)
expose <port> [--to <key>] Grant a local port to peers (default: all known)
unexpose <port> [--to <k>] Revoke grants for a port (or one peer's grant)
project <peer> [--ip <a>] [--as <name>] Bind a peer's ports at a local address
unproject <peer> Take a peer's surface down (unbind its ports)
surfaces Show each peer and its projection (JSON)
list Show peers, grants, and bindings (JSON)
| Option | Default | Description |
|---|---|---|
--host |
127.0.0.1 |
Address to forward exposed ports to |
-a, --add |
Add peer on startup (repeatable) | |
-e, --expose |
Expose port to the -a peers (repeat or comma-separate) |
|
--enroll |
One-time token to present to the -a peers |
|
--key |
~/.local/state/pai-sho/key |
Secret key path (created if missing) |
--socket |
/tmp/pai-sho.sock |
Unix socket path |
--tun |
Put surfaces on a private TUN network (utun on macOS, a pre-created device like ps0 on Linux); the resolver answers in-stack on 10.99.0.53:53 |
|
--resolver |
Loopback mode, an alternative to --tun: serve the *.pai-sho resolver on this UDP address (e.g. 127.0.0.1:5353) |
Identity. Each daemon has a stable ticket, an iroh endpoint ID backed by a
keypair at --key. Because it does not change, a launcher can bake one operator
ticket into every workload it boots.
Grants. Access is default deny. A port becomes reachable only through a grant that names the peers allowed to reach it, and is served to them alone. iroh proves the connecting peer's key cryptographically, so a grant names a proven identity, not a shareable address. You cannot hand out reach by leaking a string (ADR 0001).
Enrollment. A connection from an unknown key is refused unless it carries a
one-time token from grant-token. A valid token pins the peer's key under the
token's label and is then spent. Pins survive restarts, so a reboot does not
orphan enrolled workloads (ADR 0002). When you
already know a peer's key, pin does the same without a token
(ADR 0003).
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.
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 in-stack on 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 are addressed together at one address, named after
its enrollment label. A peer is projected automatically the first time it
announces a granted port. Because each peer owns its address, two peers can serve
the same port without colliding. project overrides the automatic choice (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.
ngrok and Cloudflare Tunnel are great when you need a public URL anyone can reach. pai-sho is for connecting your own machines, or sharing a ticket with 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, Tailscale, and NetBird are mesh VPNs that put every machine on a virtual network. pai-sho is narrower: you expose specific ports, not the whole machine, which keeps it easy to reason about exactly what is reachable.
dumbpipe is the direct inspiration. pigeons, SSH over iroh from the same team, is where pai-sho's connection handling comes from.
Questions or ideas: come by the Discord.
