diff --git a/cs/src/Connections/TunnelRelayConnection.cs b/cs/src/Connections/TunnelRelayConnection.cs index cd005917..d09d3f1f 100644 --- a/cs/src/Connections/TunnelRelayConnection.cs +++ b/cs/src/Connections/TunnelRelayConnection.cs @@ -50,6 +50,17 @@ public abstract class TunnelRelayConnection : TunnelConnection, IRelayClient, IP #endregion + /// + /// Request header that a host sends to the relay to identify its own process. + /// + /// + /// The value is , which stays the same for the + /// lifetime of the process. It lets the relay recognize a host that is reconnecting to a + /// tunnel it already holds, rather than treating it as a different host taking the tunnel + /// over. Clients do not send this header. + /// + public const string HostIdHeaderName = "X-Tunnels-Host-Process-Id"; + /// /// Maximum retry delay, ms. /// After the 6th attempt the delay will reach 2^7 * 100ms = 12.8s and stop doubling diff --git a/cs/src/Connections/TunnelRelayStreamFactory.cs b/cs/src/Connections/TunnelRelayStreamFactory.cs index 81785fef..32dc45ca 100644 --- a/cs/src/Connections/TunnelRelayStreamFactory.cs +++ b/cs/src/Connections/TunnelRelayStreamFactory.cs @@ -25,6 +25,10 @@ public class TunnelRelayStreamFactory : ITunnelRelayStreamFactory TraceSource trace, CancellationToken cancellation) { + var isHostConnection = + Array.IndexOf(subprotocols, TunnelRelayConnection.HostWebSocketSubProtocol) >= 0 || + Array.IndexOf(subprotocols, TunnelRelayConnection.HostWebSocketSubProtocolV2) >= 0; + void ConfigureWebSocketOptions(ClientWebSocketOptions options) { foreach (var subprotocol in subprotocols) @@ -36,6 +40,12 @@ void ConfigureWebSocketOptions(ClientWebSocketOptions options) { options.SetRequestHeader("Authorization", "tunnel " + accessToken); } + + if (isHostConnection && !string.IsNullOrEmpty(MultiModeTunnelHost.HostId)) + { + options.SetRequestHeader( + TunnelRelayConnection.HostIdHeaderName, MultiModeTunnelHost.HostId); + } } var stream = await WebSocketStream.ConnectToWebSocketAsync( diff --git a/rs/src/connections/relay_tunnel_host.rs b/rs/src/connections/relay_tunnel_host.rs index c3fddaa9..cee99623 100644 --- a/rs/src/connections/relay_tunnel_host.rs +++ b/rs/src/connections/relay_tunnel_host.rs @@ -49,6 +49,12 @@ type PortMap = HashMap>; // large responses continue making progress. const CHANNEL_WRITE_CHUNK_SIZE: usize = 32 * 1024; +// Identifies the host process to the relay, so it can tell a host reconnecting +// from a genuinely different host competing for the same tunnel. The value is +// the same `host_id` reported on the TunnelEndpoint and stays constant for the +// lifetime of a RelayTunnelHost. Only host connections send it; clients do not. +const HOST_ID_HEADER_NAME: &str = "X-Tunnels-Host-Process-Id"; + /// The RelayTunnelHost can host connections via the tunneling service. After /// creating it, you will generally want to run `connect()` to create a new /// a new connection. @@ -427,12 +433,14 @@ impl RelayTunnelHost { .as_deref() .ok_or(TunnelError::MissingHostEndpoint)?; + let host_id = self.host_id.to_string(); let req = build_websocket_request( url, &[ ("Sec-WebSocket-Protocol", "tunnel-relay-host"), ("Authorization", &format!("tunnel {}", host_token)), ("User-Agent", self.mgmt.user_agent.to_str().unwrap()), + (HOST_ID_HEADER_NAME, &host_id), ], )?; diff --git a/ts/src/connections/defaultTunnelRelayStreamFactory.ts b/ts/src/connections/defaultTunnelRelayStreamFactory.ts index 1ba6b7ab..d2d64186 100644 --- a/ts/src/connections/defaultTunnelRelayStreamFactory.ts +++ b/ts/src/connections/defaultTunnelRelayStreamFactory.ts @@ -4,8 +4,23 @@ import { Stream } from '@microsoft/dev-tunnels-ssh'; import { TunnelRelayStreamFactory } from './tunnelRelayStreamFactory'; import { isNode, SshHelpers } from './sshHelpers'; +import { MultiModeTunnelHost } from './multiModeTunnelHost'; import { IClientConfig } from 'websocket'; +/** + * Request header that a host sends to the relay to identify its own process. + * + * The value is `MultiModeTunnelHost.hostId`, which stays the same for the lifetime of the + * process. It lets the relay recognize a host that is reconnecting to a tunnel it already + * holds, rather than treating it as a different host taking the tunnel over. Clients do not + * send this header. + */ +const hostIdHeaderName = 'X-Tunnels-Host-Process-Id'; + +// Mirrors the host sub-protocols in tunnelRelayTunnelHost.ts. They are duplicated here rather +// than imported because that module reaches back to this one through the connection session. +const hostSubProtocols = ['tunnel-relay-host', 'tunnel-relay-host-v2-dev']; + /** * Default factory for creating streams to a tunnel relay. */ @@ -17,17 +32,24 @@ export class DefaultTunnelRelayStreamFactory implements TunnelRelayStreamFactory clientConfig?: IClientConfig, ): Promise<{ stream: Stream, protocol: string }> { if (isNode()) { + const isHostConnection = protocols.some((p) => hostSubProtocols.includes(p)); const stream = await SshHelpers.openConnection( relayUri, protocols, { ...(accessToken && { Authorization: `tunnel ${accessToken}` }), + ...(isHostConnection && + MultiModeTunnelHost.hostId && { + [hostIdHeaderName]: MultiModeTunnelHost.hostId, + }), }, clientConfig, ); return { stream, protocol: stream.protocol! }; } else { // Web sockets don't support auth. Authenticate TunnelRelay by sending accessToken as a subprotocol. + // Request headers aren't available here either, so a host running in the browser cannot + // identify its process to the relay. if (accessToken) { protocols = [...protocols, accessToken]; }