diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 66325fe78..4a82f48b2 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1116,6 +1116,7 @@ dependencies = [ "secrecy", "serde", "serde_json", + "snownet", "socket-factory", "thiserror", "time", diff --git a/rust/connlib/clients/shared/Cargo.toml b/rust/connlib/clients/shared/Cargo.toml index f4b7c8bbe..29d58c475 100644 --- a/rust/connlib/clients/shared/Cargo.toml +++ b/rust/connlib/clients/shared/Cargo.toml @@ -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"] } diff --git a/rust/connlib/clients/shared/src/eventloop.rs b/rust/connlib/clients/shared/src/eventloop.rs index c550c5a92..5560fcb7c 100644 --- a/rust/connlib/clients/shared/src/eventloop.rs +++ b/rust/connlib/clients/shared/src/eventloop.rs @@ -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), diff --git a/rust/connlib/snownet/src/lib.rs b/rust/connlib/snownet/src/lib.rs index aeeb1abbd..d92e78700 100644 --- a/rust/connlib/snownet/src/lib.rs +++ b/rust/connlib/snownet/src/lib.rs @@ -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}; diff --git a/rust/connlib/snownet/src/node.rs b/rust/connlib/snownet/src/node.rs index 35fe00421..f8931caec 100644 --- a/rust/connlib/snownet/src/node.rs +++ b/rust/connlib/snownet/src/node.rs @@ -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 Node 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, + relay: RId, intent_sent_at: Instant, now: Instant, ) -> Connection { @@ -925,12 +926,17 @@ where } /// Sample a relay to use for a new connection. - fn sample_relay(&mut self) -> Option { - let rid = self.allocations.keys().copied().choose(&mut self.rng)?; + fn sample_relay(&mut self) -> Result { + 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 { 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 { 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, + 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)> { + 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)> + '_ { 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 { 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, + relay: RId, created_at: Instant, intent_sent_at: Instant, @@ -1630,9 +1627,7 @@ enum ConnectionState { /// 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, + relay: RId, /// Packets emitted by wireguard whilst are still running ICE. /// diff --git a/rust/connlib/snownet/tests/lib.rs b/rust/connlib/snownet/tests/lib.rs deleted file mode 100644 index 2e0b0f53e..000000000 --- a/rust/connlib/snownet/tests/lib.rs +++ /dev/null @@ -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::::new(rand::random()); - alice.add_local_host_candidate(local_candidate).unwrap(); - - let mut bob = ServerNode::::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, ServerNode) { - 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, - bob: &mut ServerNode, - 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, bob: &mut ServerNode, 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() -} diff --git a/rust/connlib/tunnel/src/client.rs b/rust/connlib/tunnel/src/client.rs index 09e800fde..cd7241be8 100644 --- a/rust/connlib/tunnel/src/client.rs +++ b/rust/connlib/tunnel/src/client.rs @@ -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> { 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 { diff --git a/rust/connlib/tunnel/src/gateway.rs b/rust/connlib/tunnel/src/gateway.rs index 8b0cbf902..4c59ddfe9 100644 --- a/rust/connlib/tunnel/src/gateway.rs +++ b/rust/connlib/tunnel/src/gateway.rs @@ -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 { + 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>, 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( diff --git a/rust/connlib/tunnel/src/tests/sut.rs b/rust/connlib/tunnel/src/tests/sut.rs index 006c7a4df..ec376f04c 100644 --- a/rust/connlib/tunnel/src/tests/sut.rs +++ b/rust/connlib/tunnel/src/tests/sut.rs @@ -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, diff --git a/rust/gateway/src/eventloop.rs b/rust/gateway/src/eventloop.rs index 7a60fb00d..08c4e1cef 100644 --- a/rust/gateway/src/eventloop.rs +++ b/rust/gateway/src/eventloop.rs @@ -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, diff --git a/rust/phoenix-channel/src/lib.rs b/rust/phoenix-channel/src/lib.rs index fc2167311..dd9c71ccc 100644 --- a/rust/phoenix-channel/src/lib.rs +++ b/rust/phoenix-channel/src/lib.rs @@ -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); diff --git a/website/src/components/Changelog/Android.tsx b/website/src/components/Changelog/Android.tsx index a6a9e49f9..a7cb47f8e 100644 --- a/website/src/components/Changelog/Android.tsx +++ b/website/src/components/Changelog/Android.tsx @@ -20,6 +20,10 @@ export default function Android() { Fixes an issue where notifications would sometimes not get delivered when Firezone was active. + + Fixes an issue where Firezone would fail to establish connections to + Gateways and the user had to sign-out and in again. + diff --git a/website/src/components/Changelog/Apple.tsx b/website/src/components/Changelog/Apple.tsx index 5186fe444..2ba9f4814 100644 --- a/website/src/components/Changelog/Apple.tsx +++ b/website/src/components/Changelog/Apple.tsx @@ -16,6 +16,10 @@ export default function Apple() { Adds always-on error reporting using sentry.io. + + Fixes an issue where Firezone would fail to establish connections to + Gateways and the user had to sign-out and in again. + diff --git a/website/src/components/Changelog/GUI.tsx b/website/src/components/Changelog/GUI.tsx index ad418a8e5..abe9bc60a 100644 --- a/website/src/components/Changelog/GUI.tsx +++ b/website/src/components/Changelog/GUI.tsx @@ -25,6 +25,10 @@ export default function GUI({ title }: { title: string }) { Supports Ubuntu 24.04, no longer supports Ubuntu 20.04. + + Fixes an issue where Firezone would fail to establish connections to + Gateways and the user had to sign-out and in again. + diff --git a/website/src/components/Changelog/Gateway.tsx b/website/src/components/Changelog/Gateway.tsx index e8e8378cc..712eaea55 100644 --- a/website/src/components/Changelog/Gateway.tsx +++ b/website/src/components/Changelog/Gateway.tsx @@ -23,6 +23,10 @@ export default function Gateway() { Adds on-by-default error reporting using sentry.io. Disable by setting `FIREZONE_NO_TELEMETRY=1`. + + Fixes an issue where the Gateway would fail to accept connections and + had to be restarted. + diff --git a/website/src/components/Changelog/Headless.tsx b/website/src/components/Changelog/Headless.tsx index 6951a3674..dd89a0628 100644 --- a/website/src/components/Changelog/Headless.tsx +++ b/website/src/components/Changelog/Headless.tsx @@ -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. */} Handles DNS queries over TCP correctly. + + Fixes an issue where Firezone would fail to establish connections to + Gateways and the client had to be restarted. +