Tracking issue for lanlink design-level defects that are known, analysed, and deliberately not scheduled. Consolidates #663 and #664 so the backlog reflects the actual plan rather than implying this work is queued.
Why these are not being fixed
lanlink is in maintenance mode. P1/P2 bugs get fixed — #658 did, in #662, and the underlying write failure will be fixed so pairing actually succeeds. What it does not get is further protocol work, because peer messaging is moving onto tailnet transport.
The reasoning: wmux already ships wmux web --tailscale, an 8-character pairing code, and a versioned phone API — the phone already connects that way. Machine-to-machine over a hand-rolled PAKE is the odd one out, and it means owning encryption, replay defence, a secret store, and netsh firewall rules for what is ultimately "deliver a text message to an inbox." #658 is the argument in practice: the reporters were on a WireGuard overlay the whole time — they already had an authenticated encrypted network and were bitten by our custom one layered on top. On one of their hosts the netsh rule application had failed outright.
Every limitation below is dissolved by that transition rather than fixed in place. Nothing is removed until a working replacement exists, and the lanlink.* RPC surface keeps working. The message-level contract (msg.text, state.update, the durable inbox) is transport-independent by construction, so it carries over.
1. A revoked or burned peer returns after a restart (was #663)
PeerStore.revoke() and noteSteadyStateAuthFail() intentionally keep their in-memory effect when persist() throws — rolling them back would be fail-open, restoring access the user just removed and stopping a host that cannot persist from ever reaching PEER_BURN_THRESHOLD. #662 makes that explicit.
But in-memory only means not durable, and the restore is active rather than passive: atomicWriteJSONSync renames the previous primary to .bak before writing, and atomicReadJSONSync falls back to .bak when the primary is missing or invalid. So on a host where the peer file cannot be written:
- user revokes a peer (or it burns past the threshold)
persist() throws; the revocation holds in memory; the RPC surfaces the error
- daemon restarts
load() finds no valid primary and falls back to .bak — the pre-revocation generation
- the peer is active again, secret intact
pinFailCount / burned rewind the same way.
Proper fix: restrictions need durability independent of the peer file — an append-only revocation journal re-applied over whatever load() produces, including a .bak result. A revocation should also be cheap to persist (a peer id, not the whole store) so it does not go through the write path that is failing.
Scope note: triggers only on a host where persist() already fails, which is the #658 population.
2. The mirror-image half-pair: joiner fails to save (was #664)
#662 makes the responder's commit atomic, so a failed save there leaves neither side paired. The opposite direction is still open.
pairWithPeer finishes the handshake and calls opts.peers.upsertPaired(result) as its last step. If that throws — same causes as #658 — the joiner rolls back cleanly and reports failure, but the responder committed and confirmed several steps earlier and has no idea. Responder holds a peer the joiner has no secret for: the #658 signature with roles swapped, equally unrecoverable.
Reordering the responder to commit after sending its confirmation does not help; it relocates the window. A pairing is only genuinely mutual once both sides have committed, and no single-round-trip ordering gives you that.
Proper fix: three-way confirm — the responder holds the pairing pending and promotes it only after the joiner confirms over the established AEAD channel, dropping unconfirmed pendings on close. A cheaper approximation is reconciling on the first RECONNECT_HELLO miss.
Known doc inaccuracy: the #662 CHANGELOG says "neither side is left holding a pairing the other cannot honour." True for the responder-fails case it fixes, not for this one. Correct when this is addressed.
Still actively worked
Related: #659 (control-pipe multiplexing, documented in #662).
Tracking issue for
lanlinkdesign-level defects that are known, analysed, and deliberately not scheduled. Consolidates #663 and #664 so the backlog reflects the actual plan rather than implying this work is queued.Why these are not being fixed
lanlinkis in maintenance mode. P1/P2 bugs get fixed — #658 did, in #662, and the underlying write failure will be fixed so pairing actually succeeds. What it does not get is further protocol work, because peer messaging is moving onto tailnet transport.The reasoning: wmux already ships
wmux web --tailscale, an 8-character pairing code, and a versioned phone API — the phone already connects that way. Machine-to-machine over a hand-rolled PAKE is the odd one out, and it means owning encryption, replay defence, a secret store, andnetshfirewall rules for what is ultimately "deliver a text message to an inbox." #658 is the argument in practice: the reporters were on a WireGuard overlay the whole time — they already had an authenticated encrypted network and were bitten by our custom one layered on top. On one of their hosts thenetshrule application had failed outright.Every limitation below is dissolved by that transition rather than fixed in place. Nothing is removed until a working replacement exists, and the
lanlink.*RPC surface keeps working. The message-level contract (msg.text,state.update, the durable inbox) is transport-independent by construction, so it carries over.1. A revoked or burned peer returns after a restart (was #663)
PeerStore.revoke()andnoteSteadyStateAuthFail()intentionally keep their in-memory effect whenpersist()throws — rolling them back would be fail-open, restoring access the user just removed and stopping a host that cannot persist from ever reachingPEER_BURN_THRESHOLD. #662 makes that explicit.But in-memory only means not durable, and the restore is active rather than passive:
atomicWriteJSONSyncrenames the previous primary to.bakbefore writing, andatomicReadJSONSyncfalls back to.bakwhen the primary is missing or invalid. So on a host where the peer file cannot be written:persist()throws; the revocation holds in memory; the RPC surfaces the errorload()finds no valid primary and falls back to.bak— the pre-revocation generationpinFailCount/burnedrewind the same way.Proper fix: restrictions need durability independent of the peer file — an append-only revocation journal re-applied over whatever
load()produces, including a.bakresult. A revocation should also be cheap to persist (a peer id, not the whole store) so it does not go through the write path that is failing.Scope note: triggers only on a host where
persist()already fails, which is the #658 population.2. The mirror-image half-pair: joiner fails to save (was #664)
#662 makes the responder's commit atomic, so a failed save there leaves neither side paired. The opposite direction is still open.
pairWithPeerfinishes the handshake and callsopts.peers.upsertPaired(result)as its last step. If that throws — same causes as #658 — the joiner rolls back cleanly and reports failure, but the responder committed and confirmed several steps earlier and has no idea. Responder holds a peer the joiner has no secret for: the #658 signature with roles swapped, equally unrecoverable.Reordering the responder to commit after sending its confirmation does not help; it relocates the window. A pairing is only genuinely mutual once both sides have committed, and no single-round-trip ordering gives you that.
Proper fix: three-way confirm — the responder holds the pairing pending and promotes it only after the joiner confirms over the established AEAD channel, dropping unconfirmed pendings on close. A cheaper approximation is reconciling on the first
RECONNECT_HELLOmiss.Known doc inaccuracy: the #662 CHANGELOG says "neither side is left holding a pairing the other cannot honour." True for the responder-fails case it fixes, not for this one. Correct when this is addressed.
Still actively worked
connection closedawaitingAEAD_RECORD#658 (P1) — fixed in fix(lanlink): commit a pairing atomically so a failed save cannot half-pair #662 for the half-pair; the underlying write failure still to be fixed so pairing succeedsnoteSeen()persisting the whole store per inbound record; a PowerShell spawn per message on win32. Being fixed because it reaches beyond lanlink (the same primitive backs the daemon auth token) and is entangled with the lanlink pairing leaves a permanent half-pair: responder persists the peer, initiator always fails withconnection closedawaitingAEAD_RECORD#658 root causeRelated: #659 (control-pipe multiplexing, documented in #662).