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
jauntcommand 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:
- 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. - Downloads the binary and its
.sha256file from the latest GitHub release, over HTTPS only (it refuses plainhttp://, including a redirect to it). - Checks the binary against the checksum, runs it once with
--versionto 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. - Writes the machine name and the relay into jaunt’s config file, if you gave
--nameor--relay. Values already in the config are kept. - Tells you whether
~/.local/binis on yourPATH, 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.
| Option | Does |
|---|---|
--version TAG | install this release, for example v0.1.2, instead of the latest |
--relay URL | write the relay URL into jaunt’s config (https://host[:port][/]) |
--name NAME | write this machine’s name into jaunt’s config: lowercase letters, digits and -, at most 32 characters, not starting or ending with - |
--up | also install and start the user service, as jaunt up does |
--no-service | do not suggest the service; the daemon then starts on first use. Cannot be combined with --up |
--from-file FILE | install this binary instead of downloading; FILE.sha256 is checked if it is next to it |
--bin-dir DIR | where to put the binary (default ~/.local/bin) |
Two environment variables change where the download comes from:
| Variable | Does |
|---|---|
JAUNT_REPO | the GitHub repository as owner/name (default ZahakJ/jaunt) |
JAUNT_BASE_URL | another 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
| Platform | Status |
|---|---|
| Linux x86_64 | release binary |
| other Linux | build from source |
| macOS | build 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 URLstores the relay for the whole mesh. Every machine that links later receives it with the member list, so you set it once.jaunt relaywith no argument shows the current one. A second URL (jaunt relay set URL1 URL2) lists two relays.--ca FILEtrusts a relay certificate signed by your own certificate authority.jaunt upinstalls and starts the background service.jaunt linkopens the pairing screen and shows a code like4127-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.
- 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.
- 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.
- 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.
- 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.
- Human check. The sigil and words are computed from the shared key, so both screens agree only when both sides hold the same key.
- Only after both confirmations does the inviting machine send the roster and the relay list, and the new machine writes them.
- 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 see | What 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 burnt | a 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' again | as it says |
jaunt join says this code was already tried from this machine and is not tried twice; ask for a new one | each 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 URL | this 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
| Command | Run it on | Does |
|---|---|---|
jaunt unlink toluene | any other member | revokes 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-sessions | any other member | the same, and ends the tmux sessions toluene created, here and on every machine that confirms |
jaunt unlink toluene --cascade | any other member | also revokes every machine toluene linked |
jaunt leave | the machine itself | takes this machine out of the mesh, tells the others, and deletes its key and roster |
jaunt reset | the machine itself | forgets 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.
| Key | Does |
|---|---|
arrows, or j / k | choose a machine |
| Enter | hop to it |
l | link a new machine |
? | help |
q | quit |
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:workopens or creates a session calledwork, separate from the default one. - Prefixes. Any unambiguous start of a name works:
jaunt tolisjaunt toluene. If a prefix matches two machines, jaunt lists them and asks you to type more of the name. jaunt hop tolueneis the same command spelled out.
A hop needs a terminal. To run something from a script, use jaunt run.
The other commands
| Command | Does |
|---|---|
jaunt ls | every machine: direct, relayed or offline, latency, host state, sessions |
jaunt ls --json | the 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 -a | run 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 8080 | make toluene’s 127.0.0.1:8080 available here at 127.0.0.1:8080, until Ctrl-C |
jaunt fwd toluene 8080:9090 | the 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:
| Variable | Holds |
|---|---|
JAUNT_FROM | the machine you came from |
JAUNT_MACHINE | the 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 to | Do | Session on the other machine |
|---|---|---|
| leave a hop | Enter, then ~, then . | kept |
| see the escape keys | Enter, then ~, then ? | |
type a literal ~ at the start of a line | Enter, then ~, then ~ | |
| detach from the remote tmux (not nested) | Ctrl-b, then d | kept |
| go back | jaunt toluene (same session, same screen) | |
| use a second session there | jaunt toluene:work | separate session |
| end the session for good | type exit in the remote shell | ended |
| quit the mesh view | q | |
| stop linking | Ctrl-c (or n at the question) |
On the way out a banner says where you are again: ◂━━ back on benzene · session kept on toluene.
When the link drops
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
| Key | Does |
|---|---|
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 |
F12 | hand 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
F12once (with the snippet), then use Ctrl-b as usual; pressF12again 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-bd. 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
| Command | Does |
|---|---|
jaunt fwd toluene 8080 | toluene’s 127.0.0.1:8080 here at 127.0.0.1:8080, until Ctrl-C |
jaunt fwd toluene 8080:9090 | the same, here at port 9090 |
jaunt fwd toluene 5432 --keep --name db | held by the service, kept in config.toml, back after every restart |
jaunt unfwd db | stop a held forward, by name or local port |
jaunt rfwd toluene 3000 | the 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.
Links from the other machine
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:
| Tool | The far machine | The local end |
|---|---|---|
ports | reads its own socket table; names only this user’s processes | prints |
tabs | reads session files, never writes; lists http/https only | opens what you choose |
send-tab, links | opens only http/https URLs, never file: or other schemes | |
notify | shows text, titled with the sender’s name, control characters removed | |
clip | refuses unless its owner ran jaunt clip on | |
fwd, open | connects to its own loopback | listens on 127.0.0.1 only |
rfwd | listens on its own 127.0.0.1 only, while the command runs | |
socks | connects where it can reach | listens 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:
| Condition | What happens |
|---|---|
| on mains, every temperature below the limit | the 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 point | the 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 answering | the 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 hangs | the 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 statusandjaunt checksay so, and the guard tries again every minute. You can close this gap once, as root, withscripts/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.
| Script | Changes | Undo |
|---|---|---|
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 login | host-polkit-rule.sh undo |
logind-lid-on-ac.sh install | writes a logind drop-in, HandleLidSwitchExternalPower=ignore, and reloads logind. No heat protection | logind-lid-on-ac.sh undo |
wake-on-lan.sh install <connection> | sets NetworkManager wake-on-LAN/WLAN to magic for that connection | wake-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.
| Port | For |
|---|---|
| 443/tcp | HTTPS + WebSocket at /relay, with its own certificate |
| 7842/udp | QUIC address discovery. Without it, machines behind NAT never go direct |
| 80/tcp | Let’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.
| Where | Cost | Direct paths across networks |
|---|---|---|
| a small VPS | a few dollars a month | yes |
| a home machine with 443/tcp and 7842/udp forwarded | free | yes; the home address is public |
| a home machine behind a Cloudflare tunnel | free | no; everything away from home is relayed |
| Fly.io | dedicated IPv4 is paid | no, as shipped |
VPS
Any Linux VPS with a public IPv4 address.
-
Point a DNS name at it (A, plus AAAA with IPv6). With Cloudflare DNS, set the record to “DNS only”, not proxied.
-
Open 80/tcp, 443/tcp and 7842/udp.
-
Copy this directory over, set
hostnameandcontactinrelay.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/pingWithout Docker:
cargo install iroh-relay@1.3.0 --features server --locked, the same config, and a systemd unit withAmbientCapabilities=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
| Threat | How |
|---|---|
| someone reading or changing traffic, including the relay | QUIC with TLS 1.3, end to end between the machines’ own keys; the relay forwards ciphertext |
| an unknown machine connecting | the 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 id | ids are public keys; knowing one grants nothing |
| someone intercepting a pairing | SPAKE2: 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 unlink | permanent 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 network | after 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 revocations | roster merges are bounded and take tombstones first |
| other users on the same machine | key 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 terminal | control characters are removed before printing |
| a member pushing links, notifications or clipboard text | only 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 network | every local end (fwd, open, socks, and rfwd on the far side) listens on 127.0.0.1 only |
| a laptop overheating in a bag | host mode only on mains and below the heat limit; anything unknown means sleep |
What is not defended
| Threat | What it means, what to do |
|---|---|
| a stolen laptop that is unlocked and not yet revoked | it 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 knows | when 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 member | trust 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 analysis | the 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 service | the relay can drop traffic; anyone who knows the relay URL can block pairing (the pairing fails, nothing leaks) |
| a tampered release | install.sh checks the binary only against a checksum from the same place; there is no signature |
| root on a machine, or malware in your account | out 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-sessionsthe 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. Plainunlinkwarns 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 --jsonlists 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):
| kind | after the reply |
|---|---|
sync | client sent one Json roster right after the header; host replies Json with its merged roster |
ping | host 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 |
tabs | Json [Tab {browser, title, url}] from the browsers’ session files (src/services/tabs.rs) |
ports | Json [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):
| request | response(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):
| step | fields | when |
|---|---|---|
waiting | code, expires_at_ms, relays, first_machine | inviter: the code is live (5 min, one attempt) |
dialing | relays | joiner: looking for the inviter’s rendezvous endpoint |
rendezvous | the two temporary endpoints are connected (unauthenticated) | |
key_exchange | SPAKE2 done (a wrong code shows only at the next step) | |
sealed | other {id,name} | real ids and names opened under the PAKE key: the code was right |
confirm | other, 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) |
confirmed | here: bool | one side said yes (both arrive before linked) |
linked | other, members | both said yes; the roster is written on this side |
fan_out | machine {id,name}, ok, detail | inviter: one other member’s acknowledgement of the new entry |
fan_out_done | told, not_yet | inviter: every other member was tried (10 s each, in parallel) |
failed | reason | the 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:
flockondata/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,
jauntstartsjaunt daemondetached (setsid, log tostate/daemon.log) orsystemctl --user start jauntif the unit is installed andJAUNT_HOMEis 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 showstoluene ╌ no answer for Ns · waiting for the link · ~. to give upuntil 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.rspicks the colour mode (JAUNT_COLOR=truecolor|16|noneforces one;NO_COLOR/--no-color; off a terminal no escapes at all) and the glyph set (JAUNT_ASCII=1or a non-UTF-8 locale → ASCII).model.rsturns aStatusSnapshotplus the event stream into what is drawn;mesh.rsdraws 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.rsare the live view (barejaunton a terminal, and the hiddenjaunt pickfor the tmux popup: Enter opens the hop in a new tmux window);ls.rsisjaunt lson a terminal;ritual.rsis whatjaunt link/joinshow on a terminal (off a terminal they print the plain lines02-link.shparses);demo.rsisjaunt demo mesh|link|ls|hop [--at S] [--join]on simulated data;tmux.rsisjaunt tmux-setup.ls_plainstays stable: tab-separated name, state, rtt, host, sessions, id; this machine first with stateself. - A hop from inside tmux prints a one-time hint about the prefix (marker
state/tmux-hint-shown), unless the local tmux has sourcedjaunt.tmux(@jaunt_tmux). - Host mode (
src/awake/,jaunt host on|off|status): seedocs/awake.md. The lock is asystemd-inhibitchild 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 throwawaydebian:12container with rustup (toolchain pinned, default 1.97.1); caches undertarget/musl-build/(about 1.8 GB); outputdist/jaunt-x86_64-linux-musland.sha256. Static-pie, about 10.7 MB. The harness installs a binary under its file name, so copy it to a file calledjauntbefore running the e2e suite with it.install.sh(POSIX sh, no root): downloads the release asset and its.sha256from GitHub (JAUNT_REPO,--version TAG,JAUNT_BASE_URLfor another base,file://works), falls back togh release downloadon a 404 (private repository), or takes--from-file. Verifies the checksum, writes~/.local/bin/jauntatomically (--bin-dir), writesname/relaysinto 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 tojaunt unlink <name>from another machine. tmux sessions are left alone.deploy/relay/: Dockerfile,relay.toml,fly.tomland 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 forunlink --cascade, address hints.test/e2e/00-host.sh: two daemons on the host (JAUNT_HOME undertarget/tmp, orHOST_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 byjaunt 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 bya 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-sessionsends 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; thenunlink <id prefix of a revoked c> --cascadeworks; last, with a newly linked c offline and b frozen 15 s, a’s firstrun btakes at most ~7 s and the next two under 1.5 s (one settling wait per absence).test/e2e/06-paths.shalso asserts that a daemon restarted while the relay is down is reached again on the direct path (address hints), and05-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 (writesdocs/measurements.md). Helpers:hop_session.py(PTY driver run on the host throughdocker exec -it, so disruptions run on the host meanwhile),keystroke.py, and inlib.shh_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:
- 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.
- This network. Some networks block outgoing UDP. Run
checkfrom another network to compare. - 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 relaymeans 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 lsshows its host state;will sleep on lid closeoron batterymean exactly that. - Its daemon is not running. After a reboot without the user service, nothing starts the daemon until a
jauntcommand runs there.jaunt upon that machine fixes this for good. - It cannot reach the relay, and has no direct path to you. Run
jaunt checkon it.
“link lost · retrying”
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:
| Cause | What you see, what to do |
|---|---|
| on battery, or a charger too weak to keep up | released 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 hot | released 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 unreadable | unknown means sleep |
| no local login since boot | no 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 login | this 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 closed | it 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
| What | Where |
|---|---|
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 service | the 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 to | the 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 portslists what listens on a machine’s loopback; a key forwards one and opens it.jaunt open toluene:7707forwards a web port and opens it in one step.- Held forwards:
jaunt fwd --keep,jaunt unfwd, listed injaunt ls, back after restarts; each connection dials afresh. jaunt rfwd(likessh -R) andjaunt socks(a SOCKS5 proxy through another machine).jaunt tabspulls another machine’s open browser tabs;jaunt send-tabpushes 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 notifyandjaunt run --notify.- The install line no longer needs the GitHub CLI.
v0.1.2, 5 October 2026
jaunt uptakes 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.