Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

jaunt lets you sit at any of your own machines and open a terminal on any other one. Type jaunt toluene and you are in a tmux session on the machine called toluene. The connection is a direct, encrypted link between the two machines when one can be made, and goes through a relay you run when it cannot. Leave and come back later: the session lives in tmux on the far machine and is where you left it.

jaunt with no arguments: the live mesh. A solid green line is a direct path, a dotted amber one goes through the relay. The picture is jaunt demo.

Overview

  • One static binary per machine, in ~/.local/bin. No root, no accounts, no system VPN device, no third-party service.
  • A small background service on each machine (the daemon) holds that machine’s own key. Every other jaunt command is a thin client that talks to it over a private Unix socket.
  • The network and the encryption come from the iroh library, not from jaunt: QUIC with TLS 1.3, dialling a machine by its public key, punching a direct path through home routers, and falling back to a relay. jaunt writes no transport and no cryptography of its own.
  • jaunt adds the linking ritual (a short spoken code, then a picture and three words to compare), the shared member list and revocation, the hop commands, the mesh view, and a guard that keeps a laptop awake with the lid closed only while that is safe.
  • The relay is one small program, iroh-relay, that you host. It introduces your machines to each other and carries traffic when no direct path exists. It sees only encrypted data and can never add a machine.
 your terminal                                               the other machine
 jaunt toluene ── daemon ════ QUIC + TLS 1.3 ════ daemon ── tmux session
                     │   direct when possible        │
                     └──── else through the relay ───┘

The machine names in this manual (benzene, toluene, xylene, phenol, styrene) are the demo machines.

The source is at github.com/ZahakJ/jaunt. MIT licensed.

Install

jaunt is one static binary. Installing it means putting that file in ~/.local/bin on each machine you want to link. Nothing else is written unless you ask for it, and nothing needs root.

The one-line install

curl -fsSL https://raw.githubusercontent.com/ZahakJ/jaunt/main/install.sh | sh

Run it as the user who will use jaunt, not as root; the script refuses to run as root. It does this, in order:

  1. Picks the release asset for this machine. Only Linux on x86_64 has one (jaunt-x86_64-linux-musl); on anything else it stops and tells you to build from source.
  2. Downloads the binary and its .sha256 file from the latest GitHub release, over HTTPS only (it refuses plain http://, including a redirect to it).
  3. Checks the binary against the checksum, runs it once with --version to make sure it works on this machine, and writes it to ~/.local/bin/jaunt. The write is a copy into the same directory followed by a rename, so a daemon that is already running keeps its old file until it restarts.
  4. Writes the machine name and the relay into jaunt’s config file, if you gave --name or --relay. Values already in the config are kept.
  5. Tells you whether ~/.local/bin is on your PATH, and prints the next two commands. It starts nothing unless you pass --up.

The script is POSIX sh, and everything in it sits inside one function that runs on the last line, so a download cut off half way runs nothing.

Releases are not signed; the checksum catches a corrupted download.

Passing options through the one-liner

To give the script options when it arrives through a pipe, pass them after sh -s --:

curl -fsSL https://raw.githubusercontent.com/ZahakJ/jaunt/main/install.sh | sh -s -- --name toluene --relay https://relay.example.net

or download it first and run it as a file:

sh install.sh --relay https://relay.example.net --name toluene

Every option

These come from the header of install.sh; sh install.sh --help prints the same list.

OptionDoes
--version TAGinstall this release, for example v0.1.2, instead of the latest
--relay URLwrite the relay URL into jaunt’s config (https://host[:port][/])
--name NAMEwrite this machine’s name into jaunt’s config: lowercase letters, digits and -, at most 32 characters, not starting or ending with -
--upalso install and start the user service, as jaunt up does
--no-servicedo not suggest the service; the daemon then starts on first use. Cannot be combined with --up
--from-file FILEinstall this binary instead of downloading; FILE.sha256 is checked if it is next to it
--bin-dir DIRwhere to put the binary (default ~/.local/bin)

Two environment variables change where the download comes from:

VariableDoes
JAUNT_REPOthe GitHub repository as owner/name (default ZahakJ/jaunt)
JAUNT_BASE_URLanother download base. It must be https:// or file://

The script also reads JAUNT_BIN_DIR as the default for --bin-dir. If you point JAUNT_REPO at a private fork, the script falls back to gh release download when the plain download answers 404; for the public repository the GitHub CLI is not needed.

The config file it writes is ~/.config/jaunt/config.toml (or $XDG_CONFIG_HOME/jaunt/config.toml), created with mode 0600 in a private directory.

Installing without the network: --from-file

On a machine with no internet, or when you want to check the file yourself first, copy the binary over and install it from disk:

sh install.sh --from-file ./jaunt-x86_64-linux-musl --name xylene

If jaunt-x86_64-linux-musl.sha256 sits next to it, the checksum is checked. If not, the script installs the binary unchecked and says so.

Building from source

Every platform other than Linux x86_64 builds from source, with a Rust toolchain:

git clone https://github.com/ZahakJ/jaunt
cd jaunt
cargo build --release
sh install.sh --from-file target/release/jaunt

cargo test runs the unit tests. The release binary itself is built by scripts/build-release.sh in a throwaway debian:12 container, as a static musl binary of about 10.7 MB.

Supported platforms

PlatformStatus
Linux x86_64release binary
other Linuxbuild from source
macOSbuild from source; host mode is limited (see Host mode)

The machine you hop into should have tmux. Without it a hop gets a plain shell that ends when the link drops; jaunt check warns about this.

Demo

jaunt demo runs the interface on made-up machines:

jaunt demo               # the live mesh view on made-up machines
jaunt demo ls            # the same machines as jaunt ls prints them
jaunt demo link          # the pairing screens, inviting side
jaunt demo link --join   # the pairing screens, joining side

No daemon, network or relay is involved. Press q to leave the mesh view. Colour follows your terminal: NO_COLOR or --no-color turns it off, and JAUNT_ASCII=1 swaps the box-drawing glyphs for plain ASCII.

Next steps

On the first machine:

jaunt relay set https://relay.example.net   # once, on the first machine
jaunt up                                     # install and start the user service
jaunt link                                   # shows a code for the next machine

The relay comes first; its chapter is The relay. Then Pairing.

jaunt up installs a systemd user service (~/.config/systemd/user/jaunt.service) so the daemon starts at login and survives logout. It uses KillMode=process, so restarting jaunt never kills your tmux sessions. Without the service, any jaunt command starts the daemon by itself when it needs it, and says so. jaunt down stops it.

Uninstall

jaunt uninstall

This stops the service or the daemon, removes the unit, the config, the state and runtime directories, and the binary (only ~/.local/bin/jaunt, and only if it is the binary that is running). It asks before deleting the key and the member list; --yes deletes them without asking, --keep-key keeps them without asking. Your tmux sessions are left alone.

Uninstalling does not tell the other machines. To revoke this one, run from another machine in the mesh:

jaunt unlink toluene

To take a machine out of the mesh in one step while it is still running, use jaunt leave instead (see Pairing).

Pairing

Linking adds a machine to the mesh, once per machine, with both in front of you: one shows a short code, you type the code on the other, both screens show the same small picture and three words, you confirm on both, and the new machine is a member. Every other machine in the mesh learns about it by itself.

The relay comes first

Linking goes through the relay, so the mesh needs one first: see The relay. To try jaunt on one computer, Try it on one machine runs a local test relay.

The first machine

On the first machine, say the desktop benzene:

jaunt relay set https://relay.example.net
jaunt up
jaunt link
  • jaunt relay set URL stores the relay for the whole mesh. Every machine that links later receives it with the member list, so you set it once. jaunt relay with no argument shows the current one. A second URL (jaunt relay set URL1 URL2) lists two relays. --ca FILE trusts a relay certificate signed by your own certificate authority.
  • jaunt up installs and starts the background service.
  • jaunt link opens the pairing screen and shows a code like 4127-tremor-kudos. Leave it running.

The new machine

On the new machine, say the laptop toluene, after installing jaunt:

jaunt up
jaunt join --relay https://relay.example.net

jaunt join asks for the code; type it. Spaces instead of dashes are fine. The --relay flag is needed only because this machine knows no relay yet; the JAUNT_RELAY environment variable or install.sh --relay URL does the same.

Type the code when asked rather than putting it on the command line: a command line can be read by every other user of the machine while the join runs. The command-line form still works, with a warning.

The sigil and the three words

When the code is right, both screens show the same small symmetric picture (the sigil) and the same three words, and ask whether they match.

Look at both screens. If the picture and all three words are the same, press y on both. The screens then tick through the last steps, and the inviting side tells the rest of the mesh about the new machine.

If they differ, press n. Nothing is changed on either machine. Different pictures mean the two machines do not share a key. Run jaunt link again for a fresh code; if it happens again, do not link over that relay.

Compare the words, not only the picture: they are the harder part to forge.

To stop linking at any point, press Ctrl-c, or n at the question.

How it works

The construction is magic-wormhole’s, from standard parts.

  1. The code is a nameplate (4 random digits) and two words from the EFF large wordlist, drawn from the operating system’s random source. The digits are not secret; the words carry about 25.8 bits of secret.
  2. Meeting. The inviting machine opens a temporary endpoint whose key is derived from the nameplate alone. The joining machine derives the same address and dials it through the relay. Anyone who guesses the nameplate can reach that endpoint, and all it gets them is one guess at the next step.
  3. SPAKE2, a password-authenticated key exchange, runs with the whole code as the password. Someone in the middle gets exactly one guess: 1 in 60 million. A wrong guess burns the code. Codes expire after 5 minutes and work once.
  4. Sealed exchange. From the shared key, each side derives an encryption key, then sends its real id and name with a signature proving it holds the key it claims.
  5. Human check. The sigil and words are computed from the shared key, so both screens agree only when both sides hold the same key.
  6. Only after both confirmations does the inviting machine send the roster and the relay list, and the new machine writes them.
  7. A joining machine tries each code once, whatever went wrong.

The full description is §6 of docs/design.md in the repository, and the Security model lists what pairing defends against.

When linking fails

What you seeWhat to do
jaunt join reports wrong code; that code is burnt now, ask for a new one (jaunt link), and jaunt link says someone tried a wrong code; the code is burnta typo, or someone else guessed. Run jaunt link again for a new code
jaunt link says the code expired before anyone used it (codes last 5 minutes); run 'jaunt link' againas it says
jaunt join says this code was already tried from this machine and is not tried twice; ask for a new oneeach code is tried once per joining machine; get a new one
jaunt join says no relay is known here; give the one the other machine uses: jaunt join --relay URLthis machine has no relay yet
jaunt join says no inviting machine answered within ... s; is 'jaunt link' still showing this code, and do both machines use the same relay?check that jaunt link is still open (an expired code closes it) and that both sides name the same relay

More in Troubleshooting.

More machines

Each further machine joins the same way, from any machine already in the mesh, not only the first. Run jaunt link there and jaunt join on the new one. The new machine appears on every other member without you touching them.

Trust is flat: every member can link new machines and revoke others.

Names

A machine’s name defaults to its hostname in lower case. install.sh --name sets it at install time, and

jaunt name xylene

renames this machine later; the others see the new name. Names are lowercase letters, digits and -, at most 32 characters.

Removing machines

CommandRun it onDoes
jaunt unlink tolueneany other memberrevokes toluene: it is refused everywhere from now on, its connections close and the commands it was running end. Its tmux sessions are kept
jaunt unlink toluene --kill-sessionsany other memberthe same, and ends the tmux sessions toluene created, here and on every machine that confirms
jaunt unlink toluene --cascadeany other memberalso revokes every machine toluene linked
jaunt leavethe machine itselftakes this machine out of the mesh, tells the others, and deletes its key and roster
jaunt resetthe machine itselfforgets this machine’s identity and roster and starts with a new key; link it again afterwards

Revocation is permanent: a revoked key can never come back. A laptop you got back is jaunt reset and linked again under a new key. unlink prints which machines confirmed and which have not heard yet; a machine that has not heard still accepts the revoked one until it syncs with one that has. The whole story, including what revocation does not undo, is in Revoking a lost laptop.

Hopping

A hop is a terminal on another machine. Everything in this chapter assumes at least two linked machines; the examples sit at benzene and hop to toluene.

The mesh view: jaunt

jaunt with no arguments, on a terminal, opens the live mesh view: this machine on the left, a line to every other member, and an event log underneath. The style of a line is its path: solid green for direct, dotted amber through the relay, dim for offline. Each line carries the latency of the last minute as a small sparkline.

KeyDoes
arrows, or j / kchoose a machine
Enterhop to it
llink a new machine
?help
qquit

When the output is not a terminal (piped into another command), jaunt behaves like jaunt ls.

Hop: jaunt toluene

jaunt toluene

This opens the tmux session called jaunt on toluene, creating it the first time. A one-line banner says how you got there, for example benzene ━━▸ toluene · direct · 0.8 ms, and you are in.

  • Same session every time. Leave and come back, and you are on the same screen with the same programs running. The session lives in tmux on toluene; it outlives the connection, the client and the daemon.
  • Other sessions. jaunt toluene:work opens or creates a session called work, separate from the default one.
  • Prefixes. Any unambiguous start of a name works: jaunt tol is jaunt toluene. If a prefix matches two machines, jaunt lists them and asks you to type more of the name.
  • jaunt hop toluene is the same command spelled out.

A hop needs a terminal. To run something from a script, use jaunt run.

The other commands

CommandDoes
jaunt lsevery machine: direct, relayed or offline, latency, host state, sessions
jaunt ls --jsonthe same as JSON. Piped, plain jaunt ls prints tab-separated columns: name, state, latency, host, sessions, id, with this machine first as self
jaunt run toluene -- uname -arun one command on toluene; its output and exit status come back
jaunt cp notes.txt toluene:docs/copy a file or a directory. Either side can be machine:path; remote paths are relative to the home directory there
jaunt fwd toluene 8080make toluene’s 127.0.0.1:8080 available here at 127.0.0.1:8080, until Ctrl-C
jaunt fwd toluene 8080:9090the same, but here at port 9090
jaunt pipe toluene [port]connect stdin and stdout to a port on toluene’s loopback (default 22)

fwd reaches only toluene’s loopback. The local end listens on 127.0.0.1, so every user of this machine can use the port while it runs, as with ssh -L. Held forwards, reverse forwards, a SOCKS proxy and jaunt open: Ports, tabs and the clipboard.

jaunt pipe carries OpenSSH. Where sshd listens on loopback, this in ~/.ssh/config sends ssh, scp, rsync and git through jaunt, with SSH’s key check as a second layer:

Host benzene toluene xylene phenol
    ProxyCommand jaunt pipe %h

The Host names must be jaunt machine names.

Onward hops

Every machine is a full member, so you can hop from where you are. Inside a hop on toluene, jaunt xylene goes on from toluene to xylene. The shell in a hop has two variables set:

VariableHolds
JAUNT_FROMthe machine you came from
JAUNT_MACHINEthe machine you are on

Getting out and back

The escape keys are read by jaunt itself, before any tmux sees them, so they always work. They start with Enter, as in OpenSSH.

You want toDoSession on the other machine
leave a hopEnter, then ~, then .kept
see the escape keysEnter, then ~, then ?
type a literal ~ at the start of a lineEnter, then ~, then ~
detach from the remote tmux (not nested)Ctrl-b, then dkept
go backjaunt toluene (same session, same screen)
use a second session therejaunt toluene:workseparate session
end the session for goodtype exit in the remote shellended
quit the mesh viewq
stop linkingCtrl-c (or n at the question)

On the way out a banner says where you are again: ◂━━ back on benzene · session kept on toluene.

A change of network (Wi-Fi to a phone hotspot) is absorbed by QUIC: the connection moves to the new path, and typing resumes within about half a second. Nothing on your screen changes except the path in jaunt ls.

When the connection is really lost (the lid closed, the Wi-Fi gone), jaunt keeps your terminal and shows one status line at the bottom:

toluene ╌ link lost · retrying in 4s · ~. to give up

It tries again after 1 second, then 2, 4, 8, and every 10 seconds after that, and re-attaches by itself when toluene is back; tmux then repaints the screen as it was. ~. gives up at any time, and the session stays on toluene.

There is a second, quieter line. If you type and nothing at all (no output, no acknowledgement, no keep-alive) has come back from toluene for 3 seconds, the bottom row says:

toluene ╌ no answer for 5s · waiting for the link · ~. to give up

It disappears as soon as output arrives. A program that is simply quiet on a live link, such as a password prompt, does not trigger it.

Without tmux on the far machine, a hop is a plain login shell, and it ends when the link drops. jaunt check on that machine warns about it.

Using jaunt from tmux

A hop from inside tmux works without setup. The snippet below adds a popup picker that opens each machine in its own window, and a key that hands your keys to the tmux on the other machine.

The tmux snippet

jaunt tmux-setup

then add this one line to your ~/.tmux.conf and reload tmux:

source-file ~/.config/jaunt/jaunt.tmux
KeyDoes
prefix J (default prefix is Ctrl-b, so Ctrl-b then Shift-j)the mesh view in a popup. Enter opens the hop in a new window named after the machine, so each machine gets its own window
F12hand your keys to the tmux on the other machine. The status bar shows F12 keys to remote while this is on. Press F12 again to get them back

Nothing else is bound; your prefix and Ctrl-h/j/k/l are untouched.

Nested tmux

If you hop from inside tmux, there are now two tmux programs on your screen: yours (the outer one) and the one on the other machine (inside the window). Your prefix key (Ctrl-b) always goes to the outer one first.

  • To talk to the remote tmux: press F12 once (with the snippet), then use Ctrl-b as usual; press F12 again to come back. Without the snippet: press the prefix twice (Ctrl-b Ctrl-b) then the key, as usual in tmux.
  • To detach from the remote tmux: F12, then Ctrl-b d. You are back in your outer tmux, and the remote session stays.
  • When in doubt, Enter ~ . always works: it is read by jaunt itself, not by either tmux.

Ports, tabs and the clipboard

What reaches past the terminal: web ports, browser tabs, links, the clipboard and notifications. The examples sit at benzene; toluene is the other machine.

Web ports

jaunt ports toluene           # what listens on toluene's loopback
jaunt open toluene:7707       # forward it and open it in the browser here

jaunt ports lists every port that toluene’s loopback reaches, with the program and its working directory:

 7707  node edgewake
 5432  postgres
 8000  python3 site

On a terminal each row has a key; press it to forward that port and open it in the browser. Piped, it prints tab-separated columns: port, bind address, program, directory. --json gives the full list.

jaunt open toluene:7707/admin forwards toluene’s port 7707 to the same port here (or any free one, if that is taken), opens http://127.0.0.1:7707/admin, and returns. The forward is held by the background service until jaunt unfwd toluene:7707 or the service stops; --keep keeps it across restarts.

Forwards

CommandDoes
jaunt fwd toluene 8080toluene’s 127.0.0.1:8080 here at 127.0.0.1:8080, until Ctrl-C
jaunt fwd toluene 8080:9090the same, here at port 9090
jaunt fwd toluene 5432 --keep --name dbheld by the service, kept in config.toml, back after every restart
jaunt unfwd dbstop a held forward, by name or local port
jaunt rfwd toluene 3000the reverse, like ssh -R: toluene’s 127.0.0.1:3000 reaches port 3000 here, until Ctrl-C
jaunt socks toluene [1080]a SOCKS5 proxy here whose connections leave from toluene

Held forwards show in jaunt ls with their open connections and last error. Each connection dials afresh, so a held forward never needs restarting: it works again as soon as toluene is reachable, after sleep, a new network or a restart on either side. Connections that are open while a laptop roams carry on (the lab test moves a download across two network changes).

jaunt socks resolves names on toluene, so socks5h:// reaches toluene’s LAN by name:

jaunt socks toluene 1080 &
curl --proxy socks5h://127.0.0.1:1080 http://router.lan/
chromium --proxy-server=socks5://127.0.0.1:1080

Browser tabs

jaunt tabs toluene            # pick some of toluene's open tabs; Enter opens them here
jaunt tabs toluene --all      # open every one
jaunt tabs toluene --list     # print them: browser, title, URL
jaunt send-tab toluene URL... # open URLs in toluene's browser

In the picker, type to filter, Tab marks a tab, Ctrl-A marks every tab shown, Enter opens what is marked (or the tab under the cursor).

Tabs are read from the session files the browsers keep on disk, never from the running browser: Firefox, LibreWolf, Waterfox, Zen and Floorp (recovery.jsonlz4); Chromium, Chrome, Brave, Edge and Vivaldi (Session_*); native, Flatpak and Snap installs, Linux and macOS. A browser writes this file every few seconds, so a tab opened a moment ago may be missing. Files untouched for two weeks are skipped. Only http and https tabs are listed.

In a session jaunt starts, a program that opens a web link opens it in the browser of the machine you hopped in from: gh auth login, a dev server’s --open, Python’s webbrowser, xdg-open https://…. This works through BROWSER and an xdg-open placed first on the session’s PATH; links still open on toluene when nobody is hopped in.

Like an ssh login, these sessions see no display on toluene (DISPLAY and WAYLAND_DISPLAY are empty), so nothing opens a window on a screen nobody is watching. Sessions created before an upgrade keep their old environment; bridge_urls = false in toluene’s config.toml turns both off.

jaunt send-tab URL with no machine sends to the machine hopped in here.

Clipboard

jaunt clip on                 # on each machine that should share
jaunt clip off
jaunt clip get toluene        # print toluene's clipboard
jaunt clip set toluene < f    # replace it

With sharing on at both ends, text copied on one machine is on the clipboard of the other within two seconds, in both directions, on every linked machine that shares and is online. Only text is shared, up to 512 KiB. A copy that a password manager marks as secret (KeePassXC, Bitwarden and others set a hint for this) stays where it is. A machine that does not share refuses get and set.

Linux needs wl-clipboard on Wayland, or xclip or xsel on X11; macOS has what it needs.

Notifications

make; jaunt notify build done       # on toluene, in a hop: shows on benzene
jaunt notify --to toluene "lunch"   # on a named machine
jaunt run --notify toluene -- make  # here, when the command ends: how and after how long

jaunt notify with no --to goes to the machine hopped in here. The notification is titled with the sending machine’s name. For every long command, in ~/.zshrc on the far machine:

autoload -Uz add-zsh-hook
_jt_start() { _jt=$SECONDS }
_jt_done() { (( ${_jt:-$SECONDS} + 30 < SECONDS )) && jaunt notify "$(fc -ln -1)"; unset _jt }
[[ -n $JAUNT_FROM ]] && { add-zsh-hook preexec _jt_start; add-zsh-hook precmd _jt_done }

Trust

Every linked machine can already run any command on every other one (jaunt run), so none of this gives a machine a power it lacked. What each tool does, and who can ask for it:

ToolThe far machineThe local end
portsreads its own socket table; names only this user’s processesprints
tabsreads session files, never writes; lists http/https onlyopens what you choose
send-tab, linksopens only http/https URLs, never file: or other schemes
notifyshows text, titled with the sender’s name, control characters removed
cliprefuses unless its owner ran jaunt clip on
fwd, openconnects to its own loopbacklistens on 127.0.0.1 only
rfwdlistens on its own 127.0.0.1 only, while the command runs
socksconnects where it can reachlistens on 127.0.0.1 only

Only linked machines can ask, over the same authenticated connection as a hop, and every request shows in the event log. A local end on 127.0.0.1 can be used by every user of the machine while it is open, as with ssh -L; on a shared machine forward only what anyone there may reach.

Host mode

jaunt host on asks this machine’s jaunt daemon to keep it awake and reachable when you close the lid, only while it is on mains power and not too hot. jaunt host off undoes it. The setting is saved in config.toml ([host] enabled = true).

jaunt host on        # start holding the lid lock (when it is safe)
jaunt host status    # what is true now, what closing the lid would do, what is missing
jaunt host off       # release; the lid works as before
jaunt ls             # every machine's host state: awake / will sleep on lid close / on battery / too hot

What it does

The daemon takes a systemd-logind inhibitor lock, handle-lid-switch:sleep in block mode. With it held, logind does nothing when the lid closes, and systemctl suspend (from an idle daemon, a key binding, or someone in a remote shell) is refused. It never takes an idle lock, so the screen still locks and blanks as usual.

The guard checks every 5 seconds:

ConditionWhat happens
on mains, every temperature below the limitthe lock is held
on battery for 10 s (a shorter cable wiggle is ignored)the lock is released
on mains but the battery has drained for 60 s (a weak USB-C charger)treated as battery
a CPU sensor at ≥ 90 °C for 120 s, or any sensor within 5 °C of its critical pointthe lock is released, and it stays released until the lid is opened
power or temperature cannot be read, a sensor read hangs for 2 s, or the CPU sensors that were there stop answeringthe lock is not held: unknown means “sleep”
released because of the power (battery, weak charger)taken again only once mains has been steady for a while (20 s to 2 min, depending on how long it was off)
released while the lid is closed (and no dock or second display)logind normally suspends at once; if the machine is still awake 5 s later, jaunt runs systemctl suspend itself, and again a minute later if needed
the daemon crashes, is killed, or hangsthe lock is gone (see below), so logind suspends a closed laptop

Durations are leaky buckets: time in the condition adds up, time out of it pays back at half speed.

The lock is held by a systemd-inhibit child that waits on a pipe from the daemon. If the daemon exits in any way, the pipe closes and the child exits within milliseconds, so logind drops the lock.

If something hangs instead: sensor reads run on a helper thread with a 2 s deadline, and a reading that does not come back counts as unknown, which releases the lock. If the guard thread itself stops ticking for 25 s, the daemon kills the lock holder by its process id (with or without systemd), jaunt host status says the guard is not answering instead of showing the last state, and under systemd the daemon also stops sending the watchdog keep-alive (WatchdogSec=30), so systemd restarts it.

Mains power is detected the way systemd does it. Batteries in mice and keyboards are ignored, and USB-C ports that are giving power away do not count as mains. Temperatures come from /sys/class/thermal and /sys/class/hwmon. The limits can be changed in config.toml:

[host]
enabled = true
hot_c = 90          # sustained limit, °C (50-110)
hot_secs = 120      # how long it must last (10-600)
crit_margin_c = 5   # trip at once this close to a sensor's critical point (0-30)

Power

The lock is never held on battery. jaunt does not manage displays: turn the internal display off on lid close in your compositor, e.g. Hyprland’s bindl = , switch:on:Lid Switch, exec, hyprctl keyword monitor "eDP-1, disable".

What it needs

  • systemd-logind, and a local login since boot. logind gives the lid lock to the user’s background service only while that user has a local graphical session. After a reboot with nobody logged in, the lock is refused. jaunt host status and jaunt check say so, and the guard tries again every minute. You can close this gap once, as root, with scripts/root-extras/host-polkit-rule.sh.
  • The daemon must run as the user service (jaunt up). A daemon started from an SSH login belongs to that remote session, and logind refuses it.

Optional root extras (scripts/root-extras/)

Each script prints exactly what it will change and how to undo it, and asks you to type yes. Each has an undo mode. jaunt never runs them.

ScriptChangesUndo
host-polkit-rule.sh install <user>writes /etc/polkit-1/rules.d/49-jaunt-host.rules: lets that user take the lid and sleep locks without a local loginhost-polkit-rule.sh undo
logind-lid-on-ac.sh installwrites a logind drop-in, HandleLidSwitchExternalPower=ignore, and reloads logind. No heat protectionlogind-lid-on-ac.sh undo
wake-on-lan.sh install <connection>sets NetworkManager wake-on-LAN/WLAN to magic for that connectionwake-on-lan.sh undo <connection>

Waking a sleeping laptop is not part of jaunt. A magic packet works only from another machine on the same LAN, and WoL must also be enabled in the firmware setup.

macOS

A Mac cannot keep a closed lid awake without root (pmset disablesleep) or Apple’s clamshell conditions (power, an external display and a keyboard or mouse). On macOS jaunt host on only holds caffeinate -s (prevents system sleep on AC, tied to the daemon’s pid) while the lid is open, and says so: “closed-lid hosting needs root on macOS; holding caffeinate -s while the lid is open”.

The relay

A mesh uses one relay: iroh-relay 1.3.0, unmodified. It introduces machines, tells a machine behind NAT its public address so two machines can punch a direct path, and carries traffic when no direct path exists. It sees which ids are online, from which addresses, who talks to whom and how much; it cannot read or alter traffic and cannot join the mesh.

PortFor
443/tcpHTTPS + WebSocket at /relay, with its own certificate
7842/udpQUIC address discovery. Without it, machines behind NAT never go direct
80/tcpLet’s Encrypt HTTP challenge and /generate_204 (not needed with a manual certificate)

On the first machine: jaunt relay set https://relay.example.net. Other machines get it through the roster, or from install.sh --relay URL / jaunt join --relay URL. jaunt check reports whether the relay answers and whether UDP address discovery works. Changing relay later is jaunt relay set NEW_URL; keep the old one running until every machine has picked it up.

Files: Dockerfile (builds iroh-relay 1.3.0 in debian:12, runs it in debian:12-slim as an unprivileged user), relay.toml (Let’s Encrypt, address discovery on), fly.toml, desktop-tunnel.sh.

WhereCostDirect paths across networks
a small VPSa few dollars a monthyes
a home machine with 443/tcp and 7842/udp forwardedfreeyes; the home address is public
a home machine behind a Cloudflare tunnelfreeno; everything away from home is relayed
Fly.iodedicated IPv4 is paidno, as shipped

VPS

Any Linux VPS with a public IPv4 address.

  1. Point a DNS name at it (A, plus AAAA with IPv6). With Cloudflare DNS, set the record to “DNS only”, not proxied.

  2. Open 80/tcp, 443/tcp and 7842/udp.

  3. Copy this directory over, set hostname and contact in relay.toml, then:

    docker build -t jaunt-relay .
    docker volume create jaunt-relay-certs
    docker run -d --name jaunt-relay --restart unless-stopped \
      -p 80:80/tcp -p 443:443/tcp -p 7842:7842/udp \
      -v jaunt-relay-certs:/var/lib/iroh-relay jaunt-relay
    curl -fsS https://relay.example.net/ping
    

    Without Docker: cargo install iroh-relay@1.3.0 --features server --locked, the same config, and a systemd unit with AmbientCapabilities=CAP_NET_BIND_SERVICE.

Home machine, ports forwarded

The VPS setup on an always-on home machine. Needs a public IPv4 address (not carrier-grade NAT), forwarding of 80/tcp, 443/tcp and 7842/udp, and a DNS name kept pointing at the home address. Machines at home reach the relay through the public address, so the router must support hairpin NAT.

Home machine, Cloudflare tunnel

The relay runs in plain HTTP on loopback and an existing Cloudflare tunnel carries it. A Cloudflare hostname carries no UDP, so address discovery fails and sessions between machines on different networks are relayed through Cloudflare and the home machine.

sh deploy/relay/desktop-tunnel.sh install      # user service on 127.0.0.1:3340; `undo` removes it

Then add a public hostname to the tunnel (Zero Trust dashboard: Networks > Tunnels > Public Hostname): relay.example.net, service HTTP, 127.0.0.1:3340. Check with curl -fsS https://relay.example.net/ping. Cloudflare terminates TLS, so it sees relay metadata, never content.

A mesh can list two relays (jaunt relay set URL1 URL2), e.g. a VPS first and a tunnel relay second; iroh picks each machine’s home relay by latency.

Fly.io

Fly delivers UDP only to a socket bound to the address fly-global-services resolves to; iroh-relay 1.3.0 binds quic_bind_addr to a literal address, so it receives no UDP and nothing goes direct. Working around it needs a start script that resolves fly-global-services into quic_bind_addr and a dedicated IPv4 (fly ips allocate-v4). fly.toml is a starting point.

Access

Leave [access] at its default. An id allowlist breaks pairing (pairing endpoints have ids nobody knows in advance), and access.shared_token is not supported.

Anyone who knows the URL of an open relay can use its bandwidth and occupy pairing nameplates (pairing fails, nothing leaks); ids grant nothing inside the mesh.

Try it on one machine

Two machines on one computer, with a local test relay over plain HTTP.

cargo install iroh-relay@1.3.0 --features server --locked     # the relay program
printf 'http_bind_addr = "127.0.0.1:3340"\nenable_metrics = false\n' > ~/relay-try.toml
iroh-relay --dev -c ~/relay-try.toml &                        # test relay, plain HTTP, local only

export JAUNT_TMUX_SOCKET=jaunt-try                            # keep test sessions off your own tmux
JAUNT_HOME=~/jaunt-try/benzene jaunt relay set http://127.0.0.1:3340
JAUNT_HOME=~/jaunt-try/benzene jaunt name benzene
JAUNT_HOME=~/jaunt-try/toluene  jaunt name toluene
JAUNT_HOME=~/jaunt-try/benzene jaunt link                       # terminal 1
JAUNT_HOME=~/jaunt-try/toluene  jaunt join --relay http://127.0.0.1:3340   # terminal 2 (same JAUNT_TMUX_SOCKET)
JAUNT_HOME=~/jaunt-try/benzene jaunt toluene                       # hop; Enter ~ . to leave

JAUNT_HOME keeps everything in one directory, away from your real configuration. Clean up: JAUNT_HOME=~/jaunt-try/benzene jaunt down, the same for toluene, stop the relay (kill %1), tmux -L jaunt-try kill-server, rm -rf ~/jaunt-try ~/relay-try.toml.

The same test relay can serve two machines on one home network if it binds to the LAN address instead of 127.0.0.1 (http_bind_addr = "192.168.1.10:3340", each machine using http://192.168.1.10:3340). A plain-HTTP relay is for testing only; use an HTTPS relay (deploy/relay/) for real.

Security model

jaunt gives a shell as you on every machine in the mesh.

Trusted: the machines on the roster and your user account on each. Not trusted: the network, the relay and whoever runs it, any machine not on the roster.

What is defended

ThreatHow
someone reading or changing traffic, including the relayQUIC with TLS 1.3, end to end between the machines’ own keys; the relay forwards ciphertext
an unknown machine connectingthe handshake proves the peer’s key; non-members are refused before any service code runs, and every stream checks again
someone who knows the relay URL or a machine idids are public keys; knowing one grants nothing
someone intercepting a pairingSPAKE2: one guess per code (1 in 60 million), the code dies on a wrong guess, lives 5 minutes, works once; each side proves it holds the key it claims; the roster is sent only after both people said yes; a joining machine never tries the same code twice
a lost laptop, after jaunt unlinkpermanent tombstone, pushed to every reachable machine and spread by sync; its connections are closed, its commands and shell clients ended; its tmux sessions too with --kill-sessions
a revoked machine racing a machine that was asleep, frozen, restarted or off the networkafter such an absence a daemon first tries to sync with every other member (up to 5 s, once per absence) before it serves a machine shells, commands, ports or copies.
a revoked or broken member stuffing the roster to block revocationsroster merges are bounded and take tombstones first
other users on the same machinekey and socket are private (0600 in 0700 directories); the client checks the socket’s owner; the pairing code is typed, not passed on the command line
strings from other machines messing up your terminalcontrol characters are removed before printing
a member pushing links, notifications or clipboard textonly http and https links open; notifications carry the sender’s name; the clipboard is shared only where its owner turned sharing on, and never what a password manager marks secret (per tool)
forwards reachable from the networkevery local end (fwd, open, socks, and rfwd on the far side) listens on 127.0.0.1 only
a laptop overheating in a baghost mode only on mains and below the heat limit; anything unknown means sleep

What is not defended

ThreatWhat it means, what to do
a stolen laptop that is unlocked and not yet revokedit is a full member until you revoke it. Use disk encryption and a screen lock. The key file has no passphrase (the daemon must start unattended)
a member that missed the revocation and cannot reach a machine that knowswhen it is back it serves the revoked machine only if no machine that knows answered within 5 s of the revoked machine’s first request, and then only until one becomes reachable (the revocation then ends its connections and processes). An outage shorter than a minute, with no sleep or freeze, does not count as an absence. unlink lists who has not heard
a compromised membertrust is flat: it can reach every machine, read its open tabs and (where shared) its clipboard, link new ones and revoke the others. It can also fill the roster’s entry limit (new links then fail until jaunt reset) and lie about which machines it added, so --cascade misses them
traffic analysisthe relay and the network see who talks to whom, when and how much; the machine id is visible in the handshake, so an observer can recognise a machine across networks
denial of servicethe relay can drop traffic; anyone who knows the relay URL can block pairing (the pairing fails, nothing leaks)
a tampered releaseinstall.sh checks the binary only against a checksum from the same place; there is no signature
root on a machine, or malware in your accountout of scope

Second layer. Where sshd listens on loopback, run OpenSSH through jaunt; SSH’s key check then stands behind jaunt’s:

# ~/.ssh/config: the Host names must be jaunt machine names
Host benzene toluene xylene phenol
    ProxyCommand jaunt pipe %h

Revoking a lost laptop

From any other machine in the mesh:

jaunt unlink toluene --kill-sessions
  • toluene is refused here at once. Its connections are closed, every command and shell client it was running here is ended, and with --kill-sessions the tmux sessions it created are ended here and on every machine that confirms.
  • The revocation is pushed to every machine that can be reached. The output lists who confirmed and who has not heard yet.
  • If toluene linked other machines and you want them gone too: jaunt unlink toluene --cascade. Plain unlink warns when such machines exist. This also works after toluene was revoked. If several revoked machines share the name, give the start of the id instead (jaunt unlink 1a013197 --cascade; jaunt ls --json lists revoked ids).

The window. A machine that has not heard still accepts toluene until it syncs with one that has (daemons retry every 30 s and on every reconnect). A machine coming back from sleep, a freeze, a restart or a network outage of a minute or more first tries the other machines; it lets toluene in only if none that knows answered within 5 s of toluene’s first request, and only until one is reachable. The first hop into a machine back from sleep can therefore wait up to 5 s. Get every machine on the “not heard yet” list online, and check jaunt unlink or jaunt ls on it.

What revocation does not undo (jaunt prints this reminder too): processes toluene started outside its own tmux sessions, files it wrote or copied, cron jobs, SSH keys or config it changed. If the laptop may be in someone else’s hands, check the machines it could reach and replace any secrets it held.

If the laptop comes back: run jaunt reset on it (new key) and link it again; its old key stays revoked forever.

If a thief revoked your other machines first: run jaunt reset on each of yours and link them again.

Internals

How jaunt is implemented, and the APIs modules consume from each other. Section numbers (§4 and so on) refer to docs/design.md.

Files and paths (src/paths.rs)

With JAUNT_HOME=<dir> (must be absolute) everything lives under it: config/config.toml, data/{key,roster.json,daemon.lock}, state/{daemon.log,events.jsonl,addrs.json,tmux-owners.json}, run/sock, config/systemd/user/jaunt.service. Without it, the XDG paths of §4 ($XDG_RUNTIME_DIR/jaunt/sock, fallback ~/.local/state/jaunt/run/). Directories are 0700 and must be real directories owned by the user (a symlink or a foreign owner is refused); key/roster/config/socket and the state files 0600. Writes are atomic (temp + fsync + rename). The single-instance lock sits next to the key (one daemon per identity even when two contexts disagree about XDG_RUNTIME_DIR) and records the running daemon’s socket path; a client that finds no daemon at its own socket path tries the recorded one before starting a daemon. A client only talks to a socket whose directory is its own and whose server runs as its uid (SO_PEERCRED). A detached daemon’s daemon.log is rotated to daemon.log.1 past 1 MiB.

config.toml keys: name, relays (bootstrap list), relay_ca (PEM path), default_session, tmux_socket (env JAUNT_TMUX_SOCKET wins), portmap (UPnP / NAT-PMP / PCP; off by default; portmapper is read as an alias; jaunt check mentions it when no peer is direct), port (fixed UDP port; without it the daemon re-binds the port it used last time when that is free). JAUNT_RELAY (comma list) is used when the config has no relays.

Relays in effect: the roster’s fleet settings once anyone ran jaunt relay set, else the config’s. Fleet relay changes (jaunt relay set, or a list that arrives by roster sync) are applied live (Endpoint::insert_relay/remove_relay); a new relay_ca needs a daemon restart. The endpoint is always built with a relay transport (RelayMode::custom with the list, empty if there is none, never RelayMode::Disabled), so a daemon that started with no relay connects as soon as one is set; jaunt relay set waits up to 10 s and says whether the daemon is connected to the relay.

Wire (src/proto.rs)

Frame: [u32 BE payload length][u8 type][payload], payload ≤ 1 MiB. Types: 1 Header (JSON Hello), 2 Reply (JSON Reply), 3 Data, 4 Stderr, 5 Eof, 6 Resize (u16 cols, u16 rows BE), 7 Exit (JSON {code, signal}), 8 Notice (UTF-8), 9 Json. JSON headers are capped at 64 KiB.

ALPN jaunt/1. Each request is one bi-stream: client sends Header {proto: 1, service}; host answers Reply {kind: ok} or {kind: refused, reason} (version mismatch, unknown service, non-member, not implemented, bad session name… all in words). Services (Service, tagged by kind):

kindafter the reply
syncclient sent one Json roster right after the header; host replies Json with its merged roster
pinghost sends Json PingInfo {name, version, sessions, host, time_ms}
shell {session, term, cols, rows, env}Data both ways, Resize c→h, Notice h→c, final Exit h→c
exec {argv}Data (stdin c→h / stdout h→c), Stderr h→c, Eof c→h, final Exit
tcp {port}host connects to 127.0.0.1:port (then ::1) before replying (a closed port is a refusal in words); then Data both ways, Eof = half-close each way; a side finishes the stream only when both directions are done (src/services/tcp.rs)
copy {op: push|pull, path}the sender (client for push, host for pull) sends Json messages tagged m: plan {name, files, bytes}, then per entry entry {path, kind: file|dir|symlink, mode, size, mtime, target} followed for a file by exactly size bytes of Data, skip {path, reason} for what it could not send, done (or abort {reason}); for a push the host answers written {files, bytes} or abort (src/services/copy.rs)
kill_sessions {of}host ends the tmux sessions those (revoked) machines created; Json count
tabsJson [Tab {browser, title, url}] from the browsers’ session files (src/services/tabs.rs)
portsJson [Port {port, bind, process, what}]: listening TCP sockets loopback reaches (src/services/ports.rs)
open_url {url}host opens an http(s) URL in its browser; Json null
notify {title, body}host shows a notification titled <sender> · <title>; Json null
clip {op: get|set}refused unless clipboard = true on the host. get: Json text. set: client sends the text as one Json frame, host answers Json Reply (src/clipsync.rs)
dial {host, port}as tcp, to any address the host reaches (jaunt socks)
listen {port, to}host listens on 127.0.0.1:port while the stream is open, and opens a tcp {port: to} stream back to the client for each connection (jaunt rfwd)

Copy receiving (src/services/tree.rs): entry paths are relative, /-separated, plain components only (no .., ., empty, absolute, NUL; ≤ 4096 bytes, ≤ 128 deep, ≤ 2 million entries); before each write every directory between the copy’s root and the entry is checked with lstat to be a real directory, so a received symlink is never written through; files go to a temp file in the same directory (0600) and are renamed into place with the sent mode (permission bits only, setuid/setgid/sticky dropped) and mtime; directory modes are applied last. If the destination is an existing directory the tree goes inside it under the plan’s name; on a pull the client requires that name to equal the basename it asked for. Remote paths: relative and ~/… are under the daemon’s $HOME.

If the stream dies before Exit, the host kills the process group (SIGHUP, SIGKILL after 2 s); for shells that is the tmux client, so the tmux session survives.

IPC API (unix socket, src/ipc.rs)

Same framing; every message is one Json frame. The server checks the peer’s uid. One request per connection. Requests (IpcRequest, tagged by op):

requestresponse(s)
{"op":"status"}{"kind":"status", ...StatusSnapshot}
{"op":"subscribe","history":N}status snapshot, then up to N past events (oldest first, max 500), then live event and rtt messages until the client closes; after a lag a fresh status
{"op":"open","target":"toluene","service":{...}}{"kind":"opened","peer":{id,name},"path":"direct"|"relay"|"unknown","rtt_ms":0.8} and from then on the socket carries the QUIC stream’s frames verbatim (both directions; closing the socket ends the stream); or {"kind":"error","message":...}
{"op":"add_peer","id":"<64 hex>","name":"b"}ok / error (test bootstrap: jaunt debug add-peer; refused unless the daemon runs under JAUNT_HOME)
{"op":"quiet","id":"<64 hex>"}{"kind":"quiet","quiet_ms":N|null}: ms since any datagram (data, acks, keep-alives) arrived from that peer on its live connection; null without one. The hop client asks it when typed input has had no answer for 3 s
{"op":"rename","name":"x"}ok / error
{"op":"set_relays","relays":["https://..."]}ok / error
{"op":"shutdown"}ok, then the daemon exits
{"op":"set_host","on":true}ok; host mode saved in config.toml and applied at once (src/awake/). StatusSnapshot.host is a HostReport {state, reason, on_lid_close, lock_held, lid, power, hottest}; state is off/awake/on_battery/too_hot/will_sleep, and peers get HostState::label() in PingInfo.host
{"op":"set_clipboard","on":true}ok; saved as clipboard in config.toml
{"op":"forward","machine":"b","port":8000,"local":0,"name":null,"keep":false}{"kind":"forward", ...ForwardStatus} once listening (local 0: the same port if free, else any); keep saves it as a [[forward]] table. StatusSnapshot.forwards lists them (src/forwards.rs)
{"op":"unforward","name":"site"}ok / error; by name or local port
{"op":"visitor"}ok with the name of the machine most recently hopped in whose shell is still open, else the last one that hopped in; error if none

| {"op":"link","yes":false} | a stream of pair updates (below); at step confirm the daemon waits for {"op":"pair_answer","accept":true\|false} on the same socket (not with yes); ends with ok or error. Closing the socket cancels | | {"op":"join","code":"4127-tremor-kudos","relays":[...],"yes":false} | same conversation as link | | {"op":"unlink","target":"c","cascade":false,"kill_sessions":false} | one told per other member as it answers, then unlinked (report) or error. With kill_sessions, the tmux sessions the revoked machine created are ended here and, through the kill_sessions service, on every member that confirmed (each machine records which peer created which session in state/tmux-owners.json, and ends only sessions of machines it has revoked); the report’s tmux_sessions_killed counts them | | {"op":"leave"} | tolds, then unlinked; the client then stops the daemon and deletes key and roster |

target is resolved by the daemon: exact display name, then unambiguous name prefix, then id-hex prefix (≥ 4 chars); this machine is never a target. Errors say “could mean x or y”. For unlink with cascade or kill_sessions, a target that matches no member is looked up among revoked machines the same way (their entries, with the name, stay in the roster; a tombstone without an entry is found by id prefix; same-named revoked machines are told apart by id), so jaunt unlink toluene --cascade still works after toluene was unlinked; the report then lists it in already_revoked. Without those flags it says the machine is already unlinked.

StatusSnapshot:

me {id, name}, version, daemon_pid, started_at_ms,
relays [url], relay_connected (home relay; becomes true ~3 s after start), bound [sockaddr],
peers [PeerStatus], revoked [{id, name?, revoked_at_ms}]
PeerStatus { id, name, state: direct|relay|connecting|offline, remote_addr?, addr_kind?
  (lan|public|cgnat|loopback|relay|other), rtt_ms?, rtt_history [{t_ms, rtt_ms, path}]
  (1 sample/s, last 60), connected_since_ms?, last_seen_ms?, version?, sessions?, host?,
  last_error? }

Peers are roster members other than this machine, sorted by name. A peer’s host is the HostState::label() it reported in its last PingInfo (off, awake, on battery, too hot, will sleep on lid close); sessions is the number of tmux sessions on its jaunt tmux server (cached 5 s). StatusSnapshot.host is this machine’s own HostReport.

Event = {seq, t_ms, peer?: {id,name}, event: <kind>, ...fields}; kinds: daemon_started {version}, handshake {incoming, path} (for a connection this machine dialled: after its first sync went through), path_changed {from, to, remote_addr}, disconnected {reason}, dial_failed {error} (only when the error text changes; also, once per streak, when a peer refuses a connection we dialled as “not a member”, the usual case while a new link spreads), sync {changed, outgoing}, roster_changed {what}, served {service} (shell/exec opened here by a peer), refused {reason} (a stream from a peer was refused: bad header, version, membership). rtt messages: {"kind":"rtt", "peer":"<id>","sample":{t_ms, rtt_ms, path}} once per second per connected peer; they are live-only, not history. Events are also appended to state/events.jsonl (rotated at 1 MiB).

Trust kinds: pair {role, step, ...} (the same steps as the IPC pair messages; the waiting step’s code is logged as NNNN-…, never the words), revoked {by, sessions_killed, added} with peer = the revoked machine (emitted on every machine as the tombstone arrives, locally or by sync; added = members it added, still linked), told {ok, detail} with peer = the machine that answered a pushed unlink/leave.

Pairing and revocation messages

pair message / event: {"kind":"pair","role":"inviter"|"joiner","step":<step>,...}. Steps in order (fields inside a step are never called peer, which belongs to the enclosing event):

stepfieldswhen
waitingcode, expires_at_ms, relays, first_machineinviter: the code is live (5 min, one attempt)
dialingrelaysjoiner: looking for the inviter’s rendezvous endpoint
rendezvousthe two temporary endpoints are connected (unauthenticated)
key_exchangeSPAKE2 done (a wrong code shows only at the next step)
sealedother {id,name}real ids and names opened under the PAKE key: the code was right
confirmother, sigil[5 strings of 5 '#'/'.'], hue 0..5, words[3]show them; ask the human (the event history keeps other only, not the sigil and words)
confirmedhere: boolone side said yes (both arrive before linked)
linkedother, membersboth said yes; the roster is written on this side
fan_outmachine {id,name}, ok, detailinviter: one other member’s acknowledgement of the new entry
fan_out_donetold, not_yetinviter: every other member was tried (10 s each, in parallel)
failedreasonthe pairing ended without linking (the final error repeats it)

told: {"kind":"told","peer":{id,name},"ok":true,"detail":"confirmed"}. unlinked: {"kind":"unlinked","revoked":[PeerRef],"confirmed":[PeerRef],"not_heard":[PeerRef], "added_by_revoked":[PeerRef],"sessions_killed":N}. confirmed = answered with a roster holding the tombstone(s); added_by_revoked is empty with cascade.

jaunt debug events [--history N] prints the subscription as JSON lines; it is the easiest way to see the stream.

Daemon behaviour (src/daemon.rs)

  • Endpoint: presets::Minimal, relays only from settings, OS resolver (no public DNS fallback), no address lookup, no captive-portal probe, keep-alive 5 s, idle 30 s. The after-handshake hook admits only current members (raw 32-byte ids); every stream re-checks membership.
  • One keeper task per member: dial if no live connection, then wait 30 s / a roster change / a kick (2 s after a loss; 1 s after a “not a member” refusal, for the first 15). A dial is id + every relay URL + the peer's last working direct addresses (state/addrs.json, at most 4 per peer, saved whenever a direct path’s address is new). The addresses are only hints: the peer is still authenticated by its key, so a stale one costs a failed attempt. With them, and with the daemon re-binding its last UDP port (so its NAT mapping and the address the others remember stay the same), a restarted daemon finds a peer on the same direct path while the relay is down.
  • Every adopted connection is tracked per peer until it closes; a revocation closes all of them. A new connection from a peer replaces (and closes) any older one except inside the simultaneous-dial race below.
  • Settling. An absence is a daemon start, the 1 s monitor not having run for 5 s (a freeze or suspend), or no authenticated contact with any member for 60 s (contact is a sync either way or a ping answered or served). From then on the daemon is unsettled. It serves Sync and Ping always; it serves a peer’s Shell, Exec, Tcp, Copy or KillSessions at once if a Sync (either direction, a settling round’s or any other) has completed since the absence with a member other than that peer, or if no member other than that peer exists (nobody else could know of its revocation); it is settled once that holds for every member. Otherwise the request waits for a settling round that started at or after the moment the requester’s connection was established and after the absence began (joining a running one, else starting one): that connection proves the network was up while the round ran, so rounds that ran during an outage never count. If the round syncs with another member, settled as above; if it completes without reaching any, the daemon is provisionally settled and serves everyone without waiting until the next absence. Then the requester’s membership is checked again and it is served. So the wait is at most once per absence (up to 5 s). A round syncs with every other member at once, dialling through the relay and the remembered direct addresses; it ends when every attempt has ended (failed dials are retried while time is left; a connection from before that does not answer in 2.5 s is closed and dialled again), at most 5 s. While unsettled the monitor also starts a round every 30 s; the keepers keep dialling every 30 s in any case, and a tombstone that arrives later ends the revoked machine’s connections and processes as usual.
  • Every served stream (shell, exec, tcp, copy) ends when its peer stops being a member, besides the process kills below.
  • Every adopted connection (either direction) accepts streams, samples the selected path every second, pings every 10 s. The dialler runs Sync on every new connection; any roster change (local or merged) is pushed to every connected peer.
  • Simultaneous dials: both sides keep the connection dialled by the smaller id and close the other after 3 s. A new connection from a peer outside that race replaces the old one at once (the peer only dials when its side of the old one is gone), so a killed and restarted peer is back in about a second instead of after the 30 s idle timeout.
  • SIGTERM/SIGINT/SIGHUP or shutdown: close every connection with a reason, close the endpoint (3 s cap). sd_notify READY/WATCHDOG/STOPPING when run by systemd.
  • Single instance per identity: flock on data/daemon.lock (next to the key; see Files).

Roster (src/roster.rs)

entries{id → {id, name, added_at, added_by, named_at}}, tombstones{id → {id, revoked_at, revoked_by}}, fleet {relays, updated_at, updated_by}. Merge: entries by max (named_at, name, added_at, added_by), tombstones by min (revoked_at, revoked_by), fleet by max (updated_at, updated_by, relays); a tombstone beats any entry. Limits: 256 members, 1024 entries (revoked machines’ entries count here, not as members), 512 tombstones for ids without an entry (a tombstone for a known id is always taken), 8 relays, names [a-z0-9-]{1,32}. A roster from a peer is merged item by item (merge_bounded): malformed entries (bad name, id not an ed25519 key) are dropped singly, tombstones go first, and only new items past the limits are skipped, so a full or junk-stuffed roster never blocks a revocation. A merge that still fails is refused in words (sync refused: ...), which unlink reports as such. Same-name machines display as name (earliest added) and name-<4 hex of id>.

Pairing (src/pair/)

code.rs: codes NNNN-word-word, nameplate and words from the OS CSPRNG (getrandom, rejection sampling), the EFF large wordlist embedded (eff_large_wordlist.txt, words only). Parsing is forgiving (case, spaces/underscores/dots for dashes, dashed list words like t-shirt); a misspelt word is caught on the joiner before anything is sent. crypto.rs: rendezvous key = HKDF-SHA256(ikm = nameplate, info jaunt/pair/1/nameplate); SPAKE2 (spake2 0.4, Ed25519, start_a inviter / start_b joiner, identities jaunt-inviter / jaunt-joiner, password = the canonical code); per-direction keys = HKDF-SHA256(salt jaunt/pair/1, ikm = SPAKE2 key, info jaunt/pair/1/inviter-to-joiner / .../joiner-to-inviter); ChaCha20-Poly1305 with a counter nonce per direction; check = SHA-256(jaunt/pair/2/check ‖ key) → sigil (15 bits, mirrored 5×5), hue, three EFF words (the key alone: a claimed id, chosen after the key is known, must not feed it); proof of key = ed25519 signature by the machine key over jaunt/pair/2/proof/<role> ‖ HKDF-SHA256(salt jaunt/pair/1, ikm = key, info jaunt/pair/2/proof), checked against the claimed id (which must be a valid ed25519 key). flow.rs (pairing version 2): the conversation on one stream (diagram in the file): joiner spake → inviter spake → joiner sealed {id,name,proof} → inviter sealed {id,name,proof} → both sealed {ok} → inviter sealed {roster,your_entry} → joiner sealed done{ok} → inviter writes its roster, sealed done{ok} → joiner writes its roster. The roster reaches the joiner only after both humans said yes. Plain {t:"abort",reason} frames carry refusals (wrong code, revoked key); they are unauthenticated courtesy and are shown only cleaned of control characters, cut to 200 characters and marked “not verified”. A member that proves its key may link again (how a pairing whose last message was lost is finished). mod.rs: the inviter’s temporary endpoint (ALPN jaunt-pair/1, accepts any remote, no port mapping) lives until the code’s single attempt or its 5 minutes; incoming connections are handled side by side (up to 16) until one sends a well-formed first message, so one that never speaks holds up nobody and does not use up the code; the first SPAKE message burns it. The joiner dials from a throwaway random key and sends its real id only sealed; it records each code it sends a first message for (as a hash, 5 minutes) and never tries the same code again. The client reads the code from the terminal or stdin (jaunt join with no argument); a code given on the command line works but warns that the process list shows it. join refuses on a machine that already has other members (use leave first). If the fleet relay list was never set, link sets it from the inviter’s relays so the joiner receives it. A join on a machine whose daemon runs without a relay saves the relay to config; the running daemon takes the fleet relay list from the roster it receives. There is no LAN pairing without a relay (no mDNS).

Revocation (src/trust.rs, src/children.rs, Daemon::roster_changed)

On every roster change, in this order: the handshake allowlist is replaced (a revoked id is refused by the hook and by every stream open from then on); every process started for a non-member (shell pty clients, exec commands; children.rs registry) gets SIGHUP to its process group, SIGKILL after 1 s if still registered (the serving future is kept alive up to 3 s after the revocation, REVOKED_GRACE, so that SIGKILL still reaches a command that ignores SIGHUP); connections to non-members are closed; a revoked event is emitted per new tombstone; the change is pushed to members only. tmux sessions themselves are kept (they are the user’s; a revoked machine’s shell client is detached). unlink applies the tombstone(s) locally first, then syncs with every other member in parallel (10 s each) and reports. leave pushes a roster with this machine’s own tombstone first (the receiver spares the sender’s connection until its answer is read) and applies it locally last. reset does the same when still a member, then wipes.

Roster defences: remote rosters are sanitised before merging (entries and fleet settings with timestamps past 2100 are dropped; tombstones are never dropped, only clamped); membership ignores timestamps, so no clock can un-revoke; local renames and relay changes use max(now, previous + 1) with saturation. A machine whose own id is tombstoned talks to nobody. Accepted by design (flat trust): any member may tombstone any other, including the receiver.

Window for a machine that was away during an unlink. A machine back from an absence serves a peer no shell, command, port or copy until it has synced with some other member or a settling round run with the network up reached none (settling, above), whether the absence was a freeze, a suspend, a restart or a network-only outage with the daemon running. What is left, exactly: a machine returning from an absence serves a revoked machine only if no informed member answered within 5 s of the revoked machine’s first request (the others offline, the relay down and no direct path to them, or the member that answered had not heard either), and then only until an informed member becomes reachable (keepers dial every 30 s and on every reconnect; the tombstone then ends its connections and processes). Absences shorter than a minute without a freeze are not noticed. A machine that never left learns of a revocation by the push or by its next sync. 03-unlink.sh checks the freeze case (b frozen 60 s) once and the network-only case (b blackholed 90 s, daemon running) three times: each time c sends five commands to b the moment b is back, and none gets through.

jaunt check (src/check.rs)

IPC {"op":"check"} → {"kind":"check", net, contacts, relay_ips}: net is iroh’s latest net report (QAD over IPv4/IPv6, public address, whether the mapping varies per destination, per-relay probe latencies for https / qad_ipv4 / qad_ipv6); contacts lists every remote address of a QUIC path the daemon has used (recorded once a second per live connection, with the peer’s name) plus the daemon’s open TCP/UDP sockets with a remote end (from /proc/self/fd and /proc/self/net/{tcp,udp}[6]); relay_ips is what the relay host names resolve to now. The client prints ok / warn / fail / info lines: version, key and directory permissions, tmux, the systemd user unit (not consulted under JAUNT_HOME), daemon, relay probe and connection, UDP address discovery and hard-NAT warning, each peer’s path, the contact audit (anything that is not the relay, a linked machine, loopback or the system resolver is a warning), and the awake guard’s prerequisites. Exit 1 if any fail. The daemon is never started by check.

Client side

  • Auto-start: if the socket does not answer, jaunt starts jaunt daemon detached (setsid, log to state/daemon.log) or systemctl --user start jaunt if the unit is installed and JAUNT_HOME is unset. The detached daemon runs in $HOME (not the directory the command ran in) with an allowlisted environment (HOME, USER, LOGNAME, SHELL, LANG, LC_, TZ, XDG_, DBUS_SESSION_BUS_ADDRESS, TMUX_TMPDIR, JAUNT_*) and a PATH of absolute directories only; every daemon, however started, also leaves its start directory and drops empty or relative PATH entries, so no helper (tmux, systemd-inhibit, busctl, systemctl) is found relative to a directory.
  • Hop (src/shell.rs, src/term.rs): raw mode restored on drop, on panic (hook) and on SIGTERM/SIGHUP; SIGWINCH → Resize; Enter ~ . / Enter ~ ? / ~~; when the stream dies the client keeps the terminal, shows one status line and retries with backoff 1–10 s; ~. gives up during retry too. While the stream is up, typed input that has had no output back for 3 s makes the client ask the daemon (quiet) whether anything at all (data, acks, keep-alives) arrived from the machine in the last 3 s; if not, the bottom row shows toluene ╌ no answer for Ns · waiting for the link · ~. to give up until output arrives (then the remote screen is redrawn). A quiet program (a password prompt) on a live link shows nothing.
  • Strings from other machines (refusal reasons, ping facts, notices, abort reasons, error messages) are printed without control characters (term::safe_text).
  • UI (src/ui/): drawn by hand on a cell grid (canvas.rs, per-cell diffs, synchronized updates), no TUI crate. theme.rs picks the colour mode (JAUNT_COLOR=truecolor|16|none forces one; NO_COLOR/--no-color; off a terminal no escapes at all) and the glyph set (JAUNT_ASCII=1 or a non-UTF-8 locale → ASCII). model.rs turns a StatusSnapshot plus the event stream into what is drawn; mesh.rs draws the tree (this machine as hub, a link per peer styled by path, latency and sparkline, the peer column with host state and sessions); a relayed link is drawn through a visible relay waypoint, all sparklines share one latency scale, repeated events fold into one line (in step ×6), and the live view has a detail panel for the selected machine (path, last minute of latency, last path change, handshake, uptime, id); view.rs/live.rs are the live view (bare jaunt on a terminal, and the hidden jaunt pick for the tmux popup: Enter opens the hop in a new tmux window); ls.rs is jaunt ls on a terminal; ritual.rs is what jaunt link/join show on a terminal (off a terminal they print the plain lines 02-link.sh parses); demo.rs is jaunt demo mesh|link|ls|hop [--at S] [--join] on simulated data; tmux.rs is jaunt tmux-setup. ls_plain stays stable: tab-separated name, state, rtt, host, sessions, id; this machine first with state self.
  • A hop from inside tmux prints a one-time hint about the prefix (marker state/tmux-hint-shown), unless the local tmux has sourced jaunt.tmux (@jaunt_tmux).
  • Host mode (src/awake/, jaunt host on|off|status): see docs/awake.md. The lock is a systemd-inhibit child on a pipe from the daemon, so any daemon death releases it.

Install, uninstall, release

  • scripts/build-release.sh: static x86_64 musl binary built in a throwaway debian:12 container with rustup (toolchain pinned, default 1.97.1); caches under target/musl-build/ (about 1.8 GB); output dist/jaunt-x86_64-linux-musl and .sha256. Static-pie, about 10.7 MB. The harness installs a binary under its file name, so copy it to a file called jaunt before running the e2e suite with it.
  • install.sh (POSIX sh, no root): downloads the release asset and its .sha256 from GitHub (JAUNT_REPO, --version TAG, JAUNT_BASE_URL for another base, file:// works), falls back to gh release download on a 404 (private repository), or takes --from-file. Verifies the checksum, writes ~/.local/bin/jaunt atomically (--bin-dir), writes name/relays into config.toml for --name/--relay (keeps values already there), and starts nothing unless --up; otherwise it prints the next two commands.
  • jaunt uninstall [--yes|--keep-key]: stops the service or daemon, removes the unit, config, state and runtime directories and the binary (only ~/.local/bin/jaunt, or $JAUNT_HOME/bin/jaunt, and only if it is the running binary); asks before deleting the key and roster (keeps them off a terminal); says to jaunt unlink <name> from another machine. tmux sessions are left alone.
  • deploy/relay/: Dockerfile, relay.toml, fly.toml and the three hosting options.

Tests

  • cargo test: roster merge laws (also over generated hostile rosters), tombstones, clock skew, sanitising, cascade closure, collisions, frame limits and malformed input, escapes, argv/env limits, key file permissions; pairing: codes and forgiving entry, wrong code (fails both sides, burns), expiry and single use, replayed first messages, a man-in-the-middle with the nameplate (one guess, opens nothing), tampered sealed payload, reflection/reordering, a “no” on either side, a revoked joiner, a code holder claiming an invalid or someone else’s key (refused), no roster before both confirmed, a forged inviter id (refused), abort reasons cleaned and cut, an existing member linking again, the joiner trying a code once; roster: bounded merge equals merge below the limits, a junk-stuffed full roster never blocks a tombstone, malformed items dropped singly; host mode: heat dipping for one sample still trips, a flapping cable and an alternating weak charger release, a hung sensor read (a FIFO) releases, lost CPU sensors are unknown heat; local: uninstall removes only jaunt’s files, the daemon’s PATH and environment, systemd unit quoting, settling (what starts an absence, that a sync with a member other than the requester ends it, that rounds reaching nobody and time alone do not, that a round started before the requester’s connection does not count, and provisional settling after one that started later), revoked machines resolving for unlink --cascade, address hints.
  • test/e2e/00-host.sh: two daemons on the host (JAUNT_HOME under target/tmp, or HOST_E2E_DIR; dev relay on 127.0.0.1, tmux -L jaunt-test), run, events, and a pty hop that survives SIGTERM and SIGKILL restarts of the remote daemon; then a daemon started with no relay gets one by jaunt relay set, reaches it without a restart (same pid) and links a fourth machine.
  • test/e2e/02-link.sh: harness: a starts the mesh, b joins (code typed with spaces and capitals), identical sigils, wrong code fails and burns, c joins through b, a learns of c, all connected, pairing events present and free of the code words.
  • test/e2e/03-unlink.sh: harness: c’s shell and command on b ended by a unlink c, c refused by a and b, cannot come back by syncing or by pairing with its old key; with b frozen for 60 s, a unlink c --kill-sessions ends the session c created on a and prints the reminder; the moment b thaws c sends five commands to b and none gets through; b learns of the unlink (time printed). Then three rounds of the network-only case: c linked again, b blackholed 90 s with its daemon running while a unlinks c, c sends five commands the moment b’s network is back, none may get through; then unlink <id prefix of a revoked c> --cascade works; last, with a newly linked c offline and b frozen 15 s, a’s first run b takes at most ~7 s and the next two under 1.5 s (one settling wait per absence).
  • test/e2e/06-paths.sh also asserts that a daemon restarted while the relay is down is reached again on the direct path (address hints), and 05-continuity.sh’s driver reports for each disruption whether the stall line was shown.
  • test/e2e/04-09, run-all.sh: hop, onward hop and re-hop; continuity through link drops, roams, freezes and a daemon restart; paths (hard NAT modes, relay down and back); services (run, cp, fwd, pipe + OpenSSH, throughput); no-contact audit from outside (conntrack and iptables counters); keystroke latency (writes docs/measurements.md). Helpers: hop_session.py (PTY driver run on the host through docker exec -it, so disruptions run on the host meanwhile), keystroke.py, and in lib.sh h_mesh, h_daemon_restart (waits for the old daemon to exit: it holds the lock for up to 3 s).
  • scripts/capture.sh [--readme]: demo views × sizes × colour modes rendered to PNG; scripts/capture-live.sh: the real mesh view on harness machine a (b direct, c relayed).
  • test/e2e/01-core.sh: Docker harness with a TLS+QAD iroh-relay (test/harness/relay.sh, throwaway CA made at run time): a⇄b direct, c relayed, roster spread, run, relayed hop.

Troubleshooting

Most answers start with:

jaunt check

It prints one line per finding, each starting with ok, warn, fail or info, and exits with status 1 if any line is fail. It never starts the daemon, so it describes the machine as it is. Run it on both ends of a problem.

The quoted lines below are what check prints, word for word; … stands for the part that changes.

The daemon is not running

check says:

warn	the daemon is not running (any jaunt command starts it; 'jaunt up' installs the service)

Any other jaunt command starts the daemon when it finds none, and says jaunt: started the daemon (pid …, log …). Without the user service, nothing starts it again at the next login or after a reboot; jaunt up installs the service. Two more lines point the same way:

warn	no systemd user service: the daemon does not start at login ('jaunt up' installs it)
warn	systemd user service jaunt.service is … and … ('jaunt up' enables and starts it)

If the daemon is running but is an older binary than the command (after an upgrade), the daemon line ends with a different version from this command: restart it with 'jaunt down'. Run jaunt down, then jaunt up.

If starting fails, the client says the jaunt daemon did not come up; see …/daemon.log. That log is the place to look (see Where the logs are).

The relay does not answer

fail	no relay configured: other machines cannot find this one (jaunt relay set URL)
fail	relay … does not answer: check the URL, DNS and that its HTTPS port is reachable
fail	not connected to any relay: other machines cannot reach this one

The first means this machine has never been given a relay: jaunt relay set URL on the first machine, or jaunt join --relay URL on a new one. For the other two, check the relay itself from any computer:

curl -fsS https://relay.example.net/ping

If that fails, the problem is the relay host, its DNS name or its firewall (443/tcp must be open), not jaunt. The relay chapter lists the ports. A healthy relay gives relay … answers its web probe (… ms) and relay connected: other machines can reach this one.

While the relay is down, machines with a direct path keep working (a daemon remembers its peers’ last direct addresses); new machines cannot link, and relayed-only machines are unreachable.

UDP is blocked and every path is relayed

warn	UDP address discovery got no answer (relay UDP port 7842 blocked, or UDP blocked here): direct paths through NAT are unlikely
warn	no direct path to any machine: everything goes through the relay

Machines learn their public address from the relay over UDP port 7842; without it they cannot punch a direct path. jaunt still works, through the relay. Check, in order:

  1. The relay’s port. 7842/udp must be open in the provider’s firewall and on the host. A relay behind a Cloudflare tunnel never receives UDP: that is the expected result of that option, not a fault.
  2. This network. Some networks block outgoing UDP. Run check from another network to compare.
  3. The kind of NAT. this network's NAT uses a different public port per destination (hard NAT): direct paths are unlikely and traffic will go through the relay means the router here makes direct paths unlikely even with UDP open.

check then also mentions port mapping: portmap = true in config.toml lets jaunt ask the router (UPnP, NAT-PMP, PCP) to forward a UDP port. It is off by default.

A machine shows offline

jaunt ls says offline for toluene, and check prints a warn line for it, with the last error in brackets. Go through these:

  • It is asleep. A laptop with the lid closed suspends unless host mode holds it awake, and jaunt does not wake machines. While a machine is online, jaunt ls shows its host state; will sleep on lid close or on battery mean exactly that.
  • Its daemon is not running. After a reboot without the user service, nothing starts the daemon until a jaunt command runs there. jaunt up on that machine fixes this for good.
  • It cannot reach the relay, and has no direct path to you. Run jaunt check on it.
toluene ╌ link lost · retrying in 4s · ~. to give up

The connection is gone. jaunt keeps your terminal, retries after 1, 2, 4 and 8 seconds and then every 10, and re-attaches when toluene answers; the tmux session there is untouched. Wait, or press ~. and come back later with jaunt toluene. If it never returns, see the section above.

A slow recovery after an outage

Moving to a new network right after an outage can leave a hop frozen with the line no answer for …s · waiting for the link for 17 to 49 seconds. After an outage iroh backs off reconnecting to the relay, and a network change does not reset the backoff. The hop recovers by itself.

The first hop after sleep waits a few seconds

The first hop, command or copy into a machine that has just come back from sleep (or a restart, a freeze, or an outage of a minute or more) can wait up to 5 seconds before anything happens. The next ones do not.

Before serving a shell, command, port or copy, a machine that was away first syncs with the other members to pick up any revocation it missed: at most 5 seconds, once per absence. See Revoking a lost laptop.

tmux is missing on the host

warn	tmux is not installed: hops to this machine get a plain shell that ends when the link drops

The hop still works, but with no tmux to keep the session, closing the link ends the shell and whatever ran in it. Install tmux on the machine you hop into. With tmux there, the line is tmux at … (sessions here survive disconnects).

The lid closes and the laptop sleeps anyway

Host mode holds the laptop awake only while every condition holds; anything else means sleep. jaunt host status on the laptop says what is true now, what closing the lid would do, and what is missing. The usual causes:

CauseWhat you see, what to do
on battery, or a charger too weak to keep upreleased after 10 s on battery, or after the battery has drained for 60 s on a weak USB-C charger. Use a proper charger
too hotreleased after 120 s at 90 °C or more, or at once within 5 °C of a sensor’s critical point. It stays released until the lid is opened
power or temperature unreadableunknown means sleep
no local login since bootno local graphical session: logind refuses the lid lock until you log in at this machine once after each boot, or until scripts/root-extras/host-polkit-rule.sh is installed
the daemon was started from an SSH loginthis is an SSH login and the jaunt service is not installed: a daemon started from here belongs to the SSH session and logind refuses it the lid lock; run 'jaunt up' so it runs as a user service
plugged back in with the lid closedit stays asleep until the lid is opened; jaunt does not wake machines

The full rules are in Host mode.

jaunt join says the code is wrong or expired

Codes work once, for 5 minutes, a wrong guess burns them, and each joining machine tries a code only once. The fix is always a fresh code from jaunt link. The exact messages are in When linking fails.

Two contexts disagree about XDG_RUNTIME_DIR

The client finds the daemon through a Unix socket at $XDG_RUNTIME_DIR/jaunt/sock (or under ~/.local/state/jaunt/run/ when that variable is not set). A desktop session and an SSH login can see different values, and so look in different places.

jaunt handles this with a lock file, daemon.lock, next to the key in ~/.local/share/jaunt/. Only one daemon per key can hold it, and it records that daemon’s socket path. A client that finds no daemon at its own socket path tries the recorded one before starting a daemon, so a second context reaches the running daemon instead of starting another. A client only talks to a socket in a directory it owns, served by a process of the same user.

Where the logs are

WhatWhere
the daemon’s log, when it was started by a jaunt command~/.local/state/jaunt/daemon.log (rotated to daemon.log.1 past 1 MiB)
the daemon’s log, under the systemd user servicethe user journal: journalctl --user -u jaunt
the event history (handshakes, path changes, syncs, links)~/.local/state/jaunt/events.jsonl, also shown live in the mesh view
every address the daemon has talked tothe end of jaunt check; anything that is not the relay or a linked machine is a warn

With JAUNT_HOME set, all of these live under $JAUNT_HOME/state/ instead.

Testing

cargo test                  # unit tests
test/e2e/run-all.sh         # end-to-end, in the Docker NAT lab (no root)

Unit tests cover roster merging (including hostile rosters), attacks on pairing, frame limits, the host-mode guard on fake sysfs trees, settling and local hardening.

The lab (test/harness) runs machines a and b each behind their own home-router NAT, machine c behind a hard (symmetric) NAT, and a real iroh-relay 1.3 with TLS on a simulated internet, all in containers on one host. The e2e scripts drive the release binary through link, join, hop, onward hop, run, cp, fwd, pipe with OpenSSH, unlink and lock-out, a revoked machine racing a frozen or disconnected one, direct and relayed paths, the relay going down, drops, roams, freezes, daemon restarts and latency. 08-nocontact.sh checks from the routers that the daemon contacts nothing but the relay and its peers. 10-tools.sh covers ports, tabs, send-tab, notify, clipboard sharing, socks, rfwd and held forwards, including a download through a forward while the client roams to another network and back, and forwards across daemon restarts on both ends. 09-latency.sh writes keystroke round trips to docs/measurements.md.

Changelog

Releases: https://github.com/ZahakJ/jaunt/releases. Each has one static Linux binary, jaunt-x86_64-linux-musl, and its .sha256.

v0.2.0, 15 October 2026

  • jaunt ports lists what listens on a machine’s loopback; a key forwards one and opens it. jaunt open toluene:7707 forwards a web port and opens it in one step.
  • Held forwards: jaunt fwd --keep, jaunt unfwd, listed in jaunt ls, back after restarts; each connection dials afresh.
  • jaunt rfwd (like ssh -R) and jaunt socks (a SOCKS5 proxy through another machine).
  • jaunt tabs pulls another machine’s open browser tabs; jaunt send-tab pushes URLs.
  • Web links opened in a hop open in the browser of the machine you came from. Sessions jaunt starts see no display of the far machine, as with ssh.
  • Clipboard sharing, opt-in on each machine: jaunt clip on.
  • jaunt notify and jaunt run --notify.
  • The install line no longer needs the GitHub CLI.

v0.1.2, 5 October 2026

  • jaunt up takes over from a daemon an earlier command had started, instead of failing.
  • deploy/relay/desktop-tunnel.sh: run the relay on a desktop behind an existing Cloudflare tunnel.

v0.1.1, 5 October 2026

  • Demo machine names: benzene, toluene, xylene, phenol, styrene. No functional change.

v0.1.0, 5 October 2026

  • First release.