Skip to content

Commit b3a9bd8

Browse files
authored
perf(server): drop per-connection session tokens for TCP forwards (#3734)
`openshell forward service` minted an SSH session token before every forwarded TCP connection and revoked it afterwards: two store commits per connection. The token added nothing on that path. `ForwardTcp` already authenticates the caller and authorizes it against the sandbox's workspace on every stream before it looks at the token, the relay to the supervisor is opened with the sandbox id and target only, and the token is never forwarded, audited, or visible to the target service. The mechanism exists for `openshell sandbox ssh`, where the process that opens the stream is an ssh ProxyCommand holding nothing but the token. Reusing it per TCP connection put a store write on the connect path and serialized concurrent forwards on commit latency: #3494 measured the symptom, and #3543 made the commits cheaper, but each one still holds SQLite's writer lock for an fsync, so connection setup under a burst stayed linear in the number of concurrent connections. Let `target.tcp` streams omit `authorization_token`. The gateway admits them on the already-authorized principal, counts them against the same per-sandbox connection cap, and touches no store. `target.ssh` streams keep requiring the token. A token supplied with a TCP target is still validated and counted per token, so an older CLI against a new gateway is unchanged. The CLI stops minting and revoking a session per forwarded connection; against a gateway that predates this change it recognizes the `authorization_token is required` rejection once and falls back to per-connection tokens for the rest of that forward. Tests cover token-less TCP admission and slot release, SSH targets still rejected without a token, a supplied token still validated, and the per-sandbox cap for token-less forwards. CLI integration tests run `service_forward_tcp` against a mock gateway: token-less inits echo data with no CreateSshSession or RevokeSshSession call, and a gateway that rejects the empty token is detected once, after which every connection in that forward mints and revokes its own token. Architecture and security docs describe which targets carry a token, and the per-token connection limit now reads 3, matching the gateway. Signed-off-by: Jason T. Greene <jason.greene@redhat.com>
1 parent 3082a9a commit b3a9bd8

4 files changed

Lines changed: 540 additions & 74 deletions

File tree

‎crates/openshell-cli/src/run.rs‎

Lines changed: 136 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2161,6 +2161,9 @@ pub async fn service_forward_tcp(
21612161
let sandbox_name = name.to_string();
21622162
let sandbox_workspace = workspace.to_string();
21632163
let (fatal_tx, mut fatal_rx) = tokio::sync::mpsc::channel::<String>(1);
2164+
// Set once this forward learns that the gateway predates principal-authorized
2165+
// TCP forwards and still requires a `CreateSshSession` token per connection.
2166+
let legacy_session_tokens = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
21642167
let mut health_check = tokio::time::interval(Duration::from_secs(2));
21652168
health_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
21662169
loop {
@@ -2184,21 +2187,8 @@ pub async fn service_forward_tcp(
21842187
let target_host = target_host.to_string();
21852188
let service_id = format!("service-forward:{name}:{target_host}:{target_port}");
21862189
let fatal_tx = fatal_tx.clone();
2190+
let legacy_session_tokens = legacy_session_tokens.clone();
21872191
tokio::spawn(async move {
2188-
let token = match create_forward_session_token(
2189-
&mut client,
2190-
&sandbox_name,
2191-
&sandbox_workspace,
2192-
).await {
2193-
Ok(token) => token,
2194-
Err(err) => {
2195-
tracing::warn!(peer = %peer, error = %err, "service forward session creation failed");
2196-
if err.fatal {
2197-
let _ = fatal_tx.send(err.message).await;
2198-
}
2199-
return;
2200-
}
2201-
};
22022192
if let Err(err) = forward_one_tcp_connection(
22032193
&mut client,
22042194
socket,
@@ -2207,7 +2197,7 @@ pub async fn service_forward_tcp(
22072197
target_host,
22082198
target_port,
22092199
service_id,
2210-
token.clone(),
2200+
legacy_session_tokens,
22112201
)
22122202
.await
22132203
{
@@ -2216,9 +2206,6 @@ pub async fn service_forward_tcp(
22162206
let _ = fatal_tx.send(err.message).await;
22172207
}
22182208
}
2219-
let _ = client
2220-
.revoke_ssh_session(RevokeSshSessionRequest { allow_missing: true, token })
2221-
.await;
22222209
});
22232210
}
22242211
}
@@ -2242,6 +2229,14 @@ async fn create_forward_session_token(
22422229
Ok(response.into_inner().token)
22432230
}
22442231

2232+
/// Older gateways reject a token-less `ForwardTcp` init with this
2233+
/// `Unauthenticated` status; newer ones authorize TCP targets on the caller's
2234+
/// principal and only demand a token for SSH targets.
2235+
fn forward_requires_session_token(status: &Status) -> bool {
2236+
status.code() == Code::Unauthenticated
2237+
&& status.message().contains("authorization_token is required")
2238+
}
2239+
22452240
async fn fetch_ready_sandbox_for_forward(
22462241
client: &mut crate::tls::GrpcClient,
22472242
name: &str,
@@ -2343,37 +2338,119 @@ async fn forward_one_tcp_connection(
23432338
target_host: String,
23442339
target_port: u16,
23452340
service_id: String,
2346-
authorization_token: String,
2341+
legacy_session_tokens: std::sync::Arc<std::sync::atomic::AtomicBool>,
23472342
) -> std::result::Result<(), ForwardTcpConnectionError> {
2343+
let mut init = TcpForwardInit {
2344+
sandbox: sandbox_name.clone(),
2345+
workspace: workspace.clone(),
2346+
service_id,
2347+
target: Some(tcp_forward_init::Target::Tcp(TcpRelayTarget {
2348+
host: target_host,
2349+
port: u32::from(target_port),
2350+
})),
2351+
// The gateway authorizes TCP forwards on this client's credentials for
2352+
// every stream, so no per-connection session token is minted unless the
2353+
// gateway turns out to predate that (`forward_requires_session_token`),
2354+
// which this forward remembers in `legacy_session_tokens`.
2355+
authorization_token: String::new(),
2356+
};
2357+
2358+
let mut session_token = None;
2359+
if legacy_session_tokens.load(std::sync::atomic::Ordering::Relaxed) {
2360+
match create_forward_session_token(client, &sandbox_name, &workspace).await {
2361+
Ok(token) => {
2362+
init.authorization_token.clone_from(&token);
2363+
session_token = Some(token);
2364+
}
2365+
Err(err) => {
2366+
drain_and_shutdown_local_socket(socket).await;
2367+
return Err(err);
2368+
}
2369+
}
2370+
}
2371+
2372+
let opened = match open_forward_tcp_stream(client, init.clone()).await {
2373+
Ok(opened) => opened,
2374+
Err(status) if session_token.is_none() && forward_requires_session_token(&status) => {
2375+
tracing::info!(
2376+
"gateway requires an SSH session token per forwarded connection; \
2377+
minting one per connection for the rest of this forward"
2378+
);
2379+
legacy_session_tokens.store(true, std::sync::atomic::Ordering::Relaxed);
2380+
let token = match create_forward_session_token(client, &sandbox_name, &workspace).await
2381+
{
2382+
Ok(token) => token,
2383+
Err(err) => {
2384+
drain_and_shutdown_local_socket(socket).await;
2385+
return Err(err);
2386+
}
2387+
};
2388+
init.authorization_token.clone_from(&token);
2389+
session_token = Some(token);
2390+
match open_forward_tcp_stream(client, init).await {
2391+
Ok(opened) => opened,
2392+
Err(status) => {
2393+
drain_and_shutdown_local_socket(socket).await;
2394+
revoke_forward_session_token(client, session_token).await;
2395+
return Err(ForwardTcpConnectionError::from_status(status));
2396+
}
2397+
}
2398+
}
2399+
Err(status) => {
2400+
drain_and_shutdown_local_socket(socket).await;
2401+
revoke_forward_session_token(client, session_token).await;
2402+
return Err(ForwardTcpConnectionError::from_status(status));
2403+
}
2404+
};
2405+
2406+
let result = bridge_local_socket_to_forward_stream(socket, opened).await;
2407+
revoke_forward_session_token(client, session_token).await;
2408+
result
2409+
}
2410+
2411+
/// An open `ForwardTcp` stream: the sender for local-to-gateway frames and the
2412+
/// gateway-to-local response stream.
2413+
type OpenForwardTcpStream = (
2414+
tokio::sync::mpsc::Sender<TcpForwardFrame>,
2415+
tonic::Streaming<TcpForwardFrame>,
2416+
);
2417+
2418+
async fn open_forward_tcp_stream(
2419+
client: &mut crate::tls::GrpcClient,
2420+
init: TcpForwardInit,
2421+
) -> std::result::Result<OpenForwardTcpStream, Status> {
23482422
use tokio_stream::wrappers::ReceiverStream;
23492423

23502424
let (tx, rx) = tokio::sync::mpsc::channel::<TcpForwardFrame>(16);
23512425
tx.send(TcpForwardFrame {
23522426
payload: Some(openshell_core::proto::tcp_forward_frame::Payload::Init(
2353-
TcpForwardInit {
2354-
sandbox: sandbox_name,
2355-
workspace: workspace.clone(),
2356-
service_id,
2357-
target: Some(tcp_forward_init::Target::Tcp(TcpRelayTarget {
2358-
host: target_host,
2359-
port: u32::from(target_port),
2360-
})),
2361-
authorization_token,
2362-
},
2427+
init,
23632428
)),
23642429
})
23652430
.await
2366-
.map_err(|_| ForwardTcpConnectionError::transient("failed to initialize forward stream"))?;
2431+
.map_err(|_| Status::internal("failed to initialize forward stream"))?;
2432+
let response = client
2433+
.forward_tcp(ReceiverStream::new(rx))
2434+
.await?
2435+
.into_inner();
2436+
Ok((tx, response))
2437+
}
23672438

2368-
let response = match client.forward_tcp(ReceiverStream::new(rx)).await {
2369-
Ok(response) => response.into_inner(),
2370-
Err(status) => {
2371-
let err = ForwardTcpConnectionError::from_status(status);
2372-
drain_and_shutdown_local_socket(socket).await;
2373-
return Err(err);
2374-
}
2375-
};
2439+
async fn revoke_forward_session_token(client: &mut crate::tls::GrpcClient, token: Option<String>) {
2440+
if let Some(token) = token {
2441+
let _ = client
2442+
.revoke_ssh_session(RevokeSshSessionRequest {
2443+
allow_missing: true,
2444+
token,
2445+
})
2446+
.await;
2447+
}
2448+
}
23762449

2450+
async fn bridge_local_socket_to_forward_stream(
2451+
socket: tokio::net::TcpStream,
2452+
(tx, response): OpenForwardTcpStream,
2453+
) -> std::result::Result<(), ForwardTcpConnectionError> {
23772454
let (local_read, local_write) = socket.into_split();
23782455
relay_local_socket(local_read, local_write, tx, response).await
23792456
}
@@ -6788,13 +6865,14 @@ fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String
67886865
mod tests {
67896866
use super::{
67906867
ForwardTcpConnectionError, PolicyGetView, ProvisioningStep, build_sandbox_resource_limits,
6791-
format_endpoint, format_log_line, git_sync_files, has_main_process_result,
6792-
parse_cli_setting_value, parse_credential_expiry_cli_value, parse_driver_config_json,
6793-
parse_secret_material_env_pairs, policy_revision_list_json, policy_revision_to_json,
6794-
proto_execution_timeout, provisioning_timeout_message, ready_false_condition_message,
6795-
relay_local_socket, resolve_from, rootfs_tar_sources_supported_for_gateway,
6796-
sandbox_should_persist, sandbox_upload_plan, service_endpoint_to_json,
6797-
service_expose_status_error, service_url_for_gateway, workspace_member_to_json,
6868+
format_endpoint, format_log_line, forward_requires_session_token, git_sync_files,
6869+
has_main_process_result, parse_cli_setting_value, parse_credential_expiry_cli_value,
6870+
parse_driver_config_json, parse_secret_material_env_pairs, policy_revision_list_json,
6871+
policy_revision_to_json, proto_execution_timeout, provisioning_timeout_message,
6872+
ready_false_condition_message, relay_local_socket, resolve_from,
6873+
rootfs_tar_sources_supported_for_gateway, sandbox_should_persist, sandbox_upload_plan,
6874+
service_endpoint_to_json, service_expose_status_error, service_url_for_gateway,
6875+
workspace_member_to_json,
67986876
};
67996877
use openshell_core::proto::TcpForwardFrame;
68006878

@@ -8909,4 +8987,17 @@ mod tests {
89098987
.expect("over the limit must fail");
89108988
assert!(error.to_string().contains("sandbox upload"), "{error}");
89118989
}
8990+
8991+
#[test]
8992+
fn forward_requires_session_token_matches_only_the_legacy_gateway_error() {
8993+
assert!(forward_requires_session_token(&Status::unauthenticated(
8994+
"authorization_token is required for ForwardTcp"
8995+
)));
8996+
assert!(!forward_requires_session_token(&Status::unauthenticated(
8997+
"SSH session token not found"
8998+
)));
8999+
assert!(!forward_requires_session_token(&Status::permission_denied(
9000+
"authorization_token is required for ForwardTcp"
9001+
)));
9002+
}
89129003
}

0 commit comments

Comments
 (0)