Copying Files Like It's 1987
laplink-p2p: sending files between two machines that were never introduced, and now updating itself while it's at it

I am, as established, approximately the age of the C language, which means I am also old enough to remember an actual LapLink cable: a slightly too-short parallel cable you’d run between two PCs’ printer ports, plus a piece of DOS software on each end, so you could shovel files from one machine to another without a network, because in 1990 there frequently was no network to speak of. It was magic. It was also, from the vantage point of thirty-odd years, the single most 1990s way to move a spreadsheet that has ever existed.
Recently I ran into the problem of having to share some files among some friends, and running through my options, and my nonexistent budget, and quite a bit of ‘oh-iroh-is-so-shiny’, led me to believe that a new tool was in order. So of course I named my file transfer tool laplink-p2p.
I have to confess that I started with an example that the good folks at n0, the purveyors of fine libraries, bundled with their wares. My recollection is that it was transmogrified thoroughly through my pawing on it. If anybody finds a resemblance to the original, let me know and I’ll fall on my sword.
What it does
laplink-p2p moves files and directories between two machines over the actual internet, peer to peer, with no server in the middle for me (or you) to manage, configure, or pay for. It installs three binaries:
ll send <path>/ll receive <ticket>— a one-shot copy. Point it at a file or a directory, get back a ticket (a long opaque string), hand the ticket to the other side however you like (Slack, a phone call, carrier pigeon), and they runll receivewith it.ll-serve <folder>/ll-tui <ticket>— the same idea, but long-running and browsable, and, as of the last few months of evenings poking at this, live:ll-servewatches the folder and pushes updates to anyone connected the moment something on disk changes, andll-tui, the terminal client, can notice it’s talking to a server that’s serving a newer version of itself and update in place.
None of this touches a cloud bucket, and at most involves a public relay that cannot see into the encrypted contents of the communications. Under the hood it’s built on iroh and iroh-blobs, a pair of crates out of Number Zero that do the hard work for you: NAT hole punching (falling back to a relay when hole punching can’t find a path, which on some corporate networks is always), and BLAKE3-verified, resumable content transfer. Every node has a 256-bit node ID instead of an IP address, so a ticket keeps working even if the sending machine’s address changes mid-transfer, or you regenerate the ticket from a laptop that’s now on a different wifi network than it was an hour ago.
How it goes about it
One endpoint builder to rule them all
All three binaries end up calling the same build_endpoint function in endpoint.rs:
pub struct EndpointConfig {
pub secret_key: SecretKey,
pub alpns: Vec<Vec<u8>>,
pub relay: RelayModeOption,
pub magic_ipv4_addr: Option<SocketAddrV4>,
pub magic_ipv6_addr: Option<SocketAddrV6>,
pub publish_addr: bool,
pub lookup_by_dns: bool,
}
which is a small thing, but it means every knob for “how do I find the other side” — relay on/off/custom, publish my address to DNS so I can be found by node ID alone, look addresses up by DNS when a ticket doesn’t carry any — lives in exactly one place, and each binary just flips a different combination of the same seven fields depending on whether it’s the one being found (ll send, ll-serve: publish_addr: true) or the one doing the finding (ll receive, ll-tui: lookup_by_dns when the ticket didn’t already carry an address).
Files become one hash
Everything you send gets content-addressed. transfer.rs walks the path with walkdir, imports every regular file it finds in parallel (one task per num_cpus::get(), via futures-buffered), and folds the resulting (name, hash) pairs into an iroh_blobs::Collection, which itself gets stored and hashed:
pub async fn import_collection(
path: PathBuf,
db: &Store,
) -> anyhow::Result<(TempTag, u64, Collection)> {
let parallelism = num_cpus::get();
let data_sources = walk_data_sources(&path)?;
let mut names_and_tags = n0_future::stream::iter(data_sources)
.map(|(name, path)| {
let db = db.clone();
async move {
let (temp_tag, size) = import_one(&db, path).await?;
anyhow::Ok((name, temp_tag, size))
}
})
.buffered_unordered(parallelism)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<anyhow::Result<Vec<_>>>()?;
names_and_tags.sort_by(|(a, _, _), (b, _, _)| a.cmp(b));
let size = names_and_tags.iter().map(|(_, _, size)| *size).sum::<u64>();
// collect the (name, hash) tuples into a collection; keep the tags around
// so the data doesn't get gc'd before the collection itself protects it.
let (collection, tags) = names_and_tags
.into_iter()
.map(|(name, tag, _)| ((name, tag.hash()), tag))
.unzip::<_, _, Collection, Vec<_>>();
let temp_tag = collection.clone().store(db).await?;
drop(tags);
Ok((temp_tag, size, collection))
}
buffered_unordered is doing the actual work of “import everything at once instead of one file at a time”: every file gets its own import_one future, and up to num_cpus::get() of them run concurrently, in whatever order they finish. That one .sort_by right after is the only thing standing between that concurrency and a non-deterministic ticket — without it, hashing the same folder twice could produce two different collection hashes just because the files happened to import in a different order the second time. That one root hash, plus your node’s address, is the entire ticket.
This is also what makes resuming a free feature instead of a project: because the receiving side asks the blob store “what do I already have for this hash” before asking the network for anything, an interrupted ll receive on the exact same ticket just picks up the missing pieces. Nobody had to write resume logic. It falls out of everything being content-addressed and verified.
The one-shot path: ll send / ll receive
ll send generates a random node identity for the run (unless you set IROH_SECRET), imports your path into a throwaway store directory in the current directory, spins up a router that serves that store over the blobs protocol, and prints:
imported directory ~/some-folder, 4.2 MiB, hash bafkr4i...
to get this data, use
ll receive bafkr4i...
Then it just waits on Ctrl-C. When you hit it, it tears down the router, deletes the temp store, and exits. There’s a small touch here I didn’t expect to enjoy as much as I did: while it’s waiting, it also listens for a bare c keypress (via crossterm’s raw mode) to copy the ll receive ... line straight to your clipboard over OSC52 — which works over SSH, unlike basically every other clipboard trick. The part I actually like is what happens if you hit Ctrl-C while it’s in raw mode intercepting keys:
// Ctrl+c is pressed
Ok(Event::Key(KeyEvent {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
})) => {
disable_raw_mode()
.unwrap_or_else(|e| eprintln!("Failed to disable raw mode: {e}"));
#[cfg(unix)]
// Safety: Raw syscall to re-send the SIGINT signal to the console.
// `raise` returns nonzero for failure.
if unsafe { raise(SIGINT) } != 0 {
eprintln!("Failed to raise signal: {}", io::Error::last_os_error());
}
// ...and the Windows equivalent via GenerateConsoleCtrlEvent
}
Raw mode swallows Ctrl-C as an ordinary keypress instead of letting the terminal turn it into a real SIGINT, which is exactly what you want for catching c — but it means the moment you actually want to quit, you have to manually put the terminal back the way you found it and re-raise the signal yourself, or the process just sits there ignoring the one keystroke everyone’s muscle memory reaches for. Small feature, but it’s the kind of detail that tells you the thing has actually been used in anger a few times.
ll receive <ticket> is the mirror image, and if you don’t pass a ticket at all, it reuses the last one you gave it — tickets get quietly remembered in your OS config directory, so ll receive with no arguments just means “get me that thing again.”
The server
ll-serve <folder> differs from ll send in that it remembers the ticket it issued. The secret key and the ticket both live inside .ll-serve-store/ next to the folder being served, so restarting it against the same folder tomorrow comes back with the same node ID, and the ticket you handed out yesterday still works.
It also runs a second, tiny protocol side-by-side with iroh-blobs on the same endpoint — a listing protocol, with its own ALPN (iroh-file-server/list/0), that answers “what files do you have” with one Entry per file: path, size, hash, and a ready-made BlobTicket for that entry alone, so downloading it reuses the exact same blobs-fetch code path ll receive uses for anything else.
The wire types make the “snapshot vs. live” split explicit:
/// Wire request, version-tagged so the protocol can evolve without an outright wire break.
pub enum ListRequest {
/// One-off snapshot request.
V0,
/// Subscribe to live directory listing updates.
/// The server will immediately send the current listing snapshot, and then stream
/// updates as filesystem changes occur.
SubscribeV0,
}
/// An update sent over the subscription stream.
pub enum ListingUpdate {
V0(Listing),
}
The listing used to be nothing but that V0 snapshot taken at startup — serve, print a ticket, and whatever you added to the folder after that was invisible until you restarted. That bothered me enough to fix it. ll-serve now runs a filesystem watcher (notify, debounced 200ms by default, capped at 2 seconds of batching so a big rsync doesn’t trigger a re-index per file) that incrementally re-hashes whatever changed and republishes an updated listing. The listing protocol itself grew a second request type to go with it: alongside the original one-shot V0 snapshot, there’s now SubscribeV0, which opens a long-lived stream, sends the current listing immediately, and then pushes a new one — length-prefixed, postcard-encoded frames over the same stream — every time the watcher notices a change, for as long as the client stays connected. ll-tui subscribes instead of fetching once, folds each pushed update into its tree view, and does the mildly fiddly bit of preserving your current row selection across a re-render instead of snapping the cursor back to the top every time somebody drops a new file into the folder you’re browsing mid-download.
The tool updates itself, using itself
This is the part I like most. ll-tui also compares the listing it’s looking at against its own version (env!("CARGO_PKG_VERSION")), using a small filename convention to recognize release artifacts sitting in the served folder:
pub fn parse_update_filename(filename: &str, target: &str) -> Option<(semver::Version, AssetKind)> {
if let Some(stem) = filename.strip_suffix(".tar.gz") {
if let Some(version) = parse_archive_stem(stem, target) {
return Some((version, AssetKind::Archive));
}
}
// ...same for .zip...
let stem = filename.strip_suffix(".exe").unwrap_or(filename);
if let Some(version) = parse_binary_stem(stem, target) {
return Some((version, AssetKind::StandaloneBinary));
}
None
}
fn parse_archive_stem(stem: &str, target: &str) -> Option<semver::Version> {
let rest = stem.strip_prefix("ll-")?;
let target_suffix = format!("-{target}");
let ver_str = rest.strip_suffix(&target_suffix)?;
parse_version(ver_str)
}
So ll-v0.31.0-linux-x86_64.tar.gz sitting in a served folder parses as version 0.31.0, target linux-x86_64, kind Archive — nothing fancier than string stripping either end of the filename and handing the middle to a semver parser. If it finds one with a newer version than itself, for a target matching the machine it’s running on, it puts a banner at the top of the screen (“Update available: v0.31.0 — press ‘u’”), and pressing u does exactly what pressing Enter on any other file would do: it downloads the archive via the same receive_single function that pulls down every ordinary file in this tool. The only thing that’s actually new is what happens after the bytes land — unpack the archive, and atomically replace the running executable, either via self_replace (for the binary currently executing) or a temp-file-plus-rename dance for its siblings in the same suite.
There’s no update server, no separate channel. Shipping a new version is: build it, drop the archive in a folder ll-serve is already serving, and every ll-tui pointed at that folder finds out within a debounce interval and can pull it in with one keystroke, using machinery that was already fully exercised by ordinary file transfers. It is a pattern I saw in an old Japanese Java app made by HULFT (hi, guys!) that I really liked (the pattern, not the app).
(There’s also a qr_to_braille function sitting quietly in qr.rs, unreferenced by any binary, that renders a QR code as a grid of Unicode Braille characters — packing each glyph’s eight dot positions into a 2×4 block of the bitmap so a QR code that would normally eat half a terminal fits in a quarter of the space. It has exactly one caller, and that caller is its own test. I built the whole thing on a tangent one evening, imagining scanning a ticket off a phone, and then never wired it into ll-serve or ll-tui. It’s still in there. Some day.)
Testing, testing
The integration tests (tests/cli.rs) build and spawn the real ll, ll-serve, and ll-tui binaries as subprocesses and drive them end to end: send a file, receive it, diff the bytes; touch a file in a served folder and assert a connected subscriber’s listing updates; drop a fake ll-v99.0.0-<target>.tar.gz into a served folder and assert ll-tui finds it, downloads it, and replaces itself with it. I trust a passing integration test suite here more than I trust most mock-heavy, passing unit test suites.
Future directions
Obviously, caveat emptor, this is alpha software, but it is useful, and fun, and you can change things around all you want. There has been zero testing on Windows and MacOS, but we know we can connect across e.g. cell phone network into a private NAT.
My friend Julio, with his particular brand of OCD, suggested the roadmap to “turning this into a real product”, so there is some intention to integrate ll-serve into ll-tui, with the intention of serving multiple locations, and receiving from multiple servers.
Of course, the server needs to be running, so turning it into some sort of service would be useful, and it is the kind of thing that is a PITA to do in any OS, so doing it for the user is nice. Otherwise my interest runs more in the direction of repeating the concept but with voice calls, and eventually video calls (skype-p2p?), where the ticket is your personal phone number, so to speak.