Tailcat.Link
0.5.3
dotnet add package Tailcat.Link --version 0.5.3
NuGet\Install-Package Tailcat.Link -Version 0.5.3
<PackageReference Include="Tailcat.Link" Version="0.5.3" />
<PackageVersion Include="Tailcat.Link" Version="0.5.3" />
<PackageReference Include="Tailcat.Link" />
paket add Tailcat.Link --version 0.5.3
#r "nuget: Tailcat.Link, 0.5.3"
#:package Tailcat.Link@0.5.3
#addin nuget:?package=Tailcat.Link&version=0.5.3
#tool nuget:?package=Tailcat.Link&version=0.5.3
Tailcat.Link
Two machines that cannot see each other — different networks, no port forwarding, no VPN, no account — pair once with a short code and stay in touch for as long as they are switched on.
The link survives what a long-lived connection actually meets: a Wi-Fi network changing under either end, a relay going away, and either machine rebooting. Nothing has to be re-entered, and there is nothing to call when it drops.
Pair once
On the machine to be reached:
await using ILink link = await TailcatLink.HostAsync("my-app");
Console.WriteLine(link.InvitationCode); // show once, as text or a barcode
link.OnRequest(command => Run(command)); // answer whatever is asked
On the machine doing the reaching, with that code the first time only:
await using ILink link = await TailcatLink.JoinAsync("my-app", code);
string answer = await link.RequestAsync("status");
Every later start needs nothing from anybody:
await using ILink link = await TailcatLink.JoinAsync("my-app");
Both ends are equal once paired: each can ask, each can answer, and each can
send a message the other did not ask for (NotifyAsync).
Send anything, whatever its size
There is no size limit. A request of a kilobyte and one of twenty gigabytes go the same way, and so do their answers:
// on the machine answering
link.OnRequest(async (request, ct) =>
{
await request.SaveToAsync(Path.Combine(inbox, request.SuggestedFileName), null, ct);
return LinkContent.FromString("saved");
});
// on the machine asking
await using IncomingTransfer answer = await link.RequestAsync(LinkContent.FromFile(@"D:\wakacje\film.mkv"));
Console.WriteLine(await answer.ReadAllTextAsync());
Content can say what it is — a name, a content type and metadata of your own, which the receiving handler reads before the content arrives:
await using IncomingTransfer answer = await link.RequestAsync(
LinkContent.FromFile(@"D:\recordings\kitchen.mp4") with
{
ContentType = "video/mp4",
Metadata = JsonSerializer.SerializeToUtf8Bytes(new Recording("kitchen", DateTimeOffset.Now)),
});
link.OnRequest(async (request, ct) =>
{
Recording? recording = JsonSerializer.Deserialize<Recording>(request.Metadata.Span);
await request.SaveToAsync(Path.Combine(inbox, recording!.Camera, request.SuggestedFileName), null, ct);
return LinkContent.FromString($"saved {request.BytesReceived} bytes");
});
RequestAsync(byte[]) and NotifyAsync(byte[]) are the same thing with the
content in memory, and have no limit either. A file handed over as a file, or
content as a stream, is never held in memory by the link.
A transfer handler of its own is still there, for an application that wants
files kept apart from its requests. SendAsync takes a stream of any size —
a 20 GB video is an ordinary use of it — and neither machine ever holds more
than a few megabytes of it.
On the machine receiving:
link.OnTransfer(async (transfer, ct) =>
await transfer.SaveToAsync(Path.Combine(inbox, transfer.SuggestedFileName), null, ct));
On the machine sending:
await link.SendFileAsync(@"D:\wakacje\film.mkv",
progress: new Progress<TransferProgress>(p => Console.Write($"\r{p.Fraction:P0}")));
There is nothing to chunk, and nothing to restart. A session that dies mid-file is answered by asking the other machine where it got to and carrying on from exactly there, into the same handler, which never learns that anything happened — so a laptop that changes Wi-Fi network during a twenty-gigabyte transfer resumes mid-file rather than starting again.
SendBytesAsync is the same thing for an array already in memory, for when
what you have is two gigabytes rather than a path. SaveTransfersTo is the
whole receiving side for an application that just wants the files in a
directory.
The three things worth knowing:
- The reader sets the pace. Bytes move no faster than the receiving handler consumes them, so sending to a slow disk costs memory on neither machine.
- Resuming needs content that can be rewound — a file or an array can be; a socket, or a stream being generated as it is sent, cannot, and a transfer from one of those fails when its session does rather than delivering something with a hole in it.
- The sender's
SendAsyncreturns when the receiving handler has returned. A transfer reported as sent is one the other machine has finished dealing with, and a handler that throws fails the sender's call instead of being retried.
More than one machine
HostAsync pairs one. An application with several clients — a bridge for a
handful of phones, a code per device — uses HostManyAsync instead:
await using ILinkHost host = await TailcatLink.HostManyAsync(
"my-app", new LinkOptions { MaxPeers = 4 });
host.SetRequestHandler((peer, request, ct) => Handle(peer, request, ct));
host.PeerJoined += (_, e) => Show(e.Peer.Name, e.Peer.PairedAt);
LinkInvitation invitation = await host.InviteAsync(new InvitationRequest
{
Label = "kitchen phone", // for your own list; it never goes on the wire
Lifetime = TimeSpan.FromMinutes(2),
SingleUse = true,
});
Draw(invitation.Code, invitation.ExpiresAt);
await host.ForgetPeerAsync(peer); // unpair one device, and the code it came in on
One identity, one stored file, one relay region and one node underneath all of
it — which is what a link per client would otherwise multiply. HostAsync is
exactly this with MaxPeers = 1 behind the narrower ILink, so there is one
set of pairing rules rather than two that could drift.
Two things are worth reading twice. MaxPeers is a security bound, not a
resource one: every admitted peer reaches your handler, and lowering it
unpairs the machines the host saw longest ago rather than quietly keeping a
store written under a wider bound. And ILinkPeer.Name is
unauthenticated — it is what the other machine said about itself in
JoinAsync(..., new JoinRequest { DisplayName }), and the public key is the
only thing a session proves.
The stored file is versioned: a file written by 0.3 is read and rewritten in the new shape, and an older build refuses the new shape rather than silently losing every peer but the first.
A channel, for what is neither a request nor a file
Realtime frames — audio, telemetry, input events — are a round trip and a
ledger entry each as requests, and a promise of durability that is actively
wrong as transfers. OpenChannelAsync is the third shape: ordered within
the channel, and not durable. It ends with the session carrying it rather
than being resumed, and Closed says which of "the peer hung up" and "the
session died" happened.
host.OnChannel("telemetry", async (peer, channel, ct) =>
{
await foreach (ReadOnlyMemory<byte> frame in channel.ReadAllAsync(ct)) Consume(frame);
});
await using ILinkChannelWriter audio = await link.OpenChannelAsync("audio");
await audio.SendAsync(frame);
A name nothing is listening for is refused rather than swallowed.
State, errors and options
link.StateChanged += (_, e) => Console.WriteLine($"link is {e.State}");
try
{
await link.RequestAsync("restart the service");
}
catch (RemoteHandlerException ex) // the other machine's handler threw
{
Console.WriteLine(ex.Message);
}
catch (LinkTimeoutException) // nothing moved for too long
{
}
await using ILink configured = await TailcatLink.HostAsync("my-app", new LinkOptions
{
Store = new FileLinkStore(@"D:\my-app\state"),
LoggerFactory = loggerFactory,
RequestDeadline = TimeSpan.FromMinutes(1), // silence a request may go through
TransferStallTimeout = TimeSpan.FromMinutes(2), // the same for files and LinkContent
});
Testing without a network
Tailcat.TestSupport stands both ends up against an in-memory relay:
await using FakeDerpRelay relay = new();
var gateways = new FakeRelayGatewayFactory(relay);
LinkOptions Offline() => new() { Gateway = gateways, Store = new InMemoryLinkStore() };
await using ILink host = await TailcatLink.HostAsync("test", Offline());
host.OnRequest(text => text.ToUpperInvariant());
await using ILink client = await TailcatLink.JoinAsync("test", host.InvitationCode.Value, Offline());
Assert.Equal("PING", await client.RequestAsync("ping"));
What it handles for you
- A pairing that survives a restart. The identity key is generated once and stored — encrypted to the user account with DPAPI on Windows, mode 0600 inside a 0700 directory on Unix, written through a temporary file so a power cut cannot leave half of one.
- An address that stays valid. A host pins the relay region it first measured, so the code published once keeps pointing at it however far the machine moves.
- Reconnection. A relay outage, a network change, a peer that rebooted, and a peer that has been away for a day all look the same from here — a session that stopped answering — and all get the same answer.
- Detection that works. Writing into a dead session succeeds, so silence is what a machine that has gone away looks like: a heartbeat and a per-request timeout are what notice.
- Requests that survive a reconnection. A request is re-sent across a reconnection but not re-run: it carries an id, and a peer that already answered replies from memory.
- Transfers that survive one mid-file. The receiving machine keeps what has arrived, and the handler reading it, for ten minutes: a transfer that comes back on a later session continues into it rather than starting a second one.
- Pairing that cannot be stolen. The code carries a secret that expires, and a machine without it is refused, however well it knows the address.
Where it sits
Tailcat.Link is the friendly layer on top of Tailcat.Net, which meets a
peer at one of Tailscale's DERP relays, authenticates it with sealed
messages, punches a direct UDP path when it can, and carries QUIC over
whichever path is better. The relay only ever sees QUIC packets it cannot
read.
Requires .NET 10. QUIC — which is what carries a session onto a direct path —
needs Windows 11 or Server 2022 and later, macOS, or Linux with libmsquic
installed; .NET does not carry its own copy. Where it is missing, including
on Windows 10, the link still works: the two ends negotiate relay1 instead
and the session stays on the relay, slower but no different to use. See
docs/relay1.md.
A browser can hold one of these links too, over the same relay1: the host
is written exactly as above and never learns which arrived. The JavaScript
client lives in the repository under clients/browser and is not published
on npm. Requests, notifications, content and transfers work the same on
either end, resuming in both directions.
Tailcat.Net and the two layers under it are not published separately; their
assemblies ship inside this package, so one reference is the whole thing.
Three optional packages sit beside it, each of which you may ignore entirely:
Tailcat.Link.Json— typed requests and handlers overSystem.Text.Json, through source-generated metadata so they survive trimming and ahead-of-time compilation. Separate so thatILinkstays as narrow as it is and nobody who wants bytes pays for a serializer.Tailcat.Link.Extensions.DependencyInjection—services.AddTailcatLinkHost("my-app")and anIHostedServicethat owns start-up and shutdown, because dispose ordering against a supervision loop is what everyone hand-rolls and gets subtly wrong. A worker that takesILinkHostand sets a handler in its constructor is registered on the host the moment it is built, rather than being told there is not one yet.Tailcat.TestSupport— an in-memory DERP relay, the node gateway factory that builds real nodes against it, and a manually advancedTimeProvider. Without it, a test of your own handler needs a network.
Known limits
Hole punching between two different NATs is verified: a home connection and an LTE carrier NAT moved off the relay onto a direct path, 69 ms down to 30 ms. Between two sufficiently hostile NATs there is no direct path at all, and then the session stays on the relay — which works, but is slower and carries every byte past somebody else's server.
The authentication design has not been reviewed by anyone outside this project.
Licence
BSD-3-Clause. Parts are ported from tailscale/tailcat and carry Tailscale's copyright; see the LICENSE file in the package. Not affiliated with or endorsed by Tailscale Inc.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- BouncyCastle.Cryptography (>= 2.7.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Sodium.Core (>= 1.4.1)
- System.Formats.Cbor (>= 10.0.0)
- System.Security.Cryptography.ProtectedData (>= 10.0.0)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on Tailcat.Link:
| Package | Downloads |
|---|---|
|
Tailcat.TestSupport
Test doubles for Tailcat.Link: an in-memory DERP relay, a node gateway factory that builds real nodes against it, and a manually advanced TimeProvider. Together they stand a whole link up in a unit test — pairing, reconnection, transfers and all — with no network, no second machine and no sleeping. |
|
|
Tailcat.Link.Json
Sends and answers requests over a Tailcat link as JSON instead of bytes, through System.Text.Json's source-generated metadata so it survives trimming and ahead-of-time compilation. A separate package on purpose: ILink stays exactly as narrow as it is, and nobody who wants bytes pays for a serializer. |
|
|
Tailcat.Link.Extensions.DependencyInjection
Registers a Tailcat link host in the service collection and gives it the application's own lifetime, so nothing has to hand-roll start-up, shutdown and dispose ordering against a supervision loop. |
GitHub repositories
This package is not used by any popular GitHub repositories.
One exchange: RequestAsync, NotifyAsync and SendAsync carry content of any size — from a kilobyte of JSON to a 20 GB file — in blocks that neither machine holds more than a few of, resume in both directions when a session dies, and run the handler once. The library sets no size limit. The browser client speaks exchanges the same way, resuming in both directions and taking transfers. Hole punching between two different NATs is verified: a home connection to an LTE carrier NAT, 69 ms relayed down to 30 ms direct. Where QUIC is missing, including Windows 10, the session is carried by the relay instead and works.