The desktop app now asks the operating system before it shows a credential — the
other half of the door Android got in 0.3.0. A server whose backends are all down says so
instead of dialling into a timeout, and the client's DNS cache answers only what it holds.
30 September 2026 · one tag ships the server archives, the desktop bundles and the Android APK.
Download for your platform
Pick a build below if the guess is wrong.
Downloads
Client app first, then the server. Nothing needs an account, and nothing is gated.
One APK per ABI: arm64-v8a for every phone, x86_64 for the
emulator images a development machine can run. Desktop bundles are signed with the Tauri updater
key; the in-app updater reads latest.json in the same release. iOS is not a supported
platform — see the
roadmap.
Each archive holds the nexapipe binary, the client cdylib
for embedding, config.toml.2fa.example, the README and the licence. Checksums for
every asset above:
SHA256SUMS.txt.
Install from a terminal
No package manager yet — these are the release assets, straight from the tag.
# Linux x86_64, static binary
curl -LO https://github.com/open-nexa/nexapipe/releases/download/v0.4.0/nexapipe-0.4.0-x86_64-unknown-linux-musl.tar.gz
tar -xzf nexapipe-0.4.0-x86_64-unknown-linux-musl.tar.gz
cd nexapipe-0.4.0-x86_64-unknown-linux-musl && ./nexapipe --version
# macOS, Apple silicon
curl -LO https://github.com/open-nexa/nexapipe/releases/download/v0.4.0/nexapipe-0.4.0-aarch64-apple-darwin.tar.gz
tar -xzf nexapipe-0.4.0-aarch64-apple-darwin.tar.gz
cd nexapipe-0.4.0-aarch64-apple-darwin && ./nexapipe --version
# Verify before extracting: the check looks for the archive beside# SHA256SUMS.txt, so it has to run before the cd into the directory
curl -LO https://github.com/open-nexa/nexapipe/releases/download/v0.4.0/SHA256SUMS.txt
sha256sum -c --ignore-missing SHA256SUMS.txt
0.4.0 follows 0.3.0, released 29 September 2026. Nothing in it breaks a working
configuration: with no [timeouts] section the server dials exactly as 0.3.0 did,
and the desktop app reads its credential store with the key it already had.
Desktop: the app asks the operating system first
Showing a TOTP secret, a node's connection string or this endpoint's Node ID now goes
through the machine's own confirmation: Touch ID or the account password on macOS, Windows
Hello or the account password on Windows, PAM on Linux. The store was already sealed at rest;
what was missing was anything asking before handing a value back. This is the desktop half of
the door the Android app got in 0.3.0.
One confirmation covers the next two minutes, so reading three credentials is not three
prompts in a row.
A machine with nothing to confirm anybody with — no password, no fingerprint, nothing
enrolled — is refused rather than quietly downgraded, and the app says why instead of
failing silently. There is deliberately no app password of its own: a credential to forget,
reset and attack.
Starting, stopping and restarting the proxy is deliberately not gated: it has to
come back by itself after a reboot, with nobody there to confirm anything.
Not yet watched running on every platform.
The gate compiles on macOS, Windows and Linux, the two-minute window it opens is pinned by
unit tests, and the macOS prompt has been driven by hand. What has not been seen is the prompt
itself on a Windows or a Linux machine — if one misbehaves, that is the part to look at
first.
Backends: refusing what is already known
A route whose backends are all unhealthy now depends on how many there are. One backend is
still handed out, because with nothing to choose between the health information buys nothing.
Several, all down, refuse without dialling: the answer is already known, so a connect timeout
was only ever spent delivering this refusal late.
A refusal says which it is. No route serves the host: 404, as before. The route
exists and nothing behind it is healthy: 503. An L4 flow says the same on the
wire with Status::BackendFailed.
least_conn joins round_robin and random: the healthy
backend with the fewest requests outstanding gets the next one.
[timeouts] — connect_secs (default 10) and
response_secs (default 30), each covering one step of talking to a
backend. Read once at startup; 0 and anything past 3600 are refused
at load.
Entries are keyed on QCLASS as well as on name and type, so one class is no longer served
from another's answer.
They are keyed on the resolvers that produced them, so changing the configured DNS servers
no longer keeps serving what the previous ones said.
A hit rewrites each record's TTL to the part that is actually left, so an answer fetched
minutes ago does not go back out with its full TTL again.
A zero TTL is not cached. It used to get a one-second floor, and by RFC 1035 it means do not
cache at all.
Linux: the keychain really holds the key
The desktop app's claim that the OS keychain holds the credential store's master key was not
true on Linux, where the key was a 0600 file beside the ciphertext. Linux now
uses the Secret Service, and an install that already had a file key keeps it — the same
key moves into the keychain, because a fresh one would leave every credential in the store
undecryptable.
Android: it asks before taking another app's VPN
Android allows one VPN slot per user, and establish() revokes whoever holds it
without asking anybody. Nexa used to look only at the default network, so a per-app or
split-tunnel VPN was invisible and was dropped silently.
Every VPN Android reports is now detected, and the first connection asks: take over, or
cancel. Cancel costs nothing — nothing has been started yet.
"Remember my choice" is optional and can be taken back from the settings, because a
remembered cancel must not become a setting you cannot find again.
An always-on VPN is refused outright, and says why: Android restores it immediately, and it
may be a work-profile VPN you are not allowed to break.
Taking the slot is never automatic: a rebuild after a network switch no longer revokes
another app's VPN by the way.
The tunnel carries IPv6
A proxied domain answered an AAAA query with nothing, so every resolver that prefers IPv6
— which is most of them — had to fall back to A before it could reach anything.
Both families are answered now, each from a pool of its own.
The addresses come from a ULA block the VPN routes into the TUN for itself
(fd00:10:0:1::/64 on Android, fd00:198:18::/64 on the desktop), so
one that escapes the tunnel is a dead end rather than a real host. Only that /64 is claimed:
everything else the device sends over IPv6 still goes to the physical network.
On the desktop the address is configured per platform and may be refused — a service
account, an image with IPv6 turned off. When it is, AAAA is answered with the empty reply that
sends the resolver back to A, not with an address nothing routes, and the log says which
happened.
The server can listen on IPv6 too: [iroh] bind_ipv6 adds
[::] on the configured bind_port. It adds a socket rather than
replacing one, so nothing that reaches the server over IPv4 loses its route.
Android: an APK for the emulator
The APK was arm64-v8a only, which left an emulator — the x86_64 images
that run acceptably on a development machine — with nothing to install. There are now
two APKs, one per ABI.
One per ABI rather than one carrying both: a fat APK would make every phone download the
x86_64 library to get the arm64 one. Each carries its own version code, because two artifacts
of one version cannot both claim the same one.
The backends are asked whether they still answer
Every 30 seconds each backend is probed in parallel and the result recorded, where before a
group dialled once at startup and never again — a backend that stopped answering was
discovered by the request that needed it, which on a phone is an app that has already timed
out.
The probe is also the reconnect: the pool drops a connection idle for a minute, so a round
keeps one warm to a backend nobody has talked to.
A dead backend is still handed out.
This release only notices them: the balancer picks by index into a list it cannot change, so
taking one out needs the group to become mutable — a change to every holder of it. That
half is left for the next release, and the one to watch for in the meantime is a request served
by a backend the log has already reported as silent.
Changelog
Added
Desktop: a door in front of the credentials the app holds, described above — the
operating system confirms the user, and stays open for two minutes afterwards.
least_conn as a third load-balancing strategy, alongside round_robin
and random.
[timeouts]: connect_secs and response_secs, covering
one step each of talking to a backend.
IPv6 inside the tunnel: AAAA is answered from a ULA block the VPN routes for itself, and a
packet is routed by the family it arrived on.
Server: [iroh] bind_ipv6 binds [::] on the configured
bind_port as well as 0.0.0.0.
Client: the backends are probed on a timer, and
EndpointGroup::health_snapshot() reports what each answered.
Android: a second ABI. One APK per ABI — arm64-v8a and
x86_64 — each with its own version code.
Changed
A route whose backends are all unhealthy now depends on how many there are: one is still
handed out, several refuse without dialling.
A refused HTTP request distinguishes why: 404 when no route serves the host,
503 when the route exists and nothing behind it is healthy. An L4 flow says the
same on the wire with Status::BackendFailed.
Fixed
The DNS cache in the TUN proxy answered more than it knew: entries were keyed without
QCLASS and without the resolvers that produced them, a hit rewrote only the transaction ID,
and a zero TTL got a one-second floor instead of not being cached. Those tests now run in CI,
which had only been compiling them.
Linux: the desktop app's credential store keeps its master key in the Secret Service rather
than in a file beside the ciphertext, as both READMEs already claimed.
Desktop: a node's connection string no longer sits in localStorage in the
clear. A ticket is a credential — it names an endpoint and carries how to reach
it — and it now lives in the encrypted store with the rest.
Desktop: masks are computed in Rust rather than in the UI, so a full value no longer reaches
the page that is drawing it shortened, and an invite's secret is filed by Rust instead of
being handed to the frontend in full.
Android: another app's VPN is no longer disconnected without asking. The guard detects every
VPN Android reports rather than only the default network's, and the first connection asks
instead of refusing — or silently revoking.
Before upgrading
Downgrading the desktop app means re-importing the invite.
A node's connection string moved from the config payload into the encrypted store. A newer build
migrates it on the way in; an older one reading the new payload finds nothing and drops the
node. Upgrading keeps the node — going back does not.
Nothing changes by default: with no [timeouts] section the server dials exactly
as 0.3.0 did.
A route with several backends that are all down now answers 503 without
dialling, where it used to spend a connect timeout per request. A single-backend route is
unchanged.
Desktop: the first credential you reveal asks the machine to confirm you. On Linux, where
there is no system prompt to borrow, the app asks for your login password itself.
Desktop: bundles are signed, so an in-app update from 0.3.x verifies as usual through
latest.json.
Android: the first connection asks if another app's VPN holds the slot. Cancel leaves that
VPN running and Nexa stopped; a remembered choice can be changed in the settings.
Traffic to a proxied domain now travels over IPv6 when the application resolves it to an
IPv6 address, which is what a resolver that prefers IPv6 will do. Nothing about the route
changes — the destination is still the name — but the packets crossing the TUN
are IPv6 ones, and a desktop TUN whose interface refused the IPv6 address stays on IPv4 and
says so in the log.
Server: bind_ipv6 is off unless you set it, so an existing
config.toml binds exactly what it bound before.
Android: there are two APKs where there was one. Pick the one for the device —
arm64-v8a for a phone, x86_64 for an emulator — and note
that the two carry different version codes, so a store that saw one does not treat the other
as the same build.