Skip to content

net/cloudflared: new plugin for Cloudflare Tunnel integration#5429

Open
insanityinside wants to merge 4 commits intoopnsense:masterfrom
insanityinside:net/cloudflared
Open

net/cloudflared: new plugin for Cloudflare Tunnel integration#5429
insanityinside wants to merge 4 commits intoopnsense:masterfrom
insanityinside:net/cloudflared

Conversation

@insanityinside
Copy link
Copy Markdown

@insanityinside insanityinside commented May 8, 2026

Important notices

Before you submit a pull request, we ask you kindly to acknowledge the following:

If AI was used, please disclose:

  • Model used: Claude Sonnet 4.6 (Anthropic)
  • Extent of AI involvement: Code generation and review assistance throughout development. All code has been manually reviewed and thoroughly tested.

Describe the problem

OPNsense has no native interface for managing Cloudflare Tunnel (cloudflared). Users behind CGNAT — where a publicly reachable WAN IP is not available — commonly use Cloudflare Tunnel as a supported, production-grade solution for exposing services without port forwarding. Managing it currently requires manual CLI setup with no OPNsense service integration.


Describe the proposed solution

Adds a new plugin net/cloudflared that integrates Cloudflare Tunnel into OPNsense as a first-class service. This is an extensive rework of #5406 (original authorship and credit: @AlanMartines), which this PR builds on and supersedes. It is revised to address the architectural feedback from that review, and integrates my real-world experience with the net/cloudflared port on native FreeBSD.

Key architectural change from #5406:
The original PR bundled a custom binary installer that downloaded cloudflared from a third-party GitHub fork. This plugin instead declares PLUGIN_DEPENDS= cloudflared, delegating binary management entirely to pkg via the FreeBSD ports tree. The plugin is a pure configuration wrapper, as requested in the review of the original PR. net/cloudflared would need adding to the OPNsense port builds as it is not currently present in the repo (I manually installed the pre-built FreeBSD binary package for testing). I'm assuming it's not an automatic process with that PLUGIN_DEPENDS line.

Functionality carried from the original PR:

  • Native MVC UI under Services > Cloudflare Tunnel > Settings
  • Token-based setup (Method 1 — managed tunnels via Cloudflare Zero Trust dashboard)
  • Secure token handling: passed via TUNNEL_TOKEN environment variable so it does not appear in ps aux; /etc/rc.conf.d/cloudflared set to 0600 permissions when configuration written
  • Post-quantum encryption option (--post-quantum)
  • QUIC performance sysctl tunables (kern.ipc.maxsockbuf, net.inet.udp.recvspace)
  • Real-time tunnel health status badge (healthy/connecting) via local metrics endpoint
  • Registered in System: Diagnostics: Services with start/stop/restart

Enhancements added in this rework:

  • Transport protocol selector: Auto (QUIC with HTTP/2 fallback, default), QUIC-only, or HTTP/2-only.
  • Automatic outbound firewall rules via cloudflared_firewall() hook: UDP 7844 for QUIC, TCP 7844 for HTTP/2, both active in Auto mode
  • quic-disable-pmtu-discovery option: workaround for intermittent QUIC stream errors on networks where ICMP is filtered
  • Log viewer tab with client-side pagination (25/50/100/200 lines/page, Older/Newer navigation) and live Follow mode
  • Crash recovery: monitor.sh syshook and cron job restart cloudflared if it exits unexpectedly; sentinel file suppresses watchdog after intentional stop
  • newwanip/newwanip6 hook to restart on WAN IP change, if daemon exited with error
  • Boot-time startup via configure hook
  • Hardcoded no-autoupdate: true (pkg manages the binary; self-update is inappropriate)
  • Security notice in UI: tunnel traffic bypasses OPNsense firewall rules and access control must be enforced in Cloudflare Access

Notes:

Translations:
Machine-assisted translations for 20 languages, including original pt-BR translations from #5406, are prepared and ready to be contributed to the OPNsense lang repo via POEditor as a follow-up to this PR. I'm assuming that's the correct way to handle the translated strings for the plugin, please correct me if not.

Testing:
Tested on OPNsense 26.1.7 / FreeBSD 14.x (amd64).

  • Enable/disable service via Apply
  • Token accepted and not visible in ps aux
  • Auto mode: tunnel connects, falls back to HTTP/2 if QUIC unavailable after around 2 minutes of retries as per cloudflared default behaviour
  • QUIC-only mode: UDP 7844 firewall rule present, tunnel connects
  • HTTP/2-only mode: TCP 7844 firewall rule present, tunnel connects
  • Post-quantum and PMTU options passed through to cloudflared
  • Log viewer loads, paginates, and refreshes correctly, follow mode works correctly without any visual artifacts
  • Tunnel health badge reflects live state
  • Service appears in System: Diagnostics: Services
  • Confirmed behaviour when booted without WAN or DNS available, automatic recovery functional
  • Tested tunnel functionality to internal network services

Related issue

Closes #5070

AlanMartines and others added 3 commits May 6, 2026 16:32
Reworks the original plugin by Alan Martines to address the architectural
feedback on PR opnsense#5406: the custom binary installer is replaced with
PLUGIN_DEPENDS= cloudflared, delegating binary management entirely to pkg
via the FreeBSD ports tree. The plugin is now a pure configuration wrapper.

Binary and service:
- Remove install_binary.sh and bundled rc.d script; use FreeBSD port
- Pass tunnel token via TUNNEL_TOKEN env var (cloudflared_env in rc.subr)
  so it does not appear in ps aux; /etc/rc.conf.d/cloudflared chmod 600
- Add config.yml template; move options out of rc.conf.d command args
- Hardcode no-autoupdate: true (pkg manages the binary; self-update
  is inappropriate)

New features:
- Transport protocol selector: Auto (QUIC with HTTP/2 fallback, default),
  QUIC-only (UDP 7844), HTTP/2-only (TCP 443)
- Automatic outbound firewall rule for TCP/UDP 7844 via cloudflared_firewall()
  hook; UDP active for Auto and QUIC-only modes, TCP for Auto and HTTP/2-only
- quic-disable-pmtu-discovery option: workaround for intermittent QUIC
  stream errors on networks where ICMP is filtered
- Log viewer tab with client-side pagination (25/50/100/200 lines/page,
  Older/Newer navigation) and Follow mode for live tailing
- Crash recovery: monitor.sh syshook and cron job restart cloudflared if
  it exits unexpectedly; sentinel file suppresses watchdog after intentional
  stop
- newwanip/newwanip6 hook to restart on WAN IP change if daemon exits

Reliability fixes:
- Improve tunnel health detection: cross-check Prometheus metrics against
  log output to catch stale ha_connections; report accurate down state

Other:
- Security notice in UI: tunnel traffic bypasses OPNsense firewall rules
- Translations for 20 languages in addition to the original pt_BR
  (machine generated)
- BSD license headers on all scripts
- README.md entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@insanityinside insanityinside marked this pull request as ready for review May 8, 2026 02:00
Copilot AI review requested due to automatic review settings May 8, 2026 02:00
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new net/cloudflared plugin to integrate Cloudflare Tunnel (cloudflared) into OPNsense as a managed service with a native MVC UI and backend/service wiring, relying on the FreeBSD cloudflared package for the binary.

Changes:

  • Introduces MVC pages + API endpoints to configure, control, and display tunnel/log status.
  • Adds configd actions, templates, and scripts to generate config, apply tunables, and expose tunnel health.
  • Adds firewall hook + watchdog (cron) for protocol-specific outbound rules and crash recovery.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
README.md Adds the new plugin to the repository plugin list.
net/cloudflared/Makefile Declares the plugin and its dependency on cloudflared.
net/cloudflared/pkg-descr Describes the plugin’s purpose and major features.
net/cloudflared/LICENSE Adds plugin license file.
net/cloudflared/src/opnsense/service/templates/OPNsense/Cloudflared/+TARGETS Installs generated templates to rc.conf.d, config.yml, and sysctl.d.
net/cloudflared/src/opnsense/service/templates/OPNsense/Cloudflared/rc.conf.d Generates rc.conf.d service enablement + token env wiring.
net/cloudflared/src/opnsense/service/templates/OPNsense/Cloudflared/config.yml Generates cloudflared config (metrics, protocol, flags).
net/cloudflared/src/opnsense/service/templates/OPNsense/Cloudflared/tunables.conf Generates sysctl tunables for QUIC performance.
net/cloudflared/src/opnsense/service/conf/actions.d/actions_cloudflared.conf Defines configd actions (start/stop/restart/status/reconfigure/log/tunnel_status).
net/cloudflared/src/opnsense/scripts/OPNsense/Cloudflared/reconfigure.sh Reloads templates, applies sysctls, restarts service, reloads firewall.
net/cloudflared/src/opnsense/scripts/OPNsense/Cloudflared/tunnel_status.sh Computes tunnel health via metrics + log cross-check.
net/cloudflared/src/opnsense/scripts/OPNsense/Cloudflared/monitor.sh Watchdog script to restart the service when it dies unexpectedly.
net/cloudflared/src/opnsense/mvc/app/views/OPNsense/Cloudflared/index.volt Adds UI for settings, service controls, health badge, and log viewer.
net/cloudflared/src/opnsense/mvc/app/models/OPNsense/Cloudflared/Menu/Menu.xml Adds Services menu entry for the plugin UI.
net/cloudflared/src/opnsense/mvc/app/models/OPNsense/Cloudflared/ACL/ACL.xml Adds ACL for UI/API endpoints.
net/cloudflared/src/opnsense/mvc/app/models/OPNsense/Cloudflared/Cloudflared.xml Defines plugin configuration model (enabled/token/protocol/tunables).
net/cloudflared/src/opnsense/mvc/app/models/OPNsense/Cloudflared/Cloudflared.php Adds BaseModel class for the plugin model.
net/cloudflared/src/opnsense/mvc/app/controllers/OPNsense/Cloudflared/IndexController.php Serves the main UI page and general form.
net/cloudflared/src/opnsense/mvc/app/controllers/OPNsense/Cloudflared/forms/general.xml Defines UI fields (token, protocol, tunables, flags).
net/cloudflared/src/opnsense/mvc/app/controllers/OPNsense/Cloudflared/Api/SettingsController.php Exposes settings get/set API for the model.
net/cloudflared/src/opnsense/mvc/app/controllers/OPNsense/Cloudflared/Api/ServiceController.php Exposes service actions plus tunnel status/log APIs.
net/cloudflared/src/etc/inc/plugins.inc.d/cloudflared.inc Adds firewall hook, WAN-IP hook behavior, and service registration.
net/cloudflared/src/etc/cron.d/cloudflared Adds a per-minute cron job to run the watchdog script.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread net/cloudflared/src/etc/inc/plugins.inc.d/cloudflared.inc
Comment thread net/cloudflared/src/etc/inc/plugins.inc.d/cloudflared.inc
Comment on lines +1 to +2
# DO NOT EDIT THIS FILE -- OPNsense auto-generated file
#
Copy link
Copy Markdown
Author

@insanityinside insanityinside May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follows OPNsense plugin conventions, that's why I left it in - it was flagged it in review.

@insanityinside
Copy link
Copy Markdown
Author

insanityinside commented May 8, 2026

I did not intend to invoke Copilot to review that, automatic Github settings at work.

It did identify a couple of useful things (bad links) which I fixed in f99ed31 below that I hadn't caught in my code review, and one comment on the cronjob - followed convention I saw in other plugins, but happy to tidy up the header comments if appropriate.

<post_quantum type="BooleanField">
<default>0</default>
</post_quantum>
<kern_ipc_maxsockbuf type="IntegerField">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove the tunables glue here. Documenting what needs to be changed is better.

};

// Save settings and reconfigure the service
$("#saveAct").click(function() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use SimpleActionButton, it does this all for you.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what I get for letting Claude do the heavy lifting. Will look into it.

});

// Service control
$("#startBtn").click(function() {
Copy link
Copy Markdown
Member

@Monviech Monviech May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be needed, check out examples here:

https://github.com/opnsense/plugins/blob/master/net/ndp-proxy-go/src/opnsense/mvc/app/views/OPNsense/NdpProxy/general.volt

In general check out this above plugin. I made it very recently and its a good reference how a lean wrapper plugin should look like.

updateServiceStatus(i18n);
setInterval(function() { updateServiceStatus(i18n); }, 5000);

// Log tab: load on first activation, refresh on demand
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just use the log page thats included in core

1,
[
'descr' => 'Allow Cloudflare Tunnel QUIC (autogenerated)',
'direction' => 'out',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove auto generated firewall rules. Traffic initiated by the firewall itself has a default out allow rule.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't work in real life without an outbound rule, pf blocked the traffic, I was surprised by the behaviour - I didn't expect this was necessary.

What's the best approach here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends on your ruleset, if you have rules somewhere that block all traffic in the out direction (e.g. in floating) e.g. in GeoIP or blocklist aliases, this can happen.

But generally there is a "Allow all" rule for firewall initiated traffic, you don't need these rules in the plugin.

Best remove auto generated rules in general and document these rules might be needed to be set manually. A small https://github.com/opnsense/docs manual how to use your plugin is a good choice for that.

E.g. here an example from another plugin I maintain:
https://github.com/opnsense/docs/blob/master/source/manual/how-tos/caddy.rst#prepare-opnsense-for-caddy-after-installation

@Monviech
Copy link
Copy Markdown
Member

Monviech commented May 8, 2026

I would suggest you remove as much cruft as possible and turn this into a minimum viable product first. I'm sure it could be 30-40% less code than this when using modern examples (e.g. the plugin I referenced).

I work with AI every day, and this output here looks too random and overdesigned at quite a few spots. I don't know why the AI made those decisions, but better prompting and better examples should improve the output by a lot.

Also keep in mind that you will be the owner of the end result and you will need to maintain this and give support for it.

function cloudflared_configure()
{
return [
'newwanip' => ['cloudflared_configure_newwanip:10'],
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would that be needed, cloudflare should bind to the * (any) socket, so it won't crash when some IP address changes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:10 leaves me baffled

Copy link
Copy Markdown
Author

@insanityinside insanityinside May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's weird behaviour that came up in real-world testing, if it came started before the WAN was up and it couldn't resolve the Cloudflare router IPs. If it can't resolve them on startup, the process exits. It was a belt and braces approach.

Can't explain the :10 though, that's a Claude special.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the code you are using the default number of parameters is ok which is 1 so you can omit the ":10" completely.

{
return [
'newwanip' => ['cloudflared_configure_newwanip:10'],
'newwanip6' => ['cloudflared_configure_newwanip:10'],
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'newwanip6' isn't a thing

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops. Nice hallucination. 🤦🏻‍♂️

@insanityinside
Copy link
Copy Markdown
Author

Understood. Leave it with me, I'll rebuild it clean by hand, and do my research properly. While it works, it can be a lot slimmer, and I'll take the comments on-board.

Worth closing this PR for now and I'll recreate with a much tidier version? I leant a bit too hard on Claude, and I'm still learning the ropes with LLM code generation vs just writing it myself.

Appreciate the feedback, I'm on it.

@Monviech
Copy link
Copy Markdown
Member

Monviech commented May 8, 2026

You can leave this open, it helps to track the review progress and the actual changes you implement to improve it. No rush with anything.

if (!cloudflared_enabled()) {
return;
}
if (file_exists('/var/run/cloudflared.stopped')) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

usually we get around these special magic files unless we're dealing with difficult scenarios such as CARP which are decoupled in cause and effect

Copy link
Copy Markdown
Author

@insanityinside insanityinside May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I put that in there was to stop the cronjob auto-restarting the daemon if a user had manually stopped the service while it was still enabled. Covered the scenario if it'd exited due to the previously mentioned odd behaviour if it couldn't resolve the Cloudflare endpoints. If there's a better way, I'm happy to implement.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kinda reminds me of wireguard startup failures for configuration where it needs to resolve a hostname, and it only does so exactly once.

Why cant the Cloudflare tunnel thing be smarter and retry it longer, it's purpose is to be behind whacky networks after all... xD

Copy link
Copy Markdown
Author

@insanityinside insanityinside May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the behaviour of the daemon is impressively dumb. Why it doesn't just loop round to try and resolve again is beyond me, but it just bails out. Only seems to occur if it starts up before it can resolve DNS though - it's fine if it can resolve but can't actually connect. Appeared when I was testing all failure scenarios, when starting the box on a bench with no connection plugged in, but also occurs when the box starts up before it can connect to external resolvers if the daemon comes up first.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is how core solves this for wireguard

https://github.com/opnsense/core/blob/f648476a665117e37f6693b55fdbc701677e33eb/src/opnsense/service/conf/actions.d/actions_wireguard.conf#L26-L31

If you add a description to an action, it appears configurable in "System - Settings - Cron".

Affected users can then create a cron job if they want to, which is cheap for you since you need less glue to prevent that startup issue in your code.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure why we need this here in this way... there's no need to kill the current one, posix_kill() is not in our code/modules either and we can trust the rc framework to start it. the dynamic hook is ok. there are other ways but none of them are perfect anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: add CloudFlare tunnel

5 participants