LAN.Lib
2.4.261
dotnet add package LAN.Lib --version 2.4.261
NuGet\Install-Package LAN.Lib -Version 2.4.261
<PackageReference Include="LAN.Lib" Version="2.4.261" />
<PackageVersion Include="LAN.Lib" Version="2.4.261" />
<PackageReference Include="LAN.Lib" />
paket add LAN.Lib --version 2.4.261
#r "nuget: LAN.Lib, 2.4.261"
#:package LAN.Lib@2.4.261
#addin nuget:?package=LAN.Lib&version=2.4.261
#tool nuget:?package=LAN.Lib&version=2.4.261
LAN.Lib
Zero-dependency LAN peer discovery for .NET: a symmetric UDP announce beacon plus a
self-expiring peer table, over a single shared broadcast domain, filtered by service name.
TimeProvider-driven (so beacon cadence and expiry are deterministic in tests), AOT/trim
friendly, with a one-line DI extension for the .NET generic host. Beside it, the two halves of
asking a peer's person for something: the invite handshake's answering side, and the grants an
accepted invite leaves behind; and who an address is, by its forward-confirmed host name.
Extracted and generalised from the LAN play in the chess project.
What it does
- Symmetric discovery. Every node broadcasts a small ASCII announce beacon (1 s) and
listens for everyone else's, keeping a live table of peers that self-expires (5 s) when a
node goes quiet. Both cadences run off an injected
TimeProvider. - One shared broadcast domain. All apps broadcast on one UDP port (
38821) with one magic prefix; a consumer filters to the service it cares about (IPeerTable.PeersOf("...")).ReuseAddresslets several apps share the port on one host — no port-per-app. - Every interface, not just the routed one. The announce goes out once per up, non-loopback
interface, to that interface's directed broadcast address. A single send to
255.255.255.255leaves a multi-homed host on whichever interface wins the route — commonly a Hyper-V, WSL, Docker or VPN adapter rather than the LAN — while receiving (bound toAny) still hears every interface. That asymmetry made such a host see its peers while staying invisible to them. - Stable node identity. A node can mint a persisted node id once (
StableNodeIdPath) and advertise it in every beacon, so a consumer can bind to a node and recognise it across restarts even as its address changes. Separate from the per-process peer id, which exists only to filter a node's own beacon echo. - Extensible. The announce carries an open
key=valueproperty bag (URL-encoded values), so an app can advertise extra facts — a version, a capability flag — with no wire-format bump. - Foreign-datagram safe. A magic + version prefix means a datagram that isn't ours (or is malformed) is ignored, never misparsed — the shared port coexists with anything else on it.
Discovery is UDP-only by design: its whole job is to tell an app which address:port to open a session/control channel to (TCP, WebSocket, HTTP, …). That channel is the app's concern.
Invites and grants
Neither carries anything over the network: an invite arrives however the app receives it (chess: a line on a TCP connection; TianWen: an HTTP route, answered by a different client on the same machine), and the rules around it are what is shared.
LanInvites<T>is the answering side. One invite waits at a time, since a person answers one question at a time, so a secondTryOfferis refused while one waits. An invite lives only while its asker is there: the app callsWithdrawwhen it learns the asker left (a socket closing), orSeenon each poll, with a presence lapse after which an unseen invite is withdrawn, so nothing is granted to someone who walked away. An answer names the invite it answers, so an answer meant for a withdrawn invite never accepts the one that replaced it. The asker reads the outcome back by id (OutcomeOf) forOutcomeRetentionafter the answer. Every read is lock-free (the state is one immutable value replaced by compare-and-swap), so a UI may readPendingevery frame.LanGrantsis what an acceptance that should last turns into:GrantAsyncmints a 256-bit bearer token and hands it out once, the store (ILanGrantStore, the app's own storage) keeps only its SHA-256,TryVerifychecks a presented token in constant time, andRevokeAsyncends it. A write the store refuses did not happen, and writes are one at a time so none is lost. Over plain HTTP a token can be read off the wire: this stops "anyone who can reach the port", not a hostile network, which is TLS's job.LanGrantFileis a store in one JSON file (written whole and moved over), for an app with no storage of its own to put grants in.LanAsk(2.4) is the asking side, the client half of a grant:RunAsyncoffers, reads the ticket everyintervalwhile it is pending, and keeps the token the first time it reads accepted, ending as accepted, declined, lapsed (withdrawn or unknown there), timed out or refused. The wire is two delegates the app supplies; the clock is aTimeProvider.ILanHeldTokensis where the tokens a user holds live (endpoint to token,Forgetto drop one, so that node is watched, not commanded);LanHeldTokenFilekeeps them in one JSON object ofendpoint: token, lower-cased, written whole and moved over, readable by its user alone off Windows. A broken file holds nothing.LanHostNamessays who an address is, for a peer that cannot ask (another application on the LAN): the name it reverse-resolves to, counted only when that name resolves back to the same address, so it survives a DHCP renewal and a stray PTR record cannot claim it. Each address's answer, a miss included, is cached forCacheLifetime, and a lookup is bounded byLookupBudget, so a polling client costs one lookup and a resolver that never answers holds nothing.
var invites = new LanInvites<Requester>(TimeProvider.System, presenceLapse: TimeSpan.FromSeconds(15));
using var grants = new LanGrants(new MyGrantFile(path), TimeProvider.System);
await grants.LoadAsync(ct);
// The asker's request arrives:
if (!invites.TryOffer("Laptop (Seb)", requester, out var invite)) { /* busy: answering another */ }
// The person answers, on the machine that owns the thing asked for:
if (invites.Answer(invite.Id, accept: true) is { } accepted)
{
var issued = await grants.GrantAsync(accepted.Label, ct); // hand issued.Token to the asker, once
}
// Every later request:
if (!grants.TryVerify(presentedToken, out var grant)) { /* refused */ }
Usage
using LAN.Lib;
// On a node that others discover and bind to (e.g. a headless server):
services.AddLanDiscovery(o =>
{
o.ServiceName = "tianwen-server"; // announced + filtered on
o.ServicePort = 1888; // the port peers should reach us at
o.NodeName = Environment.MachineName; // human display name
o.StableNodeIdPath = Path.Combine(appData, "node-id.txt"); // mint-once, persisted
});
// On a consumer that only wants to see who's out there (never appears itself):
services.AddLanDiscovery(o =>
{
o.ServiceName = "tianwen-gui";
o.Announce = false; // listen-only
});
// Anywhere, read the live peer list (a fresh snapshot each call; Changed hints when to re-read):
IPeerTable peers = provider.GetRequiredService<IPeerTable>();
foreach (var peer in peers.PeersOf("tianwen-server"))
Console.WriteLine($"{peer.DisplayName} @ {peer.EndPoint} (node {peer.NodeId})");
AddLanDiscovery registers ILanTransport (via TryAdd, so a bespoke/fake transport can be
registered first), the LanDiscovery beacon + peer table (as IPeerTable), and a hosted
service that runs them for the host's lifetime (sending a polite bye on shutdown).
Testing
LanDiscovery needs no host and no real sockets: construct it directly with an in-memory
ILanTransport and a FakeTimeProvider, then Advance the clock to drive beacon cadence and
expiry deterministically. LanInvites<T>, LanGrants and LanHostNames need neither: a
FakeTimeProvider and, for grants, an in-memory ILanGrantStore, for names an IHostNameResolver
answering from tables. See src/LAN.Lib.Tests.
License
MIT — see LICENSE.
| 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
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 2.4.261 | 0 | 10/23/2026 |
| 2.3.241 | 488 | 10/20/2026 |
| 2.2.221 | 1,260 | 10/14/2026 |
| 2.2.201 | 100 | 10/13/2026 |
| 2.2.191 | 1,024 | 10/9/2026 |
| 2.2.181 | 1,704 | 9/29/2026 |
| 2.1.161 | 366 | 9/27/2026 |
| 2.0.141 | 2,102 | 9/7/2026 |
| 2.0.131 | 112 | 9/3/2026 |
| 2.0.121 | 49 | 9/3/2026 |
| 1.2.101 | 340 | 8/20/2026 |
| 1.2.91 | 236 | 8/4/2026 |
| 1.2.81 | 87 | 8/2/2026 |
| 1.1.61 | 72 | 8/2/2026 |
| 1.1.51 | 251 | 7/25/2026 |
| 1.1.31 | 52 | 7/24/2026 |
| 1.1.21 | 47 | 7/24/2026 |
| 1.0.11 | 52 | 7/23/2026 |