Repository navigation
Releases: open-nexa/nexapipe
Release list
v1.0.0
Warning
This release contains unsigned artifacts.
The run was dispatched with allow_unsigned=macos,windows, so the following platforms were published without a code signature:
macos— the.dmgand the.appinside it carry no Developer ID signature and are not notarized. macOS Gatekeeper refuses them until the user allows them by hand.windows— the NSIS installer (-setup.exe) carries no Authenticode signature. Windows SmartScreen refuses it until the user allows it by hand.
A code signature costs money — the Apple Developer Program is $99 a year and a Windows certificate is bought per year on top of that — and this project has no revenue, so the first release ships the artifacts without one. Everything else is signed: the Android APK carries the release keystore, the server archives carry a checksum, and the container image is signed with the release key and carries a provenance attestation.
The Tauri updater's own signature is separate from the operating system's and is present either way, so an update installs the same way as the first install does.
The third-party boundary is closed. A device's credential can be a key instead
of a shared secret, so a device that runs unattended needs nobody to read a code
to it; a client can be held to the ports it may reach and not only to the names;
relay_mode = "custom" really excludes n0's relays and [iroh] discovery
switches the address lookup off; the server's runtime state lives in a file of
its own beside the configuration; and both clients can bring the tunnel back by
themselves.
Nothing has to be enrolled again. The handshake, the ALPN and the L4 preface
are unchanged on the wire, so a 0.6.1 client talks to a 1.0.0 server and the
other way round. What did change is what a deployment and a device have to do.
Before upgrading
Two steps on the server are not optional.
1. The container image no longer runs as root. It runs as uid 1000, so the
three mounted directories have to be writable by that uid:
sudo chown -R 1000:1000 config secrets logsRun it before the first start, and with sudo — the files the old root
image wrote are root-owned. Skipping it does not always fail loudly: an
iroh.key that cannot be opened is regenerated, which is a new Node ID, and
every client that stored the old one stops finding the server.
2. A config key the file does not know now refuses the start. In 0.6.1 only
[peers] and [auth] refused unknown keys and the rest dropped them silently;
every section refuses them now, the top level included:
unknown field `stray_key`, expected one of `default_backend`, `debug`, `routes`, `server`, `iroh`, ...
This build does not know that key: remove it, or fix its spelling.
Run the server once against your config and fix what it names. One key is worth
naming in advance: [admin] token is not a key — the admin token is
generated into <config>.admin-token and that file is the only source of it.
Three more things on the server, none of them a step you have to take.
- The runtime state moves into a file of its own: the counters
(failed_attempts,locked_until,last_used) and the TOTP step memories
go to a file named after the configuration —config.tomlputs its state in
config.state.toml. The oldstate.tomlbeside it is still read when the new
one is not there yet, so existing lockouts survive. The configuration
directory has to stay writable. - Removed keys are still accepted and logged as ignored:
[server] tls_enabled,
tls_listen_addr,cert_pathandkey_path, the route-levelcert_path,
key_pathandredirect_to_https, and the[acme]section. TLS is
terminated by the backend now. - Nothing else changed. No configuration key is removed or renamed, every key
added in this cycle is optional, and[auth] enabledstill defaults to
falsefor a config that has no[auth]section — so an existing deployment
does not suddenly demand a credential its clients were never given.
The clients.
- Nothing to enroll, nothing to rescan.
- Android: the tunnel's address block changes. It was
10.0.1.0/24— which
a great many home routers use — and is now the first block the host will take
out of198.18.0.0/24,198.19.0.0/24,100.100.0.0/24,172.26.0.0/24,
10.200.0.0/24and10.0.0.0/24. A rule of your own that hard-codes
10.0.1.xneeds updating. - Desktop: the credentials move out of
localStorageinto an encrypted
file whose master key the OS keychain holds. The migration is automatic. - Desktop: the service is replaced during the upgrade. It runs a copy of the
app's own binary from a root-owned directory, and an upgrade used to leave the
service running the build it was started with — the app was new and the
service was old, and the panel showed a service that was running with every
button greyed out. The upgrade now puts the new binary in place; a service you
had stopped stays stopped, the account it serves is unchanged, and if it
cannot be done the installer puts the machine back the way it found it and
exits with a non-zero code. - New, and off until asked for: starting with the system, coming back after
a reboot, and a device key instead of a secret.
What's new
A device can hold a key instead of a shared secret. A client can be held to
the ports it may reach. The third-party boundary is two settings now. Both
clients can stay up on their own. Both clients pick their address block, and
both sides spell a name the same way. A backend can be reached over TLS,
retried, and taken out of rotation. The protocol is chosen at the handshake.
The full list is in the changelog,
and the release notes — with the downloads and the checksums — are on the
pages site.
NexaPipe v0.6.1
Every device now holds a credential of its own, so revoking one no longer rotates
the rest. A config.toml written before this release loads unchanged: a client
carrying only secret keeps authenticating exactly as it did.
One thing breaks. /metrics renders request latency as a histogram:
nexapipe_request_duration_ms_bucket{le="…"} beside _sum and _count replaces
nexapipe_request_duration_ms_total. A scrape or dashboard looking for the old
counter will not find it; a mean that used to divide it by
nexapipe_requests_total reads _sum the same way.
Added
-
Per-device credentials.
[auth.clients.<id>]gains adevicestable — one
TOTP secret per device, issued at enrollment and revoked on its own:[auth.clients.client-001.devices.laptop] secret = "LAPTOPSECRET"
Nothing there is written by hand: enrollment files the device, and so does
nexapipe client add client-001 --device laptop. -
Revocation closes what the device already holds, not only what it dials
next. The reload that notices a device left[auth]closes its connections,
with a new close codeREVOKED = 6that the client reports as "this credential
is gone" rather than as a code typed wrong. -
Each install says which device it is. The desktop answers after its
hostname, Android after the device model. The name is claimed at enrollment and
nowhere else, so nothing re-enrolls on its own, and a credential that was never
issued to a device keeps answering as it does today. -
An access line says whose request it was. Every line ends
client=… device=…, and/v1/connectionsanswers who is connected rather than
only how many. -
nexapipe client list|add|revoke, each taking--device, writing
config.tomlthrough the same lock the server reads it with — a running
instance picks the change up on its next reload./v1/*stays read-only on
purpose: its token has no scope and no rotation. -
[auth.clients.<id>].unnamed_device_allowed(defaulttrue): set to
false, a peer that names no device is refused rather than answered under the
client's shared secret. -
A systemd unit for the server in both READMEs. The relay has had one in
docs/self-hosted-relay.mdsince earlier in this release. -
Desktop: session totals, and a page listing the connections a running proxy
holds. A running proxy says what quitting should do.
Changed
- Request latency on
/metricsis a histogram (see above). - The desktop TUN shares DNS and address space with other TUN apps, instead of
taking both over. - The workspace declares the Rust version it needs:
1.89.
Fixed
- A name outside the hijack is answered through the host's own resolver, instead
of the TUN swallowing it. - Desktop: no empty window while the app starts; a launch with no frame still
sends its start-up timings; the macOS bundle keeps the content security policy;
the build gets the vendoredsmoltcppatch; a tunnel whose DNS was never
hijacked no longer runs as though it had been. - Server: an enrollment can no longer take over a device that is already in the
table; a peer is no longer connected before it is revocable;client add
fails instead of reporting a secret it did not write. - Android: no native crash on the stop path, no stop on the main thread, no
credential stored unencrypted without asking. config.tomland the--qr-outfile are created private, and neither follows
a symlink to reach the file;[peers]refuses a key it does not recognise; a
TOTP period that does not fit is refused at enrollment; each TUN flow and every
preconnect runs under a budget.
Not in this release
Per-device key pairs (the credential stays TOTP), a write API (client add|revoke are subcommands, not POST /v1/clients), and package managers.
Full detail in
CHANGELOG.md;
what comes next in
docs/ROADMAP.md.
NexaPipe v0.6.0
Every device now holds a credential of its own, so revoking one no longer rotates
the rest. A config.toml written before this release loads unchanged: a client
carrying only secret keeps authenticating exactly as it did.
One thing breaks. /metrics renders request latency as a histogram:
nexapipe_request_duration_ms_bucket{le="…"} beside _sum and _count replaces
nexapipe_request_duration_ms_total. A scrape or dashboard looking for the old
counter will not find it; a mean that used to divide it by
nexapipe_requests_total reads _sum the same way.
Added
-
Per-device credentials.
[auth.clients.<id>]gains adevicestable — one
TOTP secret per device, issued at enrollment and revoked on its own:[auth.clients.client-001.devices.laptop] secret = "LAPTOPSECRET"
Nothing there is written by hand: enrollment files the device, and so does
nexapipe client add client-001 --device laptop. -
Revocation closes what the device already holds, not only what it dials
next. The reload that notices a device left[auth]closes its connections,
with a new close codeREVOKED = 6that the client reports as "this credential
is gone" rather than as a code typed wrong. -
Each install says which device it is. The desktop answers after its
hostname, Android after the device model. The name is claimed at enrollment and
nowhere else, so nothing re-enrolls on its own, and a credential that was never
issued to a device keeps answering as it does today. -
An access line says whose request it was. Every line ends
client=… device=…, and/v1/connectionsanswers who is connected rather than
only how many. -
nexapipe client list|add|revoke, each taking--device, writing
config.tomlthrough the same lock the server reads it with — a running
instance picks the change up on its next reload./v1/*stays read-only on
purpose: its token has no scope and no rotation. -
[auth.clients.<id>].unnamed_device_allowed(defaulttrue): set to
false, a peer that names no device is refused rather than answered under the
client's shared secret. -
A systemd unit for the server in both READMEs. The relay has had one in
docs/self-hosted-relay.mdsince earlier in this release. -
Desktop: session totals, and a page listing the connections a running proxy
holds. A running proxy says what quitting should do.
Changed
- Request latency on
/metricsis a histogram (see above). - The desktop TUN shares DNS and address space with other TUN apps, instead of
taking both over. - The workspace declares the Rust version it needs:
1.89.
Fixed
- A name outside the hijack is answered through the host's own resolver, instead
of the TUN swallowing it. - Desktop: no empty window while the app starts; a launch with no frame still
sends its start-up timings; the macOS bundle keeps the content security policy;
the build gets the vendoredsmoltcppatch; a tunnel whose DNS was never
hijacked no longer runs as though it had been. - Server: an enrollment can no longer take over a device that is already in the
table; a peer is no longer connected before it is revocable;client add
fails instead of reporting a secret it did not write. - Android: no native crash on the stop path, no stop on the main thread, no
credential stored unencrypted without asking. config.tomland the--qr-outfile are created private, and neither follows
a symlink to reach the file;[peers]refuses a key it does not recognise; a
TOTP period that does not fit is refused at enrollment; each TUN flow and every
preconnect runs under a budget.
Not in this release
Per-device key pairs (the credential stays TOTP), a write API (client add|revoke are subcommands, not POST /v1/clients), and package managers.
Full detail in
CHANGELOG.md;
what comes next in
docs/ROADMAP.md.
NexaPipe v0.5.0
Health decides which node the next request goes to, the backends that cannot answer GET /health get asked anyway, and the server and both clients count the bytes they carry. Alongside it: an image in the release, your own relay documented, and a desktop whose DNS no longer outlives the tunnel.
Downloads
One tag ships all three products, and every file below is attached to this
release.
Desktop & mobile app — nexa
| Architecture | Windows | macOS | Linux | Android |
|---|---|---|---|---|
| x86-64 (64-bit) | Installer | Disk image | DEB · RPM · AppImage | APK — emulator images |
| AArch64 (ARM64) | Installer | Disk image | DEB · RPM · AppImage | APK — every phone |
One APK per ABI rather than one carrying both: a fat APK would make every phone
download the x86_64 library to get the arm64 one.
Server & client library — nexapipe
| Architecture | Linux (glibc) | Linux (musl, static) | macOS | Windows |
|---|---|---|---|---|
| x86-64 (64-bit) | tar.gz | tar.gz | tar.gz | zip |
| AArch64 (ARM64) | tar.gz | tar.gz | tar.gz | zip |
Each archive holds the nexapipe binary, the client cdylib for embedding,
config.toml.2fa.example, the README and the licence. The macOS updater bundles
and their .sig files, latest.json, each APK's .sha256 sidecar and
SHA256SUMS.txt are attached as well.
Verify before you install, whichever one you downloaded:
SHA256SUMS.txt
checks against any of them — sha256sum -c --ignore-missing SHA256SUMS.txt on
Linux, and on macOS grep <asset> SHA256SUMS.txt | shasum -a 256 -c -, which is
the tool that ships there.
docker-compose.yaml now names that image, so docker compose up pulls it
rather than building; --build still builds locally under the same name.
What's new
- Client: which node the next request goes to is decided by health rather
than by index — 0.4.0 started recording what the probes found and nothing ever
read those records, so a node dead for an hour kept receiving its share. A request
that fails to dial now writes to the health map where it noticed, instead of
waiting up to thirty seconds for the probe. A node that is down is skipped, not
removed; one that is the only node is still dialled, because refusing it turns
"down" into "no service at all" for a deployment with nothing to fail over to. - Server:
passthroughandtcproutes are probed at last, by connecting to
the backend — a TLS one by completing its handshake. Until now onlyhttp
routes were, so a dead TLS listener behind a passthrough route surfaced as a
connect error on whichever flow tried it first. It is deliberately the smaller
claim: something is listening, not that it works. Audp-only route is still
not probed, because every probe available is one every backend would fail. - Server: what it carried in bytes.
/metricsgains
nexapipe_traffic_bytes_total{direction="sent"|"received"}and/v1/status
the same two numbers, counted on the client leg of every conversation —
think one large download versus a thousand health checks, which look identical
in every counter this release had until now. - Client and apps: each node's numbers, where you can see them. The desktop
lists what every node carried beside whether it answered its last probe and how
long it has been unreachable; the Android notification shows live up and down
rates. What is counted is tunnel payload — no protocol headers, nothing
discarded, and nothing counted twice. - Distribution: the release publishes a container image
(ghcr.io/open-nexa/nexapipe, multi-arch) alongside the archives, and
docs/self-hosted-relay.mdtakes a relay from nothing to running, with an
honest note that it does not remove the third-party dependency — it only
changes whom you depend on.
Fixed
- Desktop: disconnecting, quitting or uninstalling no longer leaves the machine's
DNS pointed at a tunnel that is gone. Three gaps each leaked on their own — no
self-heal outside Windows, no restore on exit at all, and a Unix service
process with no signal handling whatsoever. On macOS and on Linux the setting
outlives the process, so the machine stopped resolving anything, reboot
included. - Desktop: a confirmation the machine refused says why, instead of doing nothing
at all — a wrong password, a dismissed prompt and a failed fingerprint used to
leave the page exactly as it had been. - Desktop: a copy made just after an operating-system prompt no longer reports
failure; the loading spinner turns with Reduce Motion on; macOS stopped drawing
a second title bar above the app's own. - Server: an invalid health-check path fails its probe rather than panicking.
Before upgrading
- No configuration key changed and no default moved: a
config.tomlthat works
on 0.4.0 loads unchanged. passthroughandtcproutes are probed now, so a backend that refuses TCP
connections is reported unhealthy and skipped in a multi-backend route where it
previously looked fine and failed on the request.- Desktop, macOS: the credential store's master key moves out of the keychain
into the same0600file the other platforms fall back to. It is moved rather
than replaced, so every credential already stored stays readable. - Downgrading the desktop app still means re-importing the invite, as it did in
0.4.0: an older build finds no connection string and drops the node. - Two things in this release have not been watched running: the relay guide has
not been followed end to end on a machine that had nothing on it, and the
Android notification's rates are verified by unit tests and by compilation
only.
Full details, install commands, checksums and upgrade notes:
https://open-nexa.github.io/nexapipe/v0.5.0.html · complete history in
CHANGELOG.md ·
what comes next in
docs/ROADMAP.md.
What shipped, in the order it landed:
#62 the door as a page, the
refusal that says why, and the relay on Config ·
#65 both credential gates
verified running on every platform ·
#64 the self-hosted relay guide ·
#69 probing the backends that
cannot answer GET /health ·
#71 the bytes this instance
served and accepted ·
#66 compiling the root workspace
on Windows ·
#67 health decides which node
the next request goes to ·
#68 whether each node answered,
shown on the dashboard ·
#63 the container image ·
#70 per-node byte counters, the
desktop view and the Android notification rates ·
#81 the system DNS no longer
outlives the tunnel ·
#82 the version bump and the
roadmap phase.
NexaPipe is licensed under the terms in LICENSE.
NexaPipe v0.4.0
The desktop app now asks the machine who is using it before it shows anything, and a server whose backends are all down says so instead of dialling.
Downloads
One tag ships all three products, and every file below is attached to this
release.
Desktop & mobile app — nexa
| Architecture | Windows | macOS | Linux | Android |
|---|---|---|---|---|
| x86-64 (64-bit) | Installer | Disk image | DEB · RPM · AppImage | APK — emulator images |
| AArch64 (ARM64) | Installer | Disk image | DEB · RPM · AppImage | APK — every phone |
One APK per ABI rather than one carrying both: a fat APK would make every phone
download the x86_64 library to get the arm64 one.
Server & client library — nexapipe
| Architecture | Linux (glibc) | Linux (musl, static) | macOS | Windows |
|---|---|---|---|---|
| x86-64 (64-bit) | tar.gz | tar.gz | tar.gz | zip |
| AArch64 (ARM64) | tar.gz | tar.gz | tar.gz | zip |
Each archive holds the nexapipe binary, the client cdylib for embedding,
config.toml.2fa.example, the README and the licence. The macOS updater bundles
and their .sig files, latest.json, each APK's .sha256 sidecar and
SHA256SUMS.txt are attached as well.
What's new
- Desktop: the whole Config page — nodes, domains, connection strings,
two-factor secrets, the relay — renders nothing until the machine confirms the
user, and stays open for two minutes at a time. The relay moved from Settings
onto that page. - Server: a route whose backends are all unhealthy refuses with
503
instead of dialling into a timeout;least_connjoinsround_robinand
random;[timeouts]makes the connect and response deadlines
configuration. - Client: every backend is probed on a timer and
EndpointGroup::health_snapshot()reports what each answered. - Tunnel: a proxied domain answers
AAAAas well asA, from a ULA block
routed into the TUN; the server can bind[::]too ([iroh] bind_ipv6). - Android: the app asks before taking another app's VPN slot, and ships one
APK per ABI —arm64-v8afor a phone,x86_64for an emulator.
Fixed
- The DNS cache in the TUN proxy answered more than it held; its tests now run
in CI. - Linux keeps the desktop credential store's master key in the Secret Service.
- A node's connection string no longer sits in
localStoragein the clear — it
moved into the encrypted store. - Desktop masks are computed in Rust, and an invite's secret and enrolment token
are filed by Rust rather than handed to the renderer. - A confirmation the machine refused says why, instead of changing nothing.
Full details, install commands, checksums and upgrade notes:
https://open-nexa.github.io/nexapipe/v0.4.0.html · complete history in
CHANGELOG.md ·
what comes next in
docs/ROADMAP.md.
What shipped, in the order it landed:
#56 [timeouts], least_conn
and the refusal when every backend is down ·
#57 the desktop credential door ·
#58 asking before another app's
VPN is taken ·
#59 the second Android ABI ·
#60 IPv6 in the tunnel and the
backend probe ·
#61 this release's download page ·
#62 the door as a page, the
refusal that says why, and the relay on Config.
NexaPipe is licensed under the terms in LICENSE.
NexaPipe v0.3.0
0.3.0 answers the question its own logs could not.
A running instance has always been able to say what happened to a request, and
nothing about itself: how many peers are connected, over which path, how many
backends are actually in rotation, how much work is still in flight. There is a
second listener for that now — off until you bind one, and reachable only from
the machine it runs on.
Health, metrics and live state behind loopback
[admin]
listen_addr = "127.0.0.1:9090" # absent => the listener is not bound at all
[metrics]
enabled = true # serves /metrics on it; default: false| Path | Auth | Answers |
|---|---|---|
GET /healthz |
none | 200 ok for as long as the process is serving. |
GET /metrics |
none | Instance metrics in Prometheus text format. |
GET /v1/status, /v1/routes, /v1/clients, /v1/connections, /v1/health |
token | The same instance's live state, as JSON, read fresh on every request. |
curl -s http://127.0.0.1:9090/healthz
curl -s http://127.0.0.1:9090/metrics
curl -s -H "authorization: Bearer $(cat config.toml.admin-token)" \
http://127.0.0.1:9090/v1/status
nexapipe status # the five endpoints, grouped
nexapipe status --json # the same read, as one documentWhat /metrics exposes:
| Metric | Meaning |
|---|---|
nexapipe_connections_total / _active |
Connections accepted, and how many are open. |
nexapipe_connections_by_path{kind="direct"|"relayed"} |
Read from iroh rather than guessed from an address, so "no relaying" is a number, not a claim. |
nexapipe_requests_total{status_class}, nexapipe_request_duration_ms_total |
Requests by 2xx/4xx/5xx, and the milliseconds they took. Divide one by the other for a mean that survives restart-free scraping. |
nexapipe_l4_flows_total{proto,status_class} |
Raw TCP and UDP flows that ended, and how. |
nexapipe_backends_up / _down |
Backends in and out of rotation. |
nexapipe_in_flight, nexapipe_uptime_seconds, nexapipe_build_info{version} |
Work still in flight, uptime, and which build you are scraping. |
Two deliberate edges: with the listener up but metrics off, /metrics is 404
rather than an empty body, so a scraper can tell "disabled" from "no traffic
yet"; and a missing or wrong token is 503 rather than an open door. The token
is generated by the server on first start and written next to the config as
<config>.admin-token — there is deliberately no key for it inside the config,
because a credential in the file an operator edits, copies and commits is what
this design avoids.
Loopback only, and there is no expose. [server] expose exists because the
plaintext listener may sit behind something else that gates the port; nothing on
this one should reach the network, since it names your routes, clients and
backends. A non-loopback bind is refused at startup. To scrape from another host,
tunnel it: ssh -L 9090:127.0.0.1:9090 <host>.
Android asks before it hands out credentials
The TOTP secret of an endpoint, its otpauth export, and any change to the relay
configuration now require confirmation first — a biometric where there is one,
the screen-lock credential otherwise — and stay open for two minutes after one
confirmation. SecretStore already sealed these at rest; what was missing was
anything asking before handing them back. A device enrolled with neither a
biometric nor a screen lock is refused rather than downgraded. Proxy start and
stop are deliberately not gated: the VPN has to come back after a reboot with
nobody present.
Every request has an ID
32 hex digits per request — one per entry point, not one per connection —
appended to that request's access line as id=<hex>, carried by a request
tracing span alongside its method, URI and status, and returned to the caller as
x-request-id. A backend that sends its own is dropped rather than duplicated,
so there is one ID to quote and it is ours. Appended rather than inserted, too:
every field before it keeps the column it had.
Full changelog
Added
- The auxiliary loopback listener described above:
[admin] listen_addr, with
GET /healthzalways andGET /metricsbehind[metrics] enabled. - Instance metrics: connections (total, active, and by direct/relayed path),
requests by status class and duration, L4 flows by protocol and status,
backends up and down, work in flight, and uptime. Written by hand, so no
prometheuscrate was added andCargo.lockis untouched. - The read-only management surface behind a generated token:
GET /v1/status,
/v1/routes,/v1/clients,/v1/connectionsand/v1/health. nexapipe status— a client of those same endpoints, with--json. It reads
the running instance rather than reopening the config, so the instance stays
the one thing that decides what its state is.- The Android confirmation gate over endpoint secrets,
otpauthexport and relay
configuration changes. - A request ID per entry point: on the access line, on the tracing span, and as
x-request-id. docs/iroh-boundaries.md— what still depends on third-party infrastructure
whateverrelay_modesays.customconstrains only this endpoint, and
Endpoint ID discovery still queriesdns.iroh.linkin every mode.- A
CHANGELOG.md. 0.2.0 shipped without one, so its entry there is
retrospective; 0.3.0 is the first release recorded in it as it happened.
Changed
- Hot-reload rules, previously scattered across the configuration sections, are
now one table in both READMEs: what a reload applies, and what needs a restart. - The access log line ends with
id=<hex>. - HTTP responses carry
x-request-id. An L4 flow and a TLS passthrough are
tunnels rather than requests: they get an ID on the access line and the span,
and have no HTTP response to put a header on.
Fixed
- Both READMEs quoted the iroh version wrongly in two ways at once: as pinned at
1.0.1, when it is declared^1.0.1andCargo.lockresolves 1.2.0. The line
now defers todocs/iroh-boundaries.md.
Upgrading
Nothing is removed and no config key changes meaning — every config that
starts under 0.2.0 still starts under 0.3.0. Three things to know:
- Nothing new is exposed by default. Without an
[admin] listen_addrthe
listener is not bound, so upgrading changes no attack surface. [metrics] enabledwithout an[admin]section logs a warning and serves
nothing.[admin] listen_addr,[metrics] enabledand[server] listen_addrare
restart-only: moving a listener is a restart. Everything else reloads, as
the new table in the README spells out.
Downloads
Everything is attached below, one tag shipping all three products: the server
archives for eight targets, the desktop bundles for six platforms, and the signed
arm64-v8a APK. Each desktop bundle carries a .sig; macOS updater payloads and
a merged latest.json are what the in-app updater reads; SHA256SUMS.txt covers
every file in the release.
curl -sLO https://github.com/open-nexa/nexapipe/releases/download/v0.3.0/SHA256SUMS.txt
sha256sum -c --ignore-missing SHA256SUMS.txtFull list with checksums and per-platform buttons:
https://open-nexa.github.io/nexapipe/v0.3.0.html · complete history in
CHANGELOG.md ·
what comes next in
docs/ROADMAP.md.
NexaPipe v0.2.0
NexaPipe exposes HTTP, HTTPS, WebSocket, TCP and UDP services that live behind NAT through a
single iroh/QUIC endpoint — without terminating TLS, without a control plane, and without a
server to rent.
v0.2.0 is a hardening release. A full review of the tree — server, client library, Android and
desktop apps — produced a batch of security and correctness fixes, alongside configurable
health checks, a draining shutdown, and access logs on the iroh tunnel path. It carries
breaking changes: default_backend is removed, and the server now refuses some configs it
used to "fix" silently. See Upgrading before restarting.
Highlights
Security hardening across the whole tree
Ordered by what an attacker could reach first:
- An empty enrollment token completed enrollment.
pending_enrollment = ""— the natural
way to clear a token — accepted any peer: the secret was rotated and handed to the caller,
and the real devices were dropped. Both sides of the comparison must now be non-empty, and a
blank token refuses to load. - Config hot reload logged the credentials. The endpoint's ed25519 secret key, the relay
auth token and TOTP secrets were printed on every reload. Hand-writtenDebugimpls now
redact them. - The client auth handshake had an unbounded allocation and no deadline. A 4-byte
0xFFFFFFFFlength made the client allocate 4 GiB, and one silent server could block every
request for that backend. Reads are capped at 64 KiB and the handshake has a 10 s timeout. - The desktop in-process path validated nothing. The frontend could publish an
unauthenticated LAN proxy that the privileged service path deliberately blocks. The loopback
and TUN-subnet checks now run before the service/process split, and a minimal CSP replaces
csp: null. - Request handling got deadlines and limits. The request head read has a 30 s total
deadline (an idle deadline is reset by one byte every 30 s); concurrent streams per
connection are capped at 256, refused rather than queued; request bodies are capped at
64 MiB checked before allocating; response compression switches to streaming past 1 MiB
instead of buffering unboundedly. - Android and desktop credentials are sealed. TOTP seeds, enrollment tokens and the relay
token used to sit in plaintextSharedPreferences/ localStorage, inside the Android backup
scope. They are now encrypted through the platform Keystore (AES-256-GCM), with old
plaintext values migrated on startup.
No protocol, wire-format or public API change.
Correctness fixes that were silently breaking traffic
- Chunked request bodies were silently dropped on the iroh tunnel path — only
content-lengthbodies were read, so the backend received a body with no framing and no way
to know it was truncated. They are now decoded as they arrive and forwarded with a computed
content-length. - Health checks were orphaned by a config reload: an unchanged route kept pointing at a
backend pool nobody probed, so it was never marked unhealthy again. Pools are now reused
across reloads, and probes are tracked and aborted with their route. - A reload could resurrect a spent enrollment token by overwriting live auth state with a
file snapshot read outside the lock; the read now happens inside it. - Host wildcards matched without a label boundary (
*example.commatchednotexample.com);
IPv6Hostheaders never matched a route; hop-by-hop headers were forwarded verbatim; a 502
echoed internal error details (backend URL, parse errors) to the client. - Plus fixes across TOTP validation, connection-pool lifecycle, TUN DNS parsing (RFC 1035
compression pointers), UDP flow limits, and a DNS cache for the TUN proxy.
Health checks are configurable, and the threshold is finally honoured
[health_check] is new, and every key is optional — absent means the defaults, so existing
configs keep working:
[health_check]
enabled = true # false disables probing entirely
interval = 10 # seconds between two rounds
timeout = 5 # seconds before one check is abandoned
threshold = 3 # consecutive failures before a backend leaves the pool
path = "/health"NexaPipe v0.1.1
Highlights
A server-side allow-list of client Node IDs — [peers] allow
Until now any peer that learned the Node ID could complete the 2FA handshake; that gap was
listed openly in the README's Known gaps. [peers] closes it:
[peers]
allow = [
"a1b2c3d4e5f6...", # a client's Node ID, exactly as the app shows it
"0f1e2d3c4b5a...",
]- The check runs inside the QUIC handshake, before the connection is accepted: an unlisted
peer is closed with application code5and nothing else happens — no stream is opened, no
connection slot is taken, no task is spawned. - It composes with what already exists instead of replacing it:
[peers]answers may this Node
ID be here, 2FA answers who is it, andallow_hostsanswers what may it touch. A server
running with 2FA off now has a way to refuse strangers, and a server running 2FA too has both. - Absent means unrestricted. No
[peers]section (or the section withoutallow) behaves
exactly as every build before this one — nothing about an upgrade starts refusing traffic. - Two sharp edges were deliberately made un-guessable: an entry that is not a valid Node ID is a
startup error rather than a skipped line, andallow = []is refused instead of being
read as "refuse everybody" (a plausible typo that would lock you out of your own server). - It is read once at startup, like
[iroh]— restart-only for this release.
Both sides share the new close code, so the failure is actionable instead of a mystery: the
client library now reports "the server runs a [peers] allow-list and does not permit this Node
ID" where it previously reported a bare connection loss.
Enabling 2FA no longer needs a restart
[auth] enabled = true is now picked up by the 5-second config poll and applies to connections
opened after the reload; connections that already authenticated keep the authorization they were
given. Two reload changes are refused rather than applied, because applying them would weaken
a running server:
enabled = falsewhile 2FA is gating — restart to turn it off;enabled = truewhile the plaintext listener is exposed orconfig.tomlis readable by other
accounts — both combinations are already refused at startup.
The TOTP parameters (algorithm, time_step, digits, window, issuer, max_attempts,
lockout_duration) are still read once at startup.
New clients in one command: --generate-invite --create-client
Adding a client used to take two steps (--generate-2fa, then --generate-invite). --create-client
generates the secret, writes it to config.toml and puts it into the invite in one run, and pairs
with --registration so a brand-new device can be enrolled by a single scanned link:
cargo run -p nexapipe -- --generate-invite client-001 --create-client --registrationIt only ever fills in a client that is missing entirely — a client that already exists keeps
the secret it has, so rotation stays the explicit --generate-2fa CLIENT_ID --force.
Consistent line endings and a blocking format gate
.gitattributespins the policy: everything stores and checks out LF, while Windows-only
scripts (.bat,.cmd,.ps1) keep CRLF and binary formats are listed explicitly. Windows
clones no longer produce a CRLF working tree that later shows untouched files as fully modified.- The whole Rust tree is now formatted with rustfmt, and CI's
cargo fmt --all -- --checkis
blocking instead of advisory, on a pinned toolchain.
Docs and naming
README.zh-CN.md— a full Chinese translation, linked from the top of the English README, plus
screenshots of both client apps.- The Android asset is now named
nexa-<version>-arm64-v8a.apk(previously
nexapipe-…), so one release no longer ships two names for the same GUI product —nexafor
the apps andnexapipefor the CLI, matching the desktop bundles.
Upgrading
Nothing is required: every new key is optional, and a config written for v0.1.0 behaves exactly
the same. Add [peers] allow and turn [auth] enabled on while the server runs when you want
them; restarting is only needed for TOTP parameters, [peers], [iroh], [server] and [log].
Two things worth knowing before you upgrade:
- Revocation (a deleted
[auth.clients.<id>], a rotated secret, an editedallow_hosts) takes
effect on new connections only — a connection snapshots its authorization when it
authenticates. - 2FA remains a symmetric shared secret, so anyone who scans an invite becomes a legitimate
client and the finest revocation granularity is still one client section per device. Use
--generate-invite --registration, whose enrollment token is burned by the first device that
connects.
Downloads
All attached below, with a SHA256SUMS.txt covering every asset:
- Server — 8 targets: Linux (glibc + musl), macOS and Windows, x86_64 + aarch64.
- Desktop — NSIS (Windows x64/arm64), DMG (macOS Intel/Apple Silicon), deb / rpm / AppImage
(Linux x64/arm64), pluslatest.jsonfor the updater. - Android — signed
nexa-0.1.1-arm64-v8a.apk.
Full changelog: v0.1.0...v0.1.1
Feedback
Found a bug or a rough edge? Please open an issue
— for anything exploitable, contact a maintainer directly instead.
## What changed since v0.1.0 (for reference)
| PR | Subject |
| --- | --- |
| #44 | feat: add a `[peers]` Node ID allow-list and live 2FA enablement |
| #45 | Pin line endings with `.gitattributes`, and sync the Chinese README |
| #46 | Format the Rust tree with rustfmt, and make the CI check blocking |
Release target should be upstream `main` = `82f1d14` — **not** the local `main` (`9adcd8d`),
which is behind it by exactly these three PRs. See notes below.
## Before tagging (local observations)
- Workspace `Cargo.toml` still says `version = "0.1.0"`. `release.yml` only **warns** on a tag /
Cargo.toml mismatch, so the release still builds — bump it if you want a clean log.
Desktop (`tauri.conf.json`) and Android (`-PversionName`) versions are derived from the tag and
need no manual bump.
- For people upgrading: the README's *Known gaps* table now uses a *What gates the listener*
explanation instead — `[peers]` answers the "no allow-list of client public keys" gap, so that
row moves from open to fixed.
NexaPipe v0.1.0
NexaPipe exposes HTTP, HTTPS, WebSocket, TCP and UDP services that live behind NAT through a single iroh/QUIC endpoint — without terminating TLS, without a control plane, and without a server to rent.
Server (nexapipe)
- L7 reverse proxy over iroh/QUIC: routing by
Hostand path prefix, round-robin / random load balancing, WebSocket upgrade, backend health checks. - TLS passthrough: matched by SNI and copied through as bytes — the proxy holds no certificate and never sees a plaintext byte of an
https://request. Your backend keeps the certificates. - L4 tunnel for TCP and UDP: raw TCP flows and framed UDP flows over QUIC bi-streams, via a dependency-free wire format (
nexapipe-proto). - NAT traversal without infrastructure: clients hole-punch straight to your endpoint, with relay fallback (
default/pinned/disabled/custom). - Live configuration: routes and auth clients re-read every 5 seconds, applied without a restart; an invalid edit is ignored, never taken live.
- Docker support via the bundled
docker-compose.yaml.
Client library (nexapipe-client)
The part no hosted tunnel can copy — embed it in your own app:
- Rust
rlibwith connection pooling, domain-to-node routing and load balancing. - Local HTTP proxy and a smoltcp-based TUN proxy with one virtual IP per domain (which is what makes UDP possible).
cdylibfor Android JNI and UniFFI bindings for Swift / Kotlin / Python.
Client apps
- Android (Kotlin + Compose): VpnService TUN with DNS hijack and TCP/UDP redirect, QR-code invite and 2FA import.
- Desktop (Tauri 2 + Vue 3): local HTTP proxy or system TUN, with signed auto-updates on Windows, macOS and Linux.
Security
- TOTP 2FA handshake on every client connection; the response signature is verified before the code comparison, and any standard authenticator app works via
otpauth://QR codes. - One-time enrollment invites: the first device to connect burns the token and receives a freshly issued secret, so a copied link stops being a credential the moment it is used.
- Query strings redacted in access logs by default; tokens in
?token=…never land on disk. - Hardened defaults: the direct listener is unbound unless configured, non-loopback binds require
expose = true, per-peer connection caps, and the config holding 2FA secrets must be owner-only readable.
Downloads
All attached below, with a SHA256SUMS.txt covering every asset:
- Server — 8 targets: Linux (glibc + musl), macOS and Windows, x86_64 + aarch64.
- Desktop — NSIS (Windows x64/arm64), DMG (macOS Intel/Apple Silicon), deb / rpm / AppImage (Linux x64/arm64), plus
latest.jsonfor the updater. - Android — signed
nexapipe-0.1.0-arm64-v8a.apk.
Feedback
Found a bug or a rough edge? Please open an issue — for anything exploitable, contact a maintainer directly instead.