fix(connlib): reconnect in case we lose all relays (#7164)

During normal operation, we should never lose connectivity to the set of
assigned relays in a client or gateway. In the presence of odd network
conditions and partitions however, it is possible that we disconnect
from a relay that is in fact only temporarily unavailable. Without an
explicit mechanism to retrieve new relays, this means that both clients
and gateways can end up with no relays at all. For clients, this can be
fixed by either roaming or signing out and in again. For gateways, this
can only be fixed by a restart!

Without connected relays, no connections can be established. With #7163,
we will at least be able to still establish direct connections. Yet,
that isn't good enough and we need a mechanism for restoring full
connectivity in such a case.

We creating a new connection, we already sample one of our relays and
assign it to this particular connection. This ensures that we don't
create an excessive amount of candidates for each individual connection.
Currently, this selection is allowed to be silently fallible. With this
PR, we make this a hard-error and bubble up the error that all the way
to the client's and gateway's event-loop. There, we initiate a reconnect
to the portal as a compensating action. Reconnecting to the portal means
we will receive another `init` message that allows us to reconnect the
relays.

Due to the nature of this implementation, this fix may only apply with a
certain delay from when we actually lost connectivity to the last relay.
However, this design has the advantage that we don't have to introduce
an additional state within `snownet`: Connections now simply fail to
establish and the next one soon after _should_ succeed again because we
will have received a new `init` message.

Resolves: #7162.
This commit is contained in:
Thomas Eizinger
2024-10-29 12:01:47 +11:00
committed by GitHub
parent f296dc5ad2
commit f7a388345b
16 changed files with 144 additions and 242 deletions

1
rust/Cargo.lock generated
View File

@@ -1116,6 +1116,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
"snownet",
"socket-factory",
"thiserror",
"time",

View File

@@ -14,6 +14,7 @@ ip_network = { version = "0.4", default-features = false }
phoenix-channel = { workspace = true }
secrecy = { workspace = true }
serde = { version = "1.0", default-features = false, features = ["std", "derive"] }
snownet = { workspace = true }
socket-factory = { workspace = true }
thiserror = "1.0.63"
time = { version = "0.3.36", features = ["formatting"] }

View File

@@ -331,7 +331,16 @@ where
site_id,
Instant::now(),
) {
Ok(()) => {}
Ok(Ok(())) => {}
Ok(Err(snownet::NoTurnServers {})) => {
tracing::debug!(
"Failed to request new connection: No TURN servers available"
);
// Re-connecting to the portal means we will receive another `init` and thus new TURN servers.
self.portal
.connect(PublicKeyParam(self.tunnel.public_key().to_bytes()));
}
Err(e) => {
tracing::warn!(
error = anyhow_dyn_err(&e),

View File

@@ -14,7 +14,7 @@ pub use allocation::RelaySocket;
#[allow(deprecated)] // Rust bug: `expect` doesn't seem to work on imports?
pub use node::{Answer, Offer};
pub use node::{
Client, ClientNode, Credentials, EncryptBuffer, EncryptedPacket, Error, Event, Node, Server,
ServerNode, Transmit, HANDSHAKE_TIMEOUT,
Client, ClientNode, Credentials, EncryptBuffer, EncryptedPacket, Error, Event, NoTurnServers,
Node, Server, ServerNode, Transmit, HANDSHAKE_TIMEOUT,
};
pub use stats::{ConnectionStats, NodeStats};

View File

@@ -149,6 +149,10 @@ pub enum Error {
BadLocalAddress(#[from] str0m::error::IceError),
}
#[derive(thiserror::Error, Debug)]
#[error("No TURN servers available")]
pub struct NoTurnServers {}
#[expect(private_bounds, reason = "We don't want `Mode` to be public API")]
impl<T, TId, RId> Node<T, TId, RId>
where
@@ -235,13 +239,13 @@ where
local_creds: Credentials,
remote_creds: Credentials,
now: Instant,
) {
) -> Result<(), NoTurnServers> {
let local_creds = local_creds.into();
let remote_creds = remote_creds.into();
if self.connections.initial.contains_key(&cid) {
debug_assert!(false, "The new `upsert_connection` API is incompatible with the previous `new_connection` API");
return;
return Ok(());
}
if self
@@ -250,10 +254,10 @@ where
.is_some_and(|c| c.agent.local_credentials() == &local_creds)
{
tracing::debug!("Already got a connection");
return;
return Ok(());
}
let selected_relay = self.sample_relay();
let selected_relay = self.sample_relay()?;
let mut agent = new_agent();
agent.set_controlling(self.mode.is_client());
@@ -279,6 +283,8 @@ where
} else {
tracing::info!("Created new connection");
}
Ok(())
}
pub fn public_key(&self) -> PublicKey {
@@ -353,13 +359,8 @@ where
| CandidateKind::PeerReflexive => {}
}
let Some(rid) = relay else {
tracing::debug!("No relay selected for connection");
return;
};
let Some(allocation) = self.allocations.get_mut(&rid) else {
tracing::debug!(%rid, "Unknown relay");
let Some(allocation) = self.allocations.get_mut(&relay) else {
tracing::debug!(rid = %relay, "Unknown relay");
return;
};
@@ -700,7 +701,7 @@ where
mut agent: IceAgent,
remote: PublicKey,
key: [u8; 32],
relay: Option<RId>,
relay: RId,
intent_sent_at: Instant,
now: Instant,
) -> Connection<RId> {
@@ -925,12 +926,17 @@ where
}
/// Sample a relay to use for a new connection.
fn sample_relay(&mut self) -> Option<RId> {
let rid = self.allocations.keys().copied().choose(&mut self.rng)?;
fn sample_relay(&mut self) -> Result<RId, NoTurnServers> {
let rid = self
.allocations
.keys()
.copied()
.choose(&mut self.rng)
.ok_or(NoTurnServers {})?;
tracing::debug!(%rid, "Sampled relay");
Some(rid)
Ok(rid)
}
}
@@ -947,7 +953,12 @@ where
#[must_use]
#[deprecated]
#[expect(deprecated)]
pub fn new_connection(&mut self, cid: TId, intent_sent_at: Instant, now: Instant) -> Offer {
pub fn new_connection(
&mut self,
cid: TId,
intent_sent_at: Instant,
now: Instant,
) -> Result<Offer, NoTurnServers> {
if self.connections.initial.remove(&cid).is_some() {
tracing::info!("Replacing existing initial connection");
};
@@ -975,7 +986,7 @@ where
session_key,
created_at: now,
intent_sent_at,
relay: self.sample_relay(),
relay: self.sample_relay()?,
is_failed: false,
span: info_span!("connection", %cid),
};
@@ -986,7 +997,7 @@ where
tracing::info!(?duration_since_intent, "Establishing new connection");
params
Ok(params)
}
/// Whether we have sent an [`Offer`] for this connection and are currently expecting an [`Answer`].
@@ -1052,7 +1063,7 @@ where
offer: Offer,
remote: PublicKey,
now: Instant,
) -> Answer {
) -> Result<Answer, NoTurnServers> {
debug_assert!(
!self.connections.initial.contains_key(&cid),
"server to not use `initial_connections`"
@@ -1076,7 +1087,7 @@ where
},
};
let selected_relay = self.sample_relay();
let selected_relay = self.sample_relay()?;
self.seed_agent_with_local_candidates(cid, selected_relay, &mut agent);
let connection = self.init_connection(
@@ -1094,7 +1105,7 @@ where
tracing::info!("Created new connection");
answer
Ok(answer)
}
}
@@ -1106,18 +1117,13 @@ where
fn seed_agent_with_local_candidates(
&mut self,
connection: TId,
selected_relay: Option<RId>,
selected_relay: RId,
agent: &mut IceAgent,
) {
for candidate in self.shared_candidates.iter().cloned() {
add_local_candidate(connection, agent, candidate, &mut self.pending_events);
}
let Some(selected_relay) = selected_relay else {
tracing::debug!("Skipping seeding of relay candidates: No relay selected");
return;
};
let Some(allocation) = self.allocations.get(&selected_relay) else {
tracing::debug!(%selected_relay, "Cannot seed relay candidates: Unknown relay");
return;
@@ -1175,7 +1181,7 @@ where
) {
// For initial connections, we can just update the relay to be used.
for (_, c) in self.iter_initial_mut() {
if c.relay.is_some_and(|r| allocations.contains_key(&r)) {
if allocations.contains_key(&c.relay) {
continue;
}
@@ -1186,7 +1192,7 @@ where
};
tracing::info!(old_rid = ?c.relay, %new_rid, "Updating relay");
c.relay = Some(new_rid);
c.relay = new_rid;
}
// For established connections, we check if we are currently using the relay.
@@ -1197,19 +1203,12 @@ where
let peer_socket = match &mut c.state {
Connected { peer_socket, .. } | Idle { peer_socket } => peer_socket,
Failed => continue,
Connecting {
relay: maybe_relay, ..
} => {
let Some(relay) = maybe_relay else {
continue;
};
Connecting { relay, .. } => {
if allocations.contains_key(relay) {
continue;
}
tracing::debug!("Selected relay disconnected during ICE; connection may fail");
*maybe_relay = None;
continue;
}
};
@@ -1239,7 +1238,7 @@ where
maybe_initial_connection.or(maybe_established_connection)
}
fn connecting_agent_mut(&mut self, id: TId) -> Option<(&mut IceAgent, Option<RId>)> {
fn connecting_agent_mut(&mut self, id: TId) -> Option<(&mut IceAgent, RId)> {
let maybe_initial_connection = self.initial.get_mut(&id).map(|i| (&mut i.agent, i.relay));
let maybe_pending_connection = self.established.get_mut(&id).and_then(|c| match c.state {
ConnectionState::Connecting { relay, .. } => Some((&mut c.agent, relay)),
@@ -1256,15 +1255,15 @@ where
id: RId,
) -> impl Iterator<Item = (TId, &mut IceAgent, tracing::span::Entered<'_>)> + '_ {
let initial_connections = self.initial.iter_mut().filter_map(move |(cid, i)| {
(i.relay? == id).then_some((*cid, &mut i.agent, i.span.enter()))
(i.relay == id).then_some((*cid, &mut i.agent, i.span.enter()))
});
let pending_connections = self.established.iter_mut().filter_map(move |(cid, c)| {
use ConnectionState::*;
match c.state {
Connecting {
relay: Some(relay), ..
} if relay == id => Some((*cid, &mut c.agent, c.span.enter())),
Connecting { relay, .. } if relay == id => {
Some((*cid, &mut c.agent, c.span.enter()))
}
Failed | Idle { .. } | Connecting { .. } | Connected { .. } => None,
}
});
@@ -1560,9 +1559,7 @@ struct InitialConnection<RId> {
session_key: Secret<[u8; 32]>,
/// The fallback relay we sampled for this potential connection.
///
/// `None` if we don't have any relays available.
relay: Option<RId>,
relay: RId,
created_at: Instant,
intent_sent_at: Instant,
@@ -1630,9 +1627,7 @@ enum ConnectionState<RId> {
/// We are still running ICE to figure out, which socket to use to send data.
Connecting {
/// The relay we have selected for this connection.
///
/// `None` if we didn't have any relays available.
relay: Option<RId>,
relay: RId,
/// Packets emitted by wireguard whilst are still running ICE.
///

View File

@@ -1,157 +0,0 @@
use secrecy::Secret;
use snownet::{ClientNode, Credentials, Event, ServerNode};
use std::{
iter,
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Duration, Instant},
};
use str0m::{net::Protocol, Candidate};
#[test]
#[expect(deprecated, reason = "Will be deleted together with deprecated API")]
fn connection_times_out_after_20_seconds() {
let (mut alice, _) = alice_and_bob();
let created_at = Instant::now();
let _ = alice.new_connection(1, Instant::now(), created_at);
alice.handle_timeout(created_at + Duration::from_secs(20));
assert_eq!(alice.poll_event().unwrap(), Event::ConnectionFailed(1));
}
#[test]
fn connection_without_candidates_times_out_after_10_seconds() {
let _guard = firezone_logging::test("trace");
let start = Instant::now();
let (mut alice, mut bob) = alice_and_bob();
handshake(&mut alice, &mut bob, start);
alice.handle_timeout(start + Duration::from_secs(10));
assert_eq!(alice.poll_event().unwrap(), Event::ConnectionFailed(1));
}
#[test]
fn connection_with_candidates_does_not_time_out_after_10_seconds() {
let _guard = firezone_logging::test("trace");
let start = Instant::now();
let (mut alice, mut bob) = alice_and_bob();
handshake(&mut alice, &mut bob, start);
alice.add_local_host_candidate(s("10.0.0.2:4444")).unwrap();
alice.add_remote_candidate(1, host("10.0.0.1:4444"), start);
alice.handle_timeout(start + Duration::from_secs(10));
let any_failed =
iter::from_fn(|| alice.poll_event()).any(|e| matches!(e, Event::ConnectionFailed(_)));
assert!(!any_failed);
}
#[test]
#[expect(deprecated, reason = "Will be deleted together with deprecated API")]
fn answer_after_stale_connection_does_not_panic() {
let start = Instant::now();
let (mut alice, mut bob) = alice_and_bob();
let answer = send_offer(&mut alice, &mut bob, start);
let now = start + Duration::from_secs(10);
alice.handle_timeout(now);
alice.accept_answer(1, bob.public_key(), answer, now + Duration::from_secs(1));
}
#[test]
#[expect(deprecated, reason = "Will be deleted together with deprecated API")]
fn only_generate_candidate_event_after_answer() {
let local_candidate = SocketAddr::new(IpAddr::from(Ipv4Addr::LOCALHOST), 10000);
let mut alice = ClientNode::<u64, u64>::new(rand::random());
alice.add_local_host_candidate(local_candidate).unwrap();
let mut bob = ServerNode::<u64, u64>::new(rand::random());
let offer = alice.new_connection(1, Instant::now(), Instant::now());
assert_eq!(
alice.poll_event(),
None,
"no event to be emitted before accepting the answer"
);
let answer = bob.accept_connection(1, offer, alice.public_key(), Instant::now());
alice.accept_answer(1, bob.public_key(), answer, Instant::now());
assert!(iter::from_fn(|| alice.poll_event()).any(|ev| ev
== Event::NewIceCandidate {
connection: 1,
candidate: Candidate::host(local_candidate, Protocol::Udp)
.unwrap()
.to_sdp_string()
}));
}
fn alice_and_bob() -> (ClientNode<u64, u64>, ServerNode<u64, u64>) {
let alice = ClientNode::new(rand::random());
let bob = ServerNode::new(rand::random());
(alice, bob)
}
#[expect(deprecated, reason = "Will be deleted together with deprecated API")]
fn send_offer(
alice: &mut ClientNode<u64, u64>,
bob: &mut ServerNode<u64, u64>,
now: Instant,
) -> snownet::Answer {
let offer = alice.new_connection(1, Instant::now(), now);
bob.accept_connection(1, offer, alice.public_key(), now)
}
fn handshake(alice: &mut ClientNode<u64, u64>, bob: &mut ServerNode<u64, u64>, now: Instant) {
alice.upsert_connection(
1,
bob.public_key(),
Secret::new([0u8; 32]),
Credentials {
username: "foo".to_owned(),
password: "foo".to_owned(),
},
Credentials {
username: "bar".to_owned(),
password: "bar".to_owned(),
},
now,
);
bob.upsert_connection(
1,
alice.public_key(),
Secret::new([0u8; 32]),
Credentials {
username: "bar".to_owned(),
password: "bar".to_owned(),
},
Credentials {
username: "foo".to_owned(),
password: "foo".to_owned(),
},
now,
);
}
fn host(socket: &str) -> String {
Candidate::host(s(socket), Protocol::Udp)
.unwrap()
.to_sdp_string()
}
fn s(socket: &str) -> SocketAddr {
socket.parse().unwrap()
}

View File

@@ -26,7 +26,7 @@ use crate::ClientEvent;
use domain::base::Message;
use lru::LruCache;
use secrecy::{ExposeSecret as _, Secret};
use snownet::{ClientNode, EncryptBuffer, RelaySocket, Transmit};
use snownet::{ClientNode, EncryptBuffer, NoTurnServers, RelaySocket, Transmit};
use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
@@ -535,7 +535,7 @@ impl ClientState {
gateway_id: GatewayId,
site_id: SiteId,
now: Instant,
) -> anyhow::Result<()> {
) -> anyhow::Result<Result<(), NoTurnServers>> {
tracing::trace!("Updating resource routing table");
let desc = self
@@ -544,7 +544,7 @@ impl ClientState {
.context("Unknown resource")?;
if self.node.is_expecting_answer(gateway_id) {
return Ok(());
return Ok(Ok(()));
}
let awaiting_connection_details = self
@@ -571,7 +571,16 @@ impl ClientState {
gateway_id,
maybe_domain: awaiting_connection_details.domain,
});
return Ok(());
return Ok(Ok(()));
};
let offer = match self.node.new_connection(
gateway_id,
awaiting_connection_details.last_intent_sent_at,
now,
) {
Ok(o) => o,
Err(e) => return Ok(Err(e)),
};
self.peers.insert(
@@ -581,12 +590,6 @@ impl ClientState {
self.peers
.add_ips_with_resource(&gateway_id, ips.into_iter(), &resource_id);
let offer = self.node.new_connection(
gateway_id,
awaiting_connection_details.last_intent_sent_at,
now,
);
self.buffered_events
.push_back(ClientEvent::RequestConnection {
gateway_id,
@@ -599,7 +602,7 @@ impl ClientState {
maybe_domain: awaiting_connection_details.domain,
});
Ok(())
Ok(Ok(()))
}
fn is_upstream_set_by_the_portal(&self) -> bool {

View File

@@ -12,7 +12,7 @@ use firezone_logging::{anyhow_dyn_err, std_dyn_err};
use ip_network::{Ipv4Network, Ipv6Network};
use ip_packet::{FzP2pControlSlice, IpPacket};
use secrecy::{ExposeSecret as _, Secret};
use snownet::{Credentials, EncryptBuffer, RelaySocket, ServerNode, Transmit};
use snownet::{Credentials, EncryptBuffer, NoTurnServers, RelaySocket, ServerNode, Transmit};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::{Duration, Instant};
@@ -206,13 +206,13 @@ impl GatewayState {
offer: snownet::Offer,
client: PublicKey,
now: Instant,
) -> Answer {
let answer = self.node.accept_connection(client_id, offer, client, now);
) -> Result<Answer, NoTurnServers> {
let answer = self.node.accept_connection(client_id, offer, client, now)?;
Answer {
Ok(Answer {
username: answer.credentials.username,
password: answer.credentials.password,
}
})
}
#[tracing::instrument(level = "debug", skip_all, fields(%client_id))]
@@ -229,7 +229,7 @@ impl GatewayState {
expires_at: Option<DateTime<Utc>>,
resource: ResourceDescription,
now: Instant,
) {
) -> Result<(), NoTurnServers> {
self.node.upsert_connection(
client_id,
client_key,
@@ -243,10 +243,12 @@ impl GatewayState {
password: client_ice.password,
},
now,
);
)?;
self.allow_access(client_id, ipv4, ipv6, expires_at, resource, None, now)
.expect("Should never fail without a `DnsResourceNatEntry`");
Ok(())
}
pub fn refresh_translation(

View File

@@ -710,6 +710,7 @@ impl TunnelTest {
self.client
.exec_mut(|c| c.sut.on_routing_details(resource, gateway, site, now))
.unwrap()
.unwrap();
}
@@ -794,18 +795,21 @@ impl TunnelTest {
let client_id = self.client.inner().id;
let answer = gateway.exec_mut(|g| {
let answer = g.sut.accept(
client_id,
snownet::Offer {
session_key: preshared_key.expose_secret().0.into(),
credentials: snownet::Credentials {
username: offer.username,
password: offer.password,
let answer = g
.sut
.accept(
client_id,
snownet::Offer {
session_key: preshared_key.expose_secret().0.into(),
credentials: snownet::Credentials {
username: offer.username,
password: offer.password,
},
},
},
self.client.inner().sut.public_key(),
now,
);
self.client.inner().sut.public_key(),
now,
)
.unwrap();
g.sut
.allow_access(
self.client.inner().id,

View File

@@ -197,7 +197,7 @@ impl Eventloop {
msg: IngressMessages::AuthorizeFlow(msg),
..
} => {
self.tunnel.state_mut().authorize_flow(
if let Err(snownet::NoTurnServers {}) = self.tunnel.state_mut().authorize_flow(
msg.client.id,
PublicKey::from(msg.client.public_key.0),
msg.client.preshared_key,
@@ -208,7 +208,14 @@ impl Eventloop {
msg.expires_at,
msg.resource,
Instant::now(),
);
) {
tracing::debug!("Failed to authorise flow: No TURN servers available");
// Re-connecting to the portal means we will receive another `init` and thus new TURN servers.
self.portal
.connect(PublicKeyParam(self.tunnel.public_key().to_bytes()));
return;
};
self.portal.send(
PHOENIX_TOPIC,
@@ -347,7 +354,7 @@ impl Eventloop {
.inspect_err(|e| tracing::debug!(error = std_dyn_err(e), client = %req.client.id, reference = %req.reference, "DNS resolution timed out as part of connection request"))
.unwrap_or_default();
let answer = self.tunnel.state_mut().accept(
let answer = match self.tunnel.state_mut().accept(
req.client.id,
req.client
.payload
@@ -355,7 +362,18 @@ impl Eventloop {
.into_snownet_offer(req.client.peer.preshared_key),
PublicKey::from(req.client.peer.public_key.0),
Instant::now(),
);
) {
Ok(a) => a,
Err(snownet::NoTurnServers {}) => {
tracing::debug!("Failed to accept new connection: No TURN servers available");
// Re-connecting to the portal means we will receive another `init` and thus new TURN servers.
self.portal
.connect(PublicKeyParam(self.tunnel.public_key().to_bytes()));
return;
}
};
if let Err(e) = self.tunnel.state_mut().allow_access(
req.client.id,

View File

@@ -324,11 +324,17 @@ where
/// Establishes a new connection, dropping the current one if any exists.
pub fn connect(&mut self, params: TFinish) {
let url = self.url_prototype.expose_secret().to_url(params);
if matches!(self.state, State::Connecting(_)) && Some(&url) == self.last_url.as_ref() {
tracing::debug!("We are already connecting");
return;
}
// 1. Reset the backoff.
self.reconnect_backoff.reset();
// 2. Set state to `Connecting` without a timer.
let url = self.url_prototype.expose_secret().to_url(params);
let user_agent = self.user_agent.clone();
self.state = State::connect(url.clone(), user_agent, self.socket_factory.clone());
self.last_url = Some(url);

View File

@@ -20,6 +20,10 @@ export default function Android() {
Fixes an issue where notifications would sometimes not get delivered
when Firezone was active.
</ChangeItem>
<ChangeItem pull="7164">
Fixes an issue where Firezone would fail to establish connections to
Gateways and the user had to sign-out and in again.
</ChangeItem>
</Unreleased>
<Entry version="1.3.5" date={new Date("2024-10-03")}>
<ChangeItem pull="6831">

View File

@@ -16,6 +16,10 @@ export default function Apple() {
<ChangeItem pull="7152">
Adds always-on error reporting using sentry.io.
</ChangeItem>
<ChangeItem pull="7164">
Fixes an issue where Firezone would fail to establish connections to
Gateways and the user had to sign-out and in again.
</ChangeItem>
</Unreleased>
<Entry version="1.3.6" date={new Date("2024-10-02")}>
<ChangeItem pull="6831">

View File

@@ -25,6 +25,10 @@ export default function GUI({ title }: { title: string }) {
<ChangeItem pull="6996">
Supports Ubuntu 24.04, no longer supports Ubuntu 20.04.
</ChangeItem>
<ChangeItem pull="7164">
Fixes an issue where Firezone would fail to establish connections to
Gateways and the user had to sign-out and in again.
</ChangeItem>
</Unreleased>
<Entry version="1.3.9" date={new Date("2024-10-09")}>
<ChangeItem enable={title === "Linux GUI"} pull="6987">

View File

@@ -23,6 +23,10 @@ export default function Gateway() {
Adds on-by-default error reporting using sentry.io.
Disable by setting `FIREZONE_NO_TELEMETRY=1`.
</ChangeItem>
<ChangeItem pull="7164">
Fixes an issue where the Gateway would fail to accept connections and
had to be restarted.
</ChangeItem>
</Unreleased>
<Entry version="1.3.2" date={new Date("2024-10-02")}>
<ChangeItem pull="6733">

View File

@@ -13,6 +13,10 @@ export default function Headless() {
{/* When you cut a release, remove any solved issues from the "known issues" lists over in `client-apps`. This must not be done when the issue's PR merges. */}
<Unreleased>
<ChangeItem>Handles DNS queries over TCP correctly.</ChangeItem>
<ChangeItem pull="7164">
Fixes an issue where Firezone would fail to establish connections to
Gateways and the client had to be restarted.
</ChangeItem>
</Unreleased>
<Entry version="1.3.4" date={new Date("2024-10-02")}>
<ChangeItem pull="6831">