Skip to content

Releases: open-nexa/nexapipe

v1.0.0

Choose a tag to compare

@github-actions github-actions released this 24 Oct 11:22
e2c0785

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 .dmg and the .app inside 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 logs

Run 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.toml puts its state in
    config.state.toml. The old state.toml beside 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_path and key_path, the route-level cert_path,
    key_path and redirect_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] enabled still defaults to
    false for 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 of 198.18.0.0/24, 198.19.0.0/24, 100.100.0.0/24, 172.26.0.0/24,
    10.200.0.0/24 and 10.0.0.0/24. A rule of your own that hard-codes
    10.0.1.x needs updating.
  • Desktop: the credentials move out of localStorage into 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

Choose a tag to compare

@open-nexa open-nexa released this 11 Oct 16:29
8faf433

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 a devices table — 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 code REVOKED = 6 that 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/connections answers who is connected rather than
    only how many.

  • nexapipe client list|add|revoke, each taking --device, writing
    config.toml through 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 (default true): 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.md since 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 /metrics is 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 vendored smoltcp patch; 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.toml and the --qr-out file 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

Choose a tag to compare

@open-nexa open-nexa released this 11 Oct 01:55
b1a6f48

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 a devices table — 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 code REVOKED = 6 that 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/connections answers who is connected rather than
    only how many.

  • nexapipe client list|add|revoke, each taking --device, writing
    config.toml through 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 (default true): 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.md since 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 /metrics is 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 vendored smoltcp patch; 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.toml and the --qr-out file 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

Choose a tag to compare

@open-nexa open-nexa released this 07 Oct 08:50
7ec19c4

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: passthrough and tcp routes are probed at last, by connecting to
    the backend — a TLS one by completing its handshake. Until now only http
    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. A udp-only route is still
    not probed, because every probe available is one every backend would fail.
  • Server: what it carried in bytes. /metrics gains
    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.md takes 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.toml that works
    on 0.4.0 loads unchanged.
  • passthrough and tcp routes 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 same 0600 file 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

Choose a tag to compare

@open-nexa open-nexa released this 01 Oct 01:20
9703fdd

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_conn joins round_robin and
    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 AAAA as well as A, 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-v8a for a phone, x86_64 for 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 localStorage in 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

Choose a tag to compare

@open-nexa open-nexa released this 29 Sep 13:30
100629e

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 document

What /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 /healthz always and GET /metrics behind [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
    prometheus crate was added and Cargo.lock is untouched.
  • The read-only management surface behind a generated token: GET /v1/status,
    /v1/routes, /v1/clients, /v1/connections and /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, otpauth export 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
    whatever relay_mode says. custom constrains only this endpoint, and
    Endpoint ID discovery still queries dns.iroh.link in 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.1 and Cargo.lock resolves 1.2.0. The line
    now defers to docs/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_addr the
    listener is not bound, so upgrading changes no attack surface.
  • [metrics] enabled without an [admin] section logs a warning and serves
    nothing.
  • [admin] listen_addr, [metrics] enabled and [server] listen_addr are
    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.txt

Full 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

Choose a tag to compare

@open-nexa open-nexa released this 28 Sep 16:15
2e21fcd

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-written Debug impls now
    redact them.
  • The client auth handshake had an unbounded allocation and no deadline. A 4-byte
    0xFFFFFFFF length 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 plaintext SharedPreferences / 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-length bodies 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.com matched notexample.com);
    IPv6 Host headers 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

Choose a tag to compare

@open-nexa open-nexa released this 27 Sep 05:05
82f1d14

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 code 5 and 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, and allow_hosts answers 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 without allow) 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, and allow = [] 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 = false while 2FA is gating — restart to turn it off;
  • enabled = true while the plaintext listener is exposed or config.toml is 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 --registration

It 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

  • .gitattributes pins 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 -- --check is
    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 — nexa for
    the apps and nexapipe for 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 edited allow_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), plus latest.json for 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

Choose a tag to compare

@open-nexa open-nexa released this 25 Sep 16:27
81f6c69

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 Host and 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 rlib with 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).
  • cdylib for 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.json for 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.