[plugin.public_tunnel] — manifest-driven Cloudflare quick tunnel
Phase 81.20.x Stage 7 Phase 2. Status: introduced 2026-05-16.
Plugins that expose HTTP routes the operator might want to reach
from outside the LAN (e.g. WhatsApp pairing while the daemon runs
on a desktop and the QR is scanned on a phone) declare this section.
The daemon spawns a Cloudflare quick
tunnel pointed
at its HTTP port; plugin routes exposed via
[plugin.http] become reachable at
https://*.trycloudflare.com/<plugin-mount-prefix>/....
Two-key opt-in
The daemon opens a public tunnel only when both are true:
- Plugin manifest:
[plugin.public_tunnel] enabled = true. - Operator capability env:
NEXO_PLUGIN_PUBLIC_TUNNEL_ALLOW=1.
A manifest declaration alone is not enough — the daemon still
honours the operator's hardening choice. Declaring the section
with enabled = false (or omitting it) keeps the plugin
forever-private even if the operator flips the env on for another
plugin.
Manifest section
[plugin.public_tunnel]
enabled = true
# Optional — when set, the daemon subscribes to this exact broker
# subject and closes the tunnel on the first published message.
# Wildcards (`*`, `>`) are rejected at manifest validation.
close_on_event = "plugin.lifecycle.whatsapp.tunnel_done"
Fields
enabled(bool, defaultfalse) — plugin-side opt-in. Whenfalse, the section behaves identically to "no section declared".close_on_event(string, optional) — literal broker subject. When set, the daemon subscribes once and tears the tunnel down on the first inbound message. Typical use: a pairing-channel plugin publishes<plugin>.tunnel_doneafter the operator completes pairing so the public URL stops responding immediately. WhenNone, the tunnel stays up for the daemon's lifetime.
Validation
Rejected at boot:
close_on_eventis the empty string or whitespace (PublicTunnelCloseEventEmpty).close_on_eventcontains a NATS wildcard (*or>). The daemon refuses wildcards so a stray plugin event can't race-close a healthy tunnel (PublicTunnelCloseEventWildcard).
URL sidecar file
When a tunnel comes up, the daemon writes the public URL to
$NEXO_HOME/state/tunnel.url (or ~/.nexo/state/tunnel.url).
The nexo pair start CLI reads this file directly so the operator
doesn't have to copy/paste from logs.
Migration from wa_tunnel_cfg
Earlier daemon revisions extracted public_tunnel from each
WhatsApp YAML entry under config/plugins/whatsapp.yaml and
spawned a Cloudflare tunnel inline. That orchestration was
removed (Phase 81.20.x Stage 7 Phase 2) — plugins now declare the
intent in their manifest, the daemon iterates uniformly, and the
operator's env flag stays authoritative.
Daemon-side wiring
main.rs iterates wire.plugin_handles after
wire_plugin_registry returns. For every plugin with
[plugin.public_tunnel] enabled = true, the daemon:
- Spawns
nexo_tunnel_quick::TunnelManager::new(8080).start(). - Logs the URL + writes it to the sidecar file.
- If
close_on_eventis set, spawns a broker subscriber that awaits one message then callstunnel.shutdown().await.
The capability gate is checked once at the top of the iterator block. When OFF, the iterator logs a single informational line ("declared by at least one plugin but env is not set") and skips every tunnel spawn — useful for hardened deployments that want a visible audit trail.
Threat model
https://*.trycloudflare.com/<plugin-prefix>/... is reachable
from anywhere the URL is shared. Cloudflare provides DDoS
protection + edge TLS but does NOT enforce authentication. The
plugin's HTTP handler is responsible for any access control on
the exposed paths.
Pairing pages are time-limited (the QR rotates ~every 20s, the
challenge expires in 5 minutes). When close_on_event is set,
the tunnel is teardown-immediate post-pairing — the URL becomes
404 the moment the plugin signals completion.
Validation commands
cargo nextest run -p nexo-plugin-manifest— manifest schema- validator tests.
- Manual smoke:
NEXO_PLUGIN_PUBLIC_TUNNEL_ALLOW=1 cargo run --bin nexowith a manifest declaring the section. Daemon log should show"public tunnel up"with the assigned URL.