With the introduction of the pre-resolved Sentry host, all Firezone
clients now require Internet on startup. That is a signficant usability
hit that we can easily fix by simply falling back to resolving the host
on-demand.
In CI, eBPF in driver mode actually functions just fine with no changes
to our existing tests, given we apply a few workarounds and bugfixes:
- The interface learning mechanism had two flaws: (1) it only learned
per-CPU, which meant the risk for a missing entry grew as the core count
of the relay host grew, and (2) it did not filter for unicast IPs, so it
picked up broadcast and link-local addresses, causing cross-relay paths
to fail occasionally
- The `relay-relay` candidate where the two relays are the same relay
causes packet drops / loops in the Docker bridge setup, and possibly in
GCP too. I'm not sure this is a valid path that solves a real
connectivity issue in the wild. I can understand relay-relay paths where
two relays are different hosts, and the client and gateway both talk
over their TURN channel to each other (i.e. WireGuard is blocked in each
of their networks), but I can't think of an advantage for a relay-relay
candidate where the traffic simply hairpins (or is dropped) off the
nearest switch. This has been now detected with a new `PacketLoop` error
that triggers whenever source_ip == dest_ip.
- The relays in CI need a common next-hop to talk to for the MAC address
swapping to work. A simple router service is added which functions as a
basic L3 router (no NAT) that allows the MAC swapping to work.
- The `veth` driver has some peculiar requirements to allow it to
function with XDP_TX. If you send a packet out of one interface of a
veth pair with XDP_TX, you need to either make sure both interfaces have
GRO enabled, or you need to attach a dummy XDP program that simply does
XDP_PASS to the other interface so that the sk_buff is allocated before
going up the stack to the Docker bridge. The GRO method was unreliable
and didn't work in our case, causing massive packet delays and
unpredictable bursts that prevented ICE from working, so we use the
XDP_PASS method instead. A simple docker image is built and lives at
https://github.com/firezone/xdp-pass to handle this.
Related: #10138
Related: #10260
Data has shown that we are doing a significant amount of relaying in
userspace because the latency of which candidates establish first
matters - if an IPv6 to IPv4 path establishes first, we could often pick
that, which would bypass the eBPF relaying altogether.
To address this, we now perform address translation when relaying so
these paths are covered. Preliminary benchmarking on Azure has shown
this performs around ~1.5 Gbps for a single client - gateway path,
scaling linearly with the number clients up to the core count.
On GCP, performance will be a fraction of that because we need to attach
the program in SKB_MODE (generic) based on the fact the `gve` driver
there does not support the needed `bpf_xdp_adjust_head` call.
To keep the verifier happy (and make the verifier error trace log
usable) throughout this large refactor, we unfortunately had to drop
down to pointer arithmetic in this process. This however means that we
have full control (and visibility) over how the bytes are loaded,
stored, and copied. Each struct / abstraction adds a little bit of
overhead on the stack which pushed us over the 512-byte limit. Since we
are generally loading only one set of packet headers onto the stack to
then copy into their new locations, our actual stack usage should be
well the 512-byte limit.
Further performance analysis is required to push past the current
per-core 1.5 Gbps limit. This, along with CI support for integration
testing these codepaths is left for a later date as this PR is already
quite large and needs to soak test for a bit in a live environment
before we push to prod.
Fixes#10192
Our Sentry client needs to resolve DNS before being able to send logs or
errors to the backend. Currently, this DNS resolution happens on-demand
as we don't take any control of the underlying HTTP client.
In addition, this will use HTTP/1.1 by default which isn't as efficient
as it could be, especially with concurrent requests.
Finally, if we decide to ever proxy all Sentry for traffic through our
own domain, we have to take control of the underlying client anyway.
To resolve all of the above, we create a custom `TransportFactory` where
we reuse the existing `ReqwestHttpTransport` but provide an already
configured `reqwest::Client` that always uses HTTP/2 with a
pre-configured set of DNS records for the given ingest host.
In order to support cross-stack relaying, we need to know what the
source IP is going to be to write the packets from. To know this, we can
simply learn the destination IP address for incoming packets to our XDP
program.
A separate cache is used per IP stack in order be a bit more cache line
friendly and prevent contention when only IP stack lookup is needed.
Related: #10192
This updates our eBPF module to use DRV_MODE for less CPU overhead and
better performance for all same-stack TURN relaying.
Notably, gVNIC does not seem to support the `bpf_xdp_adjust_head`
helper, so unfortunately we need to extend / shrink the packet tail and
move the payload instead.
Comprehensive benchmarks have not been performed, but early results show
that we can saturate about 1 Gbps per E2 core on GCP:
```
[SUM] 0.00-30.04 sec 3.16 GBytes 904 Mbits/sec 12088 sender
[SUM] 0.00-30.00 sec 3.12 GBytes 894 Mbits/sec receiver
```
This is with 64 TCP streams. More streams will better utilize all
available RX queues, and lead to better performance.
Related: #10138Fixes: #8633
In nearly all environments, we can safely assume that we will always use
the same network gateway for forwarding relayed packets as the one we
received them from.
By leveraging this assumption, we can simply swap the SRC and DST MAC
addresses, removing the need to keep a HaspMap for these, which
eliminates the need to worry about thread-safety for this particular
functionality.
Related: #10138
When inlining large(ish) functions that are on the hot-path, it creates
a much longer program for the eBPF verifier to validate since the
verifier is working through all packet sizes and types. We're hitting an
issue on GCP (in the 8-core dev VM, XDP-generic) where verification
fails on `main` due to the inlining of some hot-path functions.
This PR is the smallest possible change that gets the program to load,
highlighting the issue.
In practice, I'm not there is a detectable performance difference
between having these inlined vs not (especially in DRV_MODE) so I'm not
sure it's worth the potential debugging headaches later on.
The relay uses `mio` to react to readiness events from multiple sockets
at once. Including the control port 3478, the relay needs to also send
and receive traffic from up to 16384 sockets (one for each possible
allocation).
We need to process readiness events from these sockets as fairly as
possible. Under high-load, it may otherwise happen that we don't read
packets from an allocation socket, resulting in ICE timeouts of the
connection being relayed.
To achieve this fairness, we collect all readiness tokens into a set and
store it with the number of packets we have read so far from this
socket. Then, we always read from the socket next that we have so far
read the least amount of packets from.
On a Gateway with a busy connections, only being able to use a nonce 100
times causes unnecessary churn. We increase this to 10000 to be able to
handle bursts of messages such as channel bindings better.
These are flooding our monitoring infra and don't really add that much
value. Pretty much all of the processing the relay does is request in
and out and none of the spans are nested.
We can therefore almost 1-to-1 replicate the logging we do with spans by
adding the fields to each log message.
Resolves: #9954
Whilst looking through the auth module of the relay, I noticed that we
unnecessarily convert back and forth between expiry timestamps and
username formats when we could just be using the already parsed version.
Applying a filter globally to the entire subscriber means it filters
events for all layers. This prevents the Sentry layer from uploading
DEBUG logs if configured.
I am no longer able to compile `jemalloc` on my system in a debug build.
It fails with the following error:
```
src/malloc_io.c: In function ‘buferror’:
src/malloc_io.c:107:16: error: returning ‘char *’ from a function with return type ‘int’ makes integer from pointer without a cast [-Wint-conversion]
107 | return strerror_r(err, buf, buflen);
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~
```
This appears to be a problem with modern versions of clang/gcc. I
believe this started happening when I recently upgraded my system. The
upstream [`jemalloc`](https://github.com/jemalloc/jemalloc) repository
is now archived and thus unmaintained. I am not sure if we ever measured
a significant benefit in using `jemalloc`.
Related: https://github.com/servo/servo/issues/31059
Rust 1.88 has been released and brings with it a quite exciting feature:
let-chains! It allows us to mix-and-match `if` and `let` expressions,
therefore often reducing the "right-drift" of the relevant code, making
it easier to read.
Rust.188 also comes with a new clippy lint that warns when creating a
mutable reference from an immutable pointer. Attempting to fix this
revealed that this is exactly what we are doing in the eBPF kernel.
Unfortunately, it doesn't seem to be possible to design this in a way
that is both accepted by the borrow-checker AND by the eBPF verifier.
Hence, we simply make the function `unsafe` and document for the
programmer, what needs to be upheld.
Sentry has a new "Logs" feature where we can stream logs directly to
Sentry. Doing this for all Clients and Gateways would be way too much
data to collect though.
In order to aid debugging from customer installations, we add a
PostHog-managed feature flag that - if set to `true` - enables the
streaming of logs to Sentry. This feature flag is evaluated every time
the telemetry context is initialised:
- For all FFI usages of connlib, this happens every time a new session
is created.
- For the Windows/Linux Tunnel service, this also happens every time we
create a new session.
- For the Headless Client and Gateway, it happens on startup and
afterwards, every minute. The feature-flag context itself is only
checked every 5 minutes though so it might take up to 5 minutes before
this takes effect.
The default value - like all feature flags - is `false`. Therefore, if
there is any issue with the PostHog service, we will fallback to the
previous behaviour where logs are simply stored locally.
Resolves: #9600
When profiling the relay, certain syscalls may get interrupted by the
kernel. At present, this crashes the relay which makes profiling
impossible.
Co-authored-by: Antoine Labarussias <antoinelabarussias@gmail.com>
The latest release now also sorts workspace dependencies, as well as
different dependency sections. Keeping these things sorted reduces the
chances of merge conflicts when multiple PRs edit these files.
A mass upgrade of our Rust dependencies. Most crucially, these remove
several duplicated dependencies from our tree.
- The Tauri plugins have been stuck on `windows v0.60` for a while. They
are now updated to use `windows v0.61` which is what the rest of our
dependency tree uses.
- By bumping `axum`, can also bump `reqwest` which reduces a few more
duplicated dependencies.
- By removing `env_logger`, we can get rid of a few dependencies.
When working on the Rust code of Firezone from a MacOS computer, it is
useful to have pretty much all of the code at least compile to ensure
detect problems early. Eventually, once we target features like a
headless MacOS client, some of these stubs will actually be filled in an
be functional.
It turns out that the Rust compiler doesn't always say that it is adding
debug information to a binary even when it does! The build output only
displays `[optimized]` when in fact it does actually emit debug
information. Adding an additional linker flag configures `bpf-linker` to
include the necessary BTF information in our kernel.
This makes debugging verifier errors much easier as the program output
contains source code annotiations. It also should make it easier to
debug issues using `xdpdump` which relies on BTF information.
Resolves: #8503
`jemalloc` is a modern allocator that is designed for multi-threaded
systems and can better handle smaller allocations that may otherwise
fragment the heap. Firezone's components, especially Relays and Gateways
are intended to operate with a long uptime and therefore need to handle
memory efficiently.
Whilst developing the eBPF module for the relay, I needed to manually
add padding within the key and value structs used in the maps in order
for the kernel to be able to correctly retrieve the data.
For some reason, this seems no longer necessary as the integration test
now passes without this as well.
Being able to remove the padding drastically reduces the size of these
maps for the current number of entries that we allow. This brings the
overall memory usage of the relay down.
Resolves: #8682
Now that we have figured out what the problem was with the eBPF kernel
not routing certain packets, we can undo the manual implementation of
the allocation range checking again and use the more concise
`RangeInclusive::contains`.
Related: #8809
Related: #8807
The STUN message encoder & decoder from `stun_codec` are stateful
operations. However, they only operate on one datagram at the time. If
encoding or decoding fails, their internal state is corrupted and must
be discarded. At present, this doesn't happen which leads to further
failures down the line because new datagrams coming in cannot be
correctly decoded.
To fix this, we scope the stateful nature of these encoders and decoders
to their respective functions.
Resolves: #8808
In #8650, we originally added a feature-flag for toggling the eBPF TURN
router on and off at runtime. This later got removed again in #8681.
What remained was a "caching system" of the config that the eBPF kernel
and user space share with each other.
This config was initialised to the default configuration. If the
to-be-set config was the same as the current config, the config would
not actually apply to the array that was shared with the eBPF kernel.
At the time, we assumed that, if the config was not set in the kernel,
the lookup in the array would yield `None` and we would fall back to the
`Default` implementation of `Config`. This assumption was wrong. It
appears that look-ups in the array always yield an element: all zeros.
Initialising our config with all zeros yields the following:

Of course, if this range is not initialised correctly, we can never
actually route packets arriving on allocation ports and with UDP
checksumming turned off, all packets routed the other way will have an
invalid checksum and therefore be dropped by the receiving host.
Our integration test did not catch this because in there, we purposely
disable UDP checksumming. That meant that the "caching" check in the
`ebpf::Program` did not trigger and we actually did set a `Config` in
the array, therefore initialising the allocation port range correctly
and allowing the packet to be routed.
To fix this, we remove this caching check again which means every
`Config` we set on the eBPF program actually gets copied to the shared
array. Originally, this caching check was introduced to avoid a syscall
on every event-loop iteration as part of checking the feature-flag. Now
that the feature-flag has been removed, we don't need to have this cache
anymore.
I am suspecting that something is wrong with the check that a port is
indeed within that range. Thus, we now implemented this ourselves with
two simple conditions.
Neither of the moved error cases should happen very often so it is fine
to log them on debug.
- `Error::NotTurn` only happens if we receive a UDP packet that isn't
STUN traffic (port 3478) or not in the allocation-port range. I am
suspecting there to be a bug that I am aiming to fix in #8804.
- `Error::NotAChannelDataMessage` will happen for all STUN control
traffic, like channel bindings, allocation requests, etc. Those only
happen occasionally so won't spam too much.
- `Ipv4PacketWithOptions` should basically not happen at all because -
as far as I know - IPv4 options aren't used a lot.
In any case, when debugging, it is useful to see when we do hit these
cases to know, why a packet was offloaded to user space.
Any communication between user-space and the eBPF kernel happens via
maps. The keys and values in these maps are serialised to bytes, meaning
the endianness of how these values are encoded matters!
When debugging why the eBPF kernels were not performing as much as we
thought they would, I noticed that only very small packets were getting
relayed. In particular, only packets encoded as channel-data packets
were getting unwrapped correctly. The reverse didn't happen at all.
Turning the log-level up to TRACE did reveal that we do in fact see
these packets but they don't get handled.
Here is the relevant section that handles these packets:
74ccf8e0b2/rust/relay/ebpf-turn-router/src/main.rs (L127-L151)
We can see the `trace!` log in the logs and we know that it should be
handled by the first `if`. But for some reason it doesn't.
x86 systems like the machines running in GCP are typically
little-endian. Network-byte ordering is big-endian. My current theory is
that we are comparing the port range with the wrong endianness and
therefore, this branch never gets hit, causing the relaying to be
offloaded to user space.
By storing the fields within `Config` in byte-arrays, we can take
explicit control over which endianness is used to store these fields.
When debugging issues with the relays on GCP, it is useful to be able to
change the log-level at runtime without having to redeploy them. We can
achieve this by running an additional HTTP server as part of the relay
that response to HTTP POST requests that contain new logging directives.
---------
Signed-off-by: Thomas Eizinger <thomas@eizinger.io>
The original idea of this feature flag was that we can easily disable
the eBPF router in case it causes issues in production. However,
something seems to be not working in reliably turning this on / off.
Without an explicit toggle of the feature-flag, the eBPF program doesn't
seem to be loaded correctly. The uncertainty in this makes me not the
trust the metrics that we are seeing because we don't know, whether
really all relays are using the eBPF router to relay TURN traffic.
In order to draw truthful conclusions as too how much traffic we are
relaying via eBPF, this patch removes the feature flag again. As of
#8656, we can disable the eBPF program by not setting the
`EBPF_OFFLOADING` env variable. This requires a re-deploy / restart of
relays to take effect which isn't quite as fast as toggling a feature
flag but much reliable and easier to maintain.
Our feature-flags are currently coupled to our Firezone ID. Without a
Firezone ID, we cannot evaluate feature flags. In order to be able to
use the feature flags to enable / disable the eBPF TURN router, we see a
random UUID as the Firezone ID upon startup of the relay.
Not setting this causes the eBPF router to currently be instantly
disabled as soon as we start up because the default of the feature flag
is false and we don't reevaluate it later due to the missing ID.
It happens a bunch of times to me during testing that I'd forget to set
the right interface onto which the eBPF kernel should be loaded and was
wondering why it didn't work. Defaulting to `eth0` wasn't a very smart
decision because it means users cannot disable the eBPF kernel at all
(other than via the feature-flag).
It makes more sense to default to not loading the program at all AND
hard-fail if we are requested to load it but cannot. This allows us to
catch configuration errors early.
This PR implements a feature-flag in PostHog that we can use to toggle
the use of the eBPF data plane at runtime. At every tick of the
event-loop, the relay will compare the (cached) configuration of the
eBPF program with the (cached) value of the feature-flag. If they
differ, the flag will be updated and upon the next packet, the eBPF
program will act accordingly.
Feature-flags are re-evaluated every 5 minutes, meaning there is some
delay until this gets applied.
The default value of our all our feature-flags is `false`, meaning if
there is some problem with evaluating them, we'd turn the eBPF data
plane off. Performing routing in userspace is slower but it is a safer
default.
Resolves: #8548
The name `slice_mut_at` came from a time where this function actually
returned a slice of bytes. It has since been refactored to return a
mutable reference to a type T that gets set by the caller. Thus,
`ref_mut_at` is a much more fitting name.
The default here is 2 which is nowhere near enough of a batch-size for
us to read all perf events generated by the kernel when it is actually
relaying data via eBPF (we generate 1 perf event per relayed packet). If
we don't read them fast enough, the kernel has to drop some, meaning we
skew our metrics as to how much data we've relayed via eBPF.
This has been tested in my local setup and I've seen north of 500 events
being read in a single batch now.
---------
Signed-off-by: Thomas Eizinger <thomas@eizinger.io>
Instead of passing just a 4-byte array, we can pass a `CdHdr` struct
that we have already defined. This is more type-safe and correctly
captures the invariant of the order of fields in the header.
When we decide to drop a packet, it means something is seriously off and
we should look into it. These warnings will propagate to userspace and
trigger a warning that gets reported to Sentry (if telemetry is
enabled).