Using `cloudflared` with FreeBSD (and pfSense)
Getting Cloudflare's cloudflared
CLI tool working with FreeBSD (and variants)
is easy, but completely undocumented online. Below are instructions (with some
background) on how to get it working with remotely-managed Cloudflare Zero Trust
tunnels!
My goals to get cloudflared
tunnel working on FreeBSD were that it should:
- Run as a service.
- Be remotely-managed from Cloudflare Zero Trust, like Linux, Windows, and macOS.
- Not require any third-party dependencies.
If you only care about the fix and not the process of getting there, feel free to use the table of contents to skip to Step-by-step instructions below.
Cloudflare's cloudflared
CLI tool
has been officially available for FreeBSD
since late 2019, but getting it to work with Cloudflare's Zero Trust tunnels has
never been as straight-forward to set up as it has been for other operating
systems.
There have been quite a few workarounds since it was first published, but many of these workarounds require trusting third-party code and configurations. Of course, this isn't always feasible — much less desirable — in a business context (or for those who are conscientious with their privacy).
What would it take to do better? Not much, as it turns out — all the parts are there, we just need to put them together!
The main issue stems from the fact that, after installing cloudflared
(either
via pkg
or building from source), the
normal initialization command
provided during Cloudflare tunnel setup returns the following error message on
FreeBSD systems:
$ cloudflared service install eyJhIjoiNj...
You did not specify any valid additional argument to the cloudflared tunnel command.
If you are trying to run a Quick Tunnel then you need to explicitly pass the --url flag.
Eg. cloudflared tunnel --url localhost:8080/.
Please note that Quick Tunnels are meant to be ephemeral and should only be used for testing purposes.
For production usage, we recommend creating Named Tunnels. (https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/tunnel-guide/)
Which, fair enough, FreeBSD isn't Linux, so finding some discrepancies here is
only to be expected. Back to the error message: at first glance, this message
seems completely unrelated to what we requested cloudflared
to do.
What happens if we attempt to run the service without any additional
configuration? We'll use onestart
since we're still testing things out:
$ service cloudflared onestart
Starting cloudflared.
$ service cloudflared onestatus
cloudflared is not running.
Well, looks like the process probably crashed immediately. Luckily,
cloudflared
is well-behaved and writes its logs to /var/log/cloudflared.log
.
Reviewing those logs shows the same error as before:
$ tail /var/log/cloudflared.log
You did not specify any valid additional argument to the cloudflared tunnel command.
If you are trying to run a Quick Tunnel then you need to explicitly pass the --url flag.
Eg. cloudflared tunnel --url localhost:8080/.
Please note that Quick Tunnels are meant to be ephemeral and should only be used for testing purposes.
For production usage, we recommend creating Named Tunnels. (https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/tunnel-guide/)
So what's going on here? Why are we getting an error message on FreeBSD when the same setup command works on other operating systems?
At the time, I wasn't aware that cloudflared
was
already open-sourced, so I did a
little digging in the FreeBSD ports tree to find some more info about
it. Turns out this was a good thing, since it provided some additional context
for how the cloudflared
operates by default on FreeBSD.
Let's peek at the
contents of the FreshPorts tree for cloudflared
:
.
├── Makefile
├── distinfo
├── files/
│ └── cloudflared.in
└── pkg-descr
distinfo
and
pkg-descr
only contain port metadata, so let's focus on
Makefile
and
files/cloudflared.in
.
Okay, so these lines indicate that the source is on GitHub.
USE_GITHUB= yes
...
GO_PKGNAME= github.com/${GH_ACCOUNT}/${PORTNAME}
After subbing in the variables we get
github.com/cloudflare/cloudflared
— and oh neat, turns out it's been open-sourced! That saves us some time. Then
the following line tells us where the build target is (and subsequently where we
should start our investigation) — namely,
/cmd/cloudflared
.
GO_TARGET= ${GO_PKGNAME}/cmd/cloudflared
Awesome, we could immediately jump into the source code. While we're here, though, let's look at the other file we called out above:
#!/bin/sh
# PROVIDE: cloudflared
# REQUIRE: cleanvar DAEMON NETWORKING
#
# Options to configure cloudflared via /etc/rc.conf:
#
# cloudflared_enable (bool) Enable service on boot
# Default: NO
#
# cloudflared_conf (str) Config file to use
# Default: %%ETCDIR%%/config.yml
#
# cloudflared_mode (str) Mode to run cloudflared as (e.g. 'tunnel', 'tunnel run'
# or 'proxy-dns'). Should you use the default, a free
# tunnel is set up for you.
# Default: "tunnel"
. /etc/rc.subr
name="cloudflared"
rcvar="cloudflared_enable"
logfile="/var/log/cloudflared.log"
pidfile="/var/run/cloudflared.pid"
procname="%%PREFIX%%/bin/cloudflared"
load_rc_config $name
: ${cloudflared_enable:="NO"}
: ${cloudflared_conf:="%%ETCDIR%%/config.yml"}
: ${cloudflared_mode:="tunnel"}
command="/usr/sbin/daemon"
command_args="-o ${logfile} -p ${pidfile} -f ${procname} --config ${cloudflared_conf} ${cloudflared_mode}"
run_rc_command "$1"
This is a (pretty simple) service declaration for rc.d
. The default location
for this file after installation on FreeBSD will be at
/usr/local/etc/rc.d/cloudflared
.
If you're unfamiliar with
rc.d
or otherwise curious, there's a good article diving into the history and structure ofrc.d
scripts available here: Practical rc.d scripting in BSD.
A couple of things to note that will be important for later:
- Like all good FreeBSD services, it's disabled by default
(
: ${cloudflared_enable:="NO"}
). - Per the inline documentation at the top,
tunnel
is the default service mode (: ${cloudflared_mode:="tunnel"}
).
So far, we've found the source code for cloudflared
as well as the rc.d
service declaration script. Let's peek at the cloudflared
source code file
structure, specifically under the
cmd/cloudflared
build target path specified above:
.
├── access/
├── cliutil/
├── proxydns/
├── tail/
├── tunnel/
├── updater/
├── app_forward_service.go
├── app_resolver_service.go
├── app_service.go
├── common_service.go
├── generic_service.go
├── linux_service.go
├── macos_service.go
├── main.go
├── service_template.go
└── windows_service.go
Note that each of the main supported operating systems have their own
*_service.go
file (i.e.
linux_service.go
,
macos_service.go
, and
windows_service.go
.
Whereas
generic_service.go
seems to be the fallback for other operating systems, as hinted at by the build
constraints for that file:
//go:build !windows && !darwin && !linux
I won't go into a full breakdown of what these *_service.go
files do, but
there's one important thing to note for our situation: linux_service.go
,
macos_service.go
, and windows_service.go
all define an additional service
CLI command, with install
and uninstall
subcommands:
func runApp(app *cli.App, graceShutdownC chan struct{}) {
app.Commands = append(app.Commands, &cli.Command{
Name: "service",
Usage: "Manages the cloudflared system service",
Subcommands: []*cli.Command{
{
Name: "install",
Usage: "Install cloudflared as a system service",
// Note: the next line has been modified for clarity.
Action: cliutil.ConfiguredAction(installService),
Flags: []cli.Flag{
noUpdateServiceFlag,
},
},
{
Name: "uninstall",
Usage: "Uninstall the cloudflared service",
// Note: the next line has been modified for clarity.
Action: cliutil.ConfiguredAction(uninstallService),
},
},
})
app.Run(os.Args)
}
However, the generic_service.go
fallback notably omits this command entirely!
func runApp(app *cli.App, graceShutdownC chan struct{}) {
app.Run(os.Args)
}
That means that, when cloudflared service install
is run on these other
operating systems, the service install
arguments are treated like arguments to
the root cloudflared
command, not as its own subcommand.
We can see this in action by running the cloudflared
command again with any
arbitrary text as the "command", which gives us our original error message
again:
$ cloudflared arbitrary-text-that-is-not-a-command install
You did not specify any valid additional argument to the cloudflared tunnel command.
If you are trying to run a Quick Tunnel then you need to explicitly pass the --url flag.
Eg. cloudflared tunnel --url localhost:8080/.
Please note that Quick Tunnels are meant to be ephemeral and should only be used for testing purposes.
For production usage, we recommend creating Named Tunnels. (https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/tunnel-guide/)
Okay, nice! Now we know why the error is happening. But how do we resolve it?
In the previous section, we found that the normal service installation command
is simply not available on FreeBSD — instead, a static rc.d
script is provided
within the FreeBSD port itself. Are there any major differences between what's
provided in the FreeBSD port and the Linux distribution?
To make a long story slightly shorter: yes! For all platforms, the
common_service.go
file provides the following function, which:
- Validates the token provided to
cloudflared service install <token>
. - Generates the command arguments for generated services using the provided token, essentially as-is.
func buildArgsForToken(c *cli.Context, log *zerolog.Logger) ([]string, error) {
token := c.Args().First()
if _, err := tunnel.ParseToken(token); err != nil {
return nil, cliutil.UsageError("Provided tunnel token is not valid (%s).", err)
}
return []string{
"tunnel", "run", "--token", token,
}, nil
}
Hey, the first parts of the returned array looks like one of the
cloudflared_mode
options mentioned in the prebuilt, bundled FreeBSD service
file above!
But wait, the FreeBSD service defaults to tunnel
, not tunnel run
. What
happens if we change that to match what we found above? Let's configure the
service with:
$ sysrc -f /etc/rc.conf.d/cloudflared cloudflared_enable="YES"
cloudflared_enable: -> YES
$ sysrc -f /etc/rc.conf.d/cloudflared cloudflared_mode="tunnel run"
cloudflared_mode: -> tunnel run
Note: we switch to using
sysrc
to enable and configure the service, since a) defining configurations withservice -E key=value <service name> onestart
doesn't properly support spaces (even with quoting) and b)service <service name> onestart
ignores any configurations defined in anyrc
configuration files.
When starting the service and checking the log again, we now see a new error:
$ service cloudflared start
Starting cloudflared.
$ tail /var/log/cloudflared.log
"cloudflared tunnel run" requires the ID or name of the tunnel to run as the last command line argument or in the configuration file.
See 'cloudflared tunnel run --help'.
Progress! Looks like we now just need to tell cloudflared
which tunnel to run.
While cloudflared
provides several options for defining and running tunnels,
we can keep things simple using just the information we've gathered thus far.
As we saw in the previous section, the
cloudflared service install eyJhIjoiNj...
command hard-codes the token (the
last part of the installation command) in a generated service, e.g.
--token eyJhIjoiNj...
.
However, we don't have the luxury of using that command to generate a service here — we only have the static one that was bundled with the FreeBSD port. Since that file could be changed with any update to the port, we should avoid mimicking how other operating systems bake-in the token.
While we could leverage the config.yml
file we saw in the Reviewing the
FreeBSD port section above, it's not officially documented as a valid place
to store the tunnel token.
Luckily, Cloudflare's own documentation provides
the solution:
the token
parameter can also be provided as an environment variable,
TUNNEL_TOKEN
. In rc.d
, ENV vars can be set for a service with
<service name>_env="KEY=value"
. We'll leverage that by doing:
$ sysrc -f /etc/rc.conf.d/cloudflared cloudflared_env="TUNNEL_TOKEN=eyJhIjoiNj..."
cloudflared_env: -> TUNNEL_TOKEN=eyJhIjoiNj...
Unfortunately, looks like sysrc
creates the file with global read permissions
— not great since this is storing a secret!
$ ls -l /etc/rc.conf.d/cloudflared
-rw-r--r-- 1 root wheel 271 Jul 8 13:11 cloudflared
We can fix that by doing the following, then confirming the new permissions:
$ chmod 640 /etc/rc.conf.d/cloudflared
$ ls -l /etc/rc.conf.d/cloudflared
-rw-r----- 1 root wheel 271 Jul 8 13:11 /etc/rc.conf.d/cloudflared
Finally, let's start cloudflared
again, ensure it stays running, and check the
log output:
$ service cloudflared start
Starting cloudflared.
$ service cloudflared status
cloudflared is running as pid 36940.
$ tail /var/log/cloudflared.log
2024-07-07T23:48:43Z INF Starting tunnel tunnelID=████████-████-████-████-████████████
2024-07-07T23:48:43Z INF Version 2023.10.0
2024-07-07T23:48:43Z INF GOOS: freebsd, GOVersion: go1.20.14, GoArch: amd64
2024-07-07T23:48:43Z INF Settings: map[config:/usr/local/etc/cloudflared/config.yml token:*****]
2024-07-07T23:48:43Z INF Autoupdate frequency is set autoupdateFreq=86400000
2024-07-07T23:48:43Z INF Generated Connector ID: ████████-████-████-████-████████████
2024-07-07T23:48:43Z INF Initial protocol quic
2024-07-07T23:48:43Z INF ICMP proxy will use ███.███.███.███ as source for IPv4
2024-07-07T23:48:43Z INF ICMP proxy will use ████::████:████:████:████ in zone ███ as source for IPv6
2024-07-07T23:48:43Z WRN ICMP proxy feature is disabled error="cannot create ICMPv4 proxy: ICMP proxy is not implemented on freebsd amd64 nor ICMPv6 proxy: ICMP proxy is not implemented on freebsd amd64"
2024-07-07T23:48:43Z INF Starting metrics server on 127.0.0.1:2480/metrics
2024-07-07T23:48:43Z ERR update check failed error="no release found"
2024-07-07T23:48:43Z INF Registered tunnel connection connIndex=0 connection=████████-████-████-████-████████████ event=0 ip=198.41.200.233 location=atl08 protocol=quic
2024-07-07T23:48:44Z INF Registered tunnel connection connIndex=1 connection=████████-████-████-████-████████████ event=0 ip=198.41.192.7 location=atl01 protocol=quic
2024-07-07T23:48:45Z INF Registered tunnel connection connIndex=2 connection=████████-████-████-████-████████████ event=0 ip=198.41.200.23 location=atl10 protocol=quic
2024-07-07T23:48:45Z INF Updated to new configuration config="{...}" version=██
2024-07-07T23:48:46Z INF Registered tunnel connection connIndex=3 connection=████████-████-████-████-████████████ event=0 ip=198.41.192.227 location=atl06 protocol=quic
Note: the above logs have been manually redacted to remove any potentially-sensitive information.
That looks significantly better! The single ERR
log is because cloudflared
can't auto-update on FreeBSD, and the WRN
log can be ignored — neither impact
functionality of the tunnel.
Last check: let's ensure Cloudflare thinks everything looks good, too. Looking at the tunnel in Cloudflare Zero Trust:
Excellent.
Note: if you're not setting up
cloudflared
on pfSense, you can skip this section.
Set up pfSense to allow installing FreeBSD packages:
- Read and understand the pfSense documentation regarding the risks associated with installing FreeBSD packages on pfSense.
- If comfortable proceeding after reading the above, complete the Installing Packages section of that same document, specifically the part beginning with "Additionally, the full set of FreeBSD packages can be made available by...".
-
Create or modify a tunnel.
-
In the tunnel's configuration page under the Install and run a connector section, copy the command under If you already have cloudflared installed on your machine.
-
Paste the command in a text editor and copy just the token (the last part of the command). Using the screenshot above as an example, the token would begin with
eyJhijoinj
.
On the target FreeBSD system's command line:
-
Install the
cloudflared
package with the following command (alternatively, you could build it yourself from the ports tree):$ pkg install cloudflared
-
Configure the service with the following commands:
$ sysrc -f /etc/rc.conf.d/cloudflared cloudflared_enable="YES" $ sysrc -f /etc/rc.conf.d/cloudflared cloudflared_mode="tunnel run" $ chmod 640 /etc/rc.conf.d/cloudflared
-
Add the token to the configuration, replacing
<token>
with the token you copied above:$ sysrc -f /etc/rc.conf.d/cloudflared cloudflared_env="TUNNEL_TOKEN=<token>"
-
Start the service:
$ service cloudflared start
-
Ensure the service is still running:
$ service cloudflared status
Back in Cloudflare Zero Trust:
-
Ensure the tunnel status shows as Healthy.
-
The tunnel is successfully configured! You can now remotely manage it from Cloudflare Zero Trust.
Like I said originally, configuring the service isn't too hard — any difficulty stems from the lack of documentation surrounding how to configure the service on operating systems other than Linux, macOS, and Windows.
I've also created
a pull request for cloudflared
in order to standardize the available commands on operating systems like
FreeBSD, even if service management isn't yet available for the OS. Hopefully
that gets merged and helps reduce confusion for end-users!