mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-16 05:20:22 +08:00
* test(e2e): close Podman driver test coverage gaps Add podman_gateway_resume test following the VM pattern (no container-state assertions since Podman keeps containers running across gateway restarts). Widen websocket_conformance feature gate from e2e-docker to e2e-host-gateway so it runs on both Docker and Podman. Fix e2e-podman.sh to default to the e2e-podman feature so Podman- specific and host-gateway tests are actually included in Podman CI runs. * refactor(e2e): extract shared CLI helpers from gateway_resume tests Move run_cli, wait_for_healthy, sandbox_names, and wait_for_sandbox_exec_contains into a new harness::cli module. All three gateway_resume variants (Docker, Podman, VM) now import these shared helpers instead of defining identical copies. Docker-specific container introspection functions remain local to gateway_resume.rs.
483 lines
15 KiB
Rust
483 lines
15 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
#![cfg(feature = "e2e")]
|
|
|
|
//! E2E regression: WebSocket credential placeholders are resolved on the
|
|
//! sandbox path after an RFC 6455 upgrade.
|
|
//!
|
|
//! The sandbox process sends its provider-managed placeholder in a masked text
|
|
//! frame. The local upstream only reports whether it saw the real secret and
|
|
//! whether any placeholder survived; it never echoes payload bytes, placeholder
|
|
//! text, or secret material back into test output.
|
|
|
|
use std::io::{self, Error, ErrorKind, Write};
|
|
use std::process::Stdio;
|
|
use std::sync::Mutex;
|
|
|
|
use base64::Engine as _;
|
|
use openshell_e2e::harness::binary::openshell_cmd;
|
|
use openshell_e2e::harness::sandbox::SandboxGuard;
|
|
use sha1::{Digest, Sha1};
|
|
use tempfile::NamedTempFile;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::{TcpListener, TcpStream};
|
|
use tokio::task::JoinHandle;
|
|
|
|
const WEBSOCKET_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
const PROVIDER_NAME: &str = "e2e-websocket-conformance";
|
|
const TEST_SERVER_HOST: &str = "host.openshell.internal";
|
|
const TEST_SECRET: &str = "sk-e2e-websocket-conformance-secret";
|
|
const TOKEN_ENV: &str = "WS_E2E_TOKEN";
|
|
const PLACEHOLDER_PREFIX: &str = "openshell:resolve:env:";
|
|
static PROVIDER_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
async fn run_cli(args: &[&str]) -> Result<String, String> {
|
|
let mut cmd = openshell_cmd();
|
|
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
|
|
|
|
let output = cmd
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("failed to spawn openshell {}: {e}", args.join(" ")))?;
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
let combined = format!("{stdout}{stderr}");
|
|
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"openshell {} failed (exit {:?}):\n{combined}",
|
|
args.join(" "),
|
|
output.status.code()
|
|
));
|
|
}
|
|
|
|
Ok(combined)
|
|
}
|
|
|
|
async fn delete_provider(name: &str) {
|
|
let mut cmd = openshell_cmd();
|
|
cmd.arg("provider")
|
|
.arg("delete")
|
|
.arg(name)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
let _ = cmd.status().await;
|
|
}
|
|
|
|
async fn create_generic_provider(name: &str) -> Result<String, String> {
|
|
let credential = format!("{TOKEN_ENV}={TEST_SECRET}");
|
|
run_cli(&[
|
|
"provider",
|
|
"create",
|
|
"--name",
|
|
name,
|
|
"--type",
|
|
"generic",
|
|
"--credential",
|
|
&credential,
|
|
])
|
|
.await
|
|
}
|
|
|
|
struct WebSocketProbeServer {
|
|
port: u16,
|
|
task: JoinHandle<()>,
|
|
}
|
|
|
|
impl WebSocketProbeServer {
|
|
async fn start() -> Result<Self, String> {
|
|
let listener = TcpListener::bind(("0.0.0.0", 0))
|
|
.await
|
|
.map_err(|e| format!("bind websocket probe server: {e}"))?;
|
|
let port = listener
|
|
.local_addr()
|
|
.map_err(|e| format!("read websocket probe server address: {e}"))?
|
|
.port();
|
|
let task = tokio::spawn(async move {
|
|
loop {
|
|
let Ok((stream, _)) = listener.accept().await else {
|
|
break;
|
|
};
|
|
tokio::spawn(async move {
|
|
let _ = handle_websocket_probe_connection(stream).await;
|
|
});
|
|
}
|
|
});
|
|
|
|
Ok(Self { port, task })
|
|
}
|
|
}
|
|
|
|
impl Drop for WebSocketProbeServer {
|
|
fn drop(&mut self) {
|
|
self.task.abort();
|
|
}
|
|
}
|
|
|
|
async fn recv_until(stream: &mut TcpStream, marker: &[u8]) -> io::Result<Vec<u8>> {
|
|
let mut data = Vec::new();
|
|
let mut buf = [0_u8; 1024];
|
|
loop {
|
|
let read = stream.read(&mut buf).await?;
|
|
if read == 0 {
|
|
return Ok(data);
|
|
}
|
|
data.extend_from_slice(&buf[..read]);
|
|
if data.windows(marker.len()).any(|window| window == marker) {
|
|
return Ok(data);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn read_websocket_text(stream: &mut TcpStream) -> io::Result<String> {
|
|
let mut header = [0_u8; 2];
|
|
stream.read_exact(&mut header).await?;
|
|
let length = match header[1] & 0x7F {
|
|
len @ 0..=125 => usize::from(len),
|
|
126 => {
|
|
let mut bytes = [0_u8; 2];
|
|
stream.read_exact(&mut bytes).await?;
|
|
usize::from(u16::from_be_bytes(bytes))
|
|
}
|
|
127 => {
|
|
let mut bytes = [0_u8; 8];
|
|
stream.read_exact(&mut bytes).await?;
|
|
usize::try_from(u64::from_be_bytes(bytes))
|
|
.map_err(|_| Error::new(ErrorKind::InvalidData, "websocket frame too large"))?
|
|
}
|
|
_ => unreachable!(),
|
|
};
|
|
|
|
let mut mask = [0_u8; 4];
|
|
if header[1] & 0x80 != 0 {
|
|
stream.read_exact(&mut mask).await?;
|
|
} else {
|
|
mask = [0, 0, 0, 0];
|
|
}
|
|
|
|
let mut payload = vec![0_u8; length];
|
|
stream.read_exact(&mut payload).await?;
|
|
if header[1] & 0x80 != 0 {
|
|
for (index, byte) in payload.iter_mut().enumerate() {
|
|
*byte ^= mask[index % mask.len()];
|
|
}
|
|
}
|
|
|
|
String::from_utf8(payload).map_err(|e| {
|
|
Error::new(
|
|
ErrorKind::InvalidData,
|
|
format!("invalid websocket text: {e}"),
|
|
)
|
|
})
|
|
}
|
|
|
|
async fn send_websocket_text(stream: &mut TcpStream, payload: &str) -> io::Result<()> {
|
|
let data = payload.as_bytes();
|
|
let mut frame = Vec::with_capacity(data.len() + 10);
|
|
frame.push(0x81);
|
|
if data.len() < 126 {
|
|
frame.push(
|
|
u8::try_from(data.len())
|
|
.map_err(|_| Error::new(ErrorKind::InvalidData, "websocket frame too large"))?,
|
|
);
|
|
} else if data.len() <= usize::from(u16::MAX) {
|
|
frame.push(126);
|
|
frame.extend_from_slice(
|
|
&u16::try_from(data.len())
|
|
.map_err(|_| Error::new(ErrorKind::InvalidData, "websocket frame too large"))?
|
|
.to_be_bytes(),
|
|
);
|
|
} else {
|
|
frame.push(127);
|
|
frame.extend_from_slice(
|
|
&u64::try_from(data.len())
|
|
.map_err(|_| Error::new(ErrorKind::InvalidData, "websocket frame too large"))?
|
|
.to_be_bytes(),
|
|
);
|
|
}
|
|
frame.extend_from_slice(data);
|
|
stream.write_all(&frame).await
|
|
}
|
|
|
|
fn header_value(request: &str, name: &str) -> Option<String> {
|
|
request.lines().find_map(|line| {
|
|
let (header, value) = line.split_once(':')?;
|
|
if header.trim().eq_ignore_ascii_case(name) {
|
|
Some(value.trim().to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
fn websocket_accept_for_key(key: &str) -> String {
|
|
let mut hasher = Sha1::new();
|
|
hasher.update(key.as_bytes());
|
|
hasher.update(WEBSOCKET_GUID.as_bytes());
|
|
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
|
|
}
|
|
|
|
async fn handle_websocket_probe_connection(mut stream: TcpStream) -> io::Result<()> {
|
|
let request_bytes = recv_until(&mut stream, b"\r\n\r\n").await?;
|
|
let request = String::from_utf8_lossy(&request_bytes);
|
|
if !request.to_ascii_lowercase().contains("upgrade: websocket") {
|
|
stream
|
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let accept = header_value(&request, "Sec-WebSocket-Key")
|
|
.map(|key| websocket_accept_for_key(&key))
|
|
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "missing Sec-WebSocket-Key"))?;
|
|
let response = format!(
|
|
"HTTP/1.1 101 Switching Protocols\r\n\
|
|
Upgrade: websocket\r\n\
|
|
Connection: Upgrade\r\n\
|
|
Sec-WebSocket-Accept: {accept}\r\n\
|
|
\r\n"
|
|
);
|
|
stream.write_all(response.as_bytes()).await?;
|
|
|
|
let text = read_websocket_text(&mut stream).await?;
|
|
let response = format!(
|
|
r#"{{"saw_placeholder": {}, "saw_secret": {}}}"#,
|
|
text.contains(PLACEHOLDER_PREFIX),
|
|
text.contains(TEST_SECRET)
|
|
);
|
|
send_websocket_text(&mut stream, &response).await
|
|
}
|
|
|
|
fn write_websocket_policy(host: &str, port: u16) -> Result<NamedTempFile, String> {
|
|
let mut file = NamedTempFile::new().map_err(|e| format!("create temp policy file: {e}"))?;
|
|
let policy = format!(
|
|
r#"version: 1
|
|
|
|
filesystem_policy:
|
|
include_workdir: true
|
|
read_only:
|
|
- /usr
|
|
- /lib
|
|
- /proc
|
|
- /dev/urandom
|
|
- /app
|
|
- /etc
|
|
- /var/log
|
|
read_write:
|
|
- /sandbox
|
|
- /tmp
|
|
- /dev/null
|
|
|
|
landlock:
|
|
compatibility: best_effort
|
|
|
|
process:
|
|
run_as_user: sandbox
|
|
run_as_group: sandbox
|
|
|
|
network_policies:
|
|
websocket_conformance:
|
|
name: websocket_conformance
|
|
endpoints:
|
|
- host: {host}
|
|
port: {port}
|
|
protocol: websocket
|
|
enforcement: enforce
|
|
access: read-write
|
|
websocket_credential_rewrite: true
|
|
allowed_ips:
|
|
- "10.0.0.0/8"
|
|
- "172.0.0.0/8"
|
|
- "192.168.0.0/16"
|
|
- "fc00::/7"
|
|
binaries:
|
|
- path: /usr/bin/python*
|
|
- path: /usr/local/bin/python*
|
|
- path: /sandbox/.uv/python/*/bin/python*
|
|
"#
|
|
);
|
|
file.write_all(policy.as_bytes())
|
|
.map_err(|e| format!("write temp policy file: {e}"))?;
|
|
file.flush()
|
|
.map_err(|e| format!("flush temp policy file: {e}"))?;
|
|
Ok(file)
|
|
}
|
|
|
|
fn websocket_client_script(host: &str, port: u16) -> String {
|
|
format!(
|
|
r#"
|
|
import base64
|
|
import json
|
|
import os
|
|
import socket
|
|
import struct
|
|
import time
|
|
import urllib.parse
|
|
|
|
HOST = {host:?}
|
|
PORT = {port}
|
|
TOKEN_ENV = {token_env:?}
|
|
|
|
def recv_until(sock, marker):
|
|
data = b""
|
|
while marker not in data:
|
|
chunk = sock.recv(4096)
|
|
if not chunk:
|
|
break
|
|
data += chunk
|
|
return data
|
|
|
|
def read_exact(sock, size):
|
|
data = b""
|
|
while len(data) < size:
|
|
chunk = sock.recv(size - len(data))
|
|
if not chunk:
|
|
raise EOFError("unexpected end of websocket frame")
|
|
data += chunk
|
|
return data
|
|
|
|
def masked_text_frame(payload):
|
|
data = payload.encode("utf-8")
|
|
mask = os.urandom(4)
|
|
if len(data) < 126:
|
|
header = bytes([0x81, 0x80 | len(data)])
|
|
elif len(data) <= 0xFFFF:
|
|
header = bytes([0x81, 0x80 | 126]) + struct.pack("!H", len(data))
|
|
else:
|
|
header = bytes([0x81, 0x80 | 127]) + struct.pack("!Q", len(data))
|
|
masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(data))
|
|
return header + mask + masked
|
|
|
|
def read_frame(sock):
|
|
first, second = read_exact(sock, 2)
|
|
length = second & 0x7F
|
|
if length == 126:
|
|
length = struct.unpack("!H", read_exact(sock, 2))[0]
|
|
elif length == 127:
|
|
length = struct.unpack("!Q", read_exact(sock, 8))[0]
|
|
mask = read_exact(sock, 4) if second & 0x80 else b""
|
|
payload = read_exact(sock, length)
|
|
if mask:
|
|
payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
|
|
return first, payload
|
|
|
|
def proxy_parts():
|
|
names = ("HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy")
|
|
proxy_url = next((os.environ.get(name) for name in names if os.environ.get(name)), None)
|
|
if not proxy_url:
|
|
raise RuntimeError("proxy environment is not configured")
|
|
parsed = urllib.parse.urlparse(proxy_url)
|
|
if not parsed.hostname:
|
|
raise RuntimeError(f"invalid proxy URL: {{proxy_url!r}}")
|
|
return parsed.hostname, parsed.port or 80
|
|
|
|
def connect_with_retry(host, port, timeout_seconds=20):
|
|
proxy_host, proxy_port = proxy_parts()
|
|
target = f"{{host}}:{{port}}"
|
|
deadline = time.monotonic() + timeout_seconds
|
|
last_error = None
|
|
while time.monotonic() < deadline:
|
|
sock = None
|
|
try:
|
|
sock = socket.create_connection((proxy_host, proxy_port), timeout=5)
|
|
request = f"CONNECT {{target}} HTTP/1.1\r\nHost: {{target}}\r\n\r\n"
|
|
sock.sendall(request.encode("ascii"))
|
|
response = recv_until(sock, b"\r\n\r\n").decode("iso-8859-1", "replace")
|
|
if response.startswith("HTTP/1.1 200") or response.startswith("HTTP/1.0 200"):
|
|
return sock
|
|
first_line = response.splitlines()[0] if response else "<empty response>"
|
|
raise RuntimeError(f"proxy CONNECT failed: {{first_line}}")
|
|
except (OSError, RuntimeError) as error:
|
|
if sock is not None:
|
|
sock.close()
|
|
last_error = error
|
|
time.sleep(0.25)
|
|
raise last_error
|
|
|
|
token = os.environ[TOKEN_ENV]
|
|
payload = json.dumps({{"authorization": "Bearer " + token}}, sort_keys=True)
|
|
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
|
|
with connect_with_retry(HOST, PORT) as sock:
|
|
request = (
|
|
f"GET /ws HTTP/1.1\r\n"
|
|
f"Host: {{HOST}}:{{PORT}}\r\n"
|
|
"Upgrade: websocket\r\n"
|
|
"Connection: Upgrade\r\n"
|
|
f"Sec-WebSocket-Key: {{key}}\r\n"
|
|
"Sec-WebSocket-Version: 13\r\n"
|
|
"\r\n"
|
|
)
|
|
sock.sendall(request.encode("ascii"))
|
|
response = recv_until(sock, b"\r\n\r\n").decode("iso-8859-1", "replace")
|
|
if not response.startswith("HTTP/1.1 101"):
|
|
raise RuntimeError("websocket upgrade failed")
|
|
sock.sendall(masked_text_frame(payload))
|
|
_, response_payload = read_frame(sock)
|
|
print(response_payload.decode("utf-8"))
|
|
"#,
|
|
host = host,
|
|
port = port,
|
|
token_env = TOKEN_ENV,
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_text_placeholder_is_rewritten_in_sandbox() {
|
|
let _provider_lock = PROVIDER_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
|
|
delete_provider(PROVIDER_NAME).await;
|
|
create_generic_provider(PROVIDER_NAME)
|
|
.await
|
|
.expect("create generic provider");
|
|
|
|
let result = async {
|
|
let server = WebSocketProbeServer::start().await?;
|
|
let policy = write_websocket_policy(TEST_SERVER_HOST, server.port)?;
|
|
let policy_path = policy
|
|
.path()
|
|
.to_str()
|
|
.ok_or_else(|| "temp policy path should be utf-8".to_string())?
|
|
.to_string();
|
|
let script = websocket_client_script(TEST_SERVER_HOST, server.port);
|
|
|
|
SandboxGuard::create(&[
|
|
"--policy",
|
|
&policy_path,
|
|
"--provider",
|
|
PROVIDER_NAME,
|
|
"--",
|
|
"python3",
|
|
"-c",
|
|
&script,
|
|
])
|
|
.await
|
|
}
|
|
.await;
|
|
|
|
delete_provider(PROVIDER_NAME).await;
|
|
|
|
let guard = result.expect("sandbox create");
|
|
assert!(
|
|
guard
|
|
.create_output
|
|
.contains(r#"{"saw_placeholder": false, "saw_secret": true}"#),
|
|
"expected upstream to see only the resolved secret marker:\n{}",
|
|
guard.create_output
|
|
);
|
|
assert!(
|
|
!guard.create_output.contains(TEST_SECRET),
|
|
"test output should not expose the raw WebSocket credential:\n{}",
|
|
guard.create_output
|
|
);
|
|
assert!(
|
|
!guard.create_output.contains(PLACEHOLDER_PREFIX),
|
|
"test output should not expose unresolved credential placeholders:\n{}",
|
|
guard.create_output
|
|
);
|
|
}
|