mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-03 08:17:44 +08:00
test(mxc): cover named HTTP service forwarding
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
@@ -372,6 +372,10 @@ coverage, not evidence that direct host ingress is fenced. Use
|
||||
The E2E mise task and remote runner build the `mxc-forwarding-agent` Cargo example
|
||||
using `windows:build:mxc-fixtures` (or its `:x64` / `:arm64` variant). This fixture
|
||||
is not one of the four production deliverables; do not package it in releases.
|
||||
The `service-forwarding` scenario uses its HTTP mode to test named endpoint
|
||||
exposure, two exact GET responses through gateway routing, and HTTP 404 after
|
||||
endpoint deletion. This is distinct from `openshell forward service`; neither
|
||||
scenario tests file upload or exclusive ingress enforcement.
|
||||
|
||||
When reporting `windows:ci`, distinguish these categories:
|
||||
|
||||
|
||||
@@ -172,3 +172,9 @@ assigned. Its native echo agent is a test-only Cargo example, built automaticall
|
||||
by the E2E mise task and remote runner, not a shipped runtime binary. It verifies
|
||||
managed ingress, not denial of direct host-to-workload connections. Select it with
|
||||
`-Scenario forwarding` on the local harness or remote runner.
|
||||
|
||||
The separate `service-forwarding` scenario runs the same native fixture in HTTP
|
||||
mode, exposes a named endpoint with `openshell service expose`, and verifies two
|
||||
unique GET responses through gateway host-based routing. It deletes the endpoint
|
||||
and requires HTTP 404 afterward. Requests connect only to the gateway with the
|
||||
exposed URL's Host header, so no wildcard DNS setup is required.
|
||||
|
||||
@@ -9,12 +9,13 @@ use std::time::Duration;
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
let arguments: Vec<String> = std::env::args().collect();
|
||||
if arguments.len() != 3 {
|
||||
if !(arguments.len() == 3 || (arguments.len() == 4 && arguments[3] == "http")) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"usage: mxc-forwarding-agent READY_FILE NONCE",
|
||||
"usage: mxc-forwarding-agent READY_FILE NONCE [http]",
|
||||
));
|
||||
}
|
||||
let http_mode = arguments.len() == 4;
|
||||
let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))?;
|
||||
let ready_file = std::path::Path::new(&arguments[1]);
|
||||
let staged_ready = ready_file.with_extension("tmp");
|
||||
@@ -26,14 +27,48 @@ fn main() -> std::io::Result<()> {
|
||||
socket.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||
socket.set_write_timeout(Some(Duration::from_secs(10)))?;
|
||||
let mut request = String::new();
|
||||
BufReader::new((&socket).take(4096)).read_line(&mut request)?;
|
||||
let mut reader = BufReader::new((&socket).take(8192));
|
||||
reader.read_line(&mut request)?;
|
||||
if !request.ends_with('\n') {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"request must be a bounded newline-terminated string",
|
||||
));
|
||||
}
|
||||
write!(socket, "{}:{request}", arguments[2])?;
|
||||
if http_mode {
|
||||
loop {
|
||||
let mut header = String::new();
|
||||
if reader.read_line(&mut header)? == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"HTTP headers must be bounded and complete",
|
||||
));
|
||||
}
|
||||
if header == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
drop(reader);
|
||||
let mut fields = request.split_whitespace();
|
||||
if fields.next() != Some("GET") {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"HTTP fixture accepts GET only",
|
||||
));
|
||||
}
|
||||
let target = fields.next().ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "missing HTTP target")
|
||||
})?;
|
||||
let body = format!("{}:{target}", arguments[2]);
|
||||
write!(
|
||||
socket,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)?;
|
||||
} else {
|
||||
drop(reader);
|
||||
write!(socket, "{}:{request}", arguments[2])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
# processcontainer only.
|
||||
# network-policy - supervisor-owned network policy permits admission.
|
||||
# forwarding - two TCP request/reply exchanges through OpenShell ingress.
|
||||
# service-forwarding - named HTTP service routing and endpoint deletion.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -335,6 +336,70 @@ function Test-Forwarding([string]$sandboxName, [string]$readyFile, [string]$nonc
|
||||
}
|
||||
}
|
||||
|
||||
# Connect to the gateway only, setting the exposed service URL's Host header.
|
||||
# This avoids DNS requirements and cannot accidentally dial the workload directly.
|
||||
function Invoke-ServiceRequest([uri]$serviceUrl, [string]$target) {
|
||||
if ($serviceUrl.Scheme -ne 'http' -or $serviceUrl.Port -ne $Port) {
|
||||
throw "Unexpected E2E service URL: $serviceUrl"
|
||||
}
|
||||
$request = [Net.HttpWebRequest]::Create("http://127.0.0.1:$Port$target")
|
||||
$request.Host = $serviceUrl.Authority
|
||||
$request.Proxy = $null
|
||||
$request.AllowAutoRedirect = $false
|
||||
$request.KeepAlive = $false
|
||||
$request.Timeout = 10000
|
||||
$request.ReadWriteTimeout = 10000
|
||||
$response = $null
|
||||
$reader = $null
|
||||
try {
|
||||
try { $response = $request.GetResponse() }
|
||||
catch [Net.WebException] {
|
||||
if (-not $_.Exception.Response) { throw }
|
||||
$response = $_.Exception.Response
|
||||
}
|
||||
$reader = New-Object IO.StreamReader($response.GetResponseStream())
|
||||
return @{ Status = [int]$response.StatusCode; Body = $reader.ReadToEnd() }
|
||||
} finally {
|
||||
if ($reader) { $reader.Dispose() }
|
||||
if ($response) { $response.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ServiceForwarding([string]$sandboxName, [string]$readyFile, [string]$nonce) {
|
||||
if (-not (Wait-File $readyFile 30)) { throw "MXC HTTP listener did not report readiness" }
|
||||
$targetPort = [int](Get-Content -LiteralPath $readyFile -Raw).Trim()
|
||||
if ($targetPort -lt 1 -or $targetPort -gt 65535) { throw "Invalid workload HTTP port" }
|
||||
$exposed = $false
|
||||
try {
|
||||
$result = Invoke-NativeCaptured $cli @('service', 'expose', $sandboxName, "$targetPort", 'web') 15
|
||||
$text = $result.Output -join "`n"
|
||||
$text | Set-Content -LiteralPath (Join-Path $resultDir 'service.expose.log') -Encoding UTF8
|
||||
if ($result.ExitCode -ne 0) { throw "service expose failed: $text" }
|
||||
$exposed = $true
|
||||
if ($text -notmatch 'URL:\s+(http://[^\s\x1b]+)') { throw "service expose returned no HTTP URL: $text" }
|
||||
$serviceUrl = [uri]$Matches[1]
|
||||
for ($exchange = 1; $exchange -le 2; $exchange++) {
|
||||
$target = "/probe-$exchange?request=$([guid]::NewGuid().ToString('N'))"
|
||||
$response = Invoke-ServiceRequest $serviceUrl $target
|
||||
if ($response.Status -ne 200 -or $response.Body -ne "$nonce`:$target") {
|
||||
throw "HTTP exchange $exchange failed: status=$($response.Status); body=$($response.Body)"
|
||||
}
|
||||
Info "service HTTP exchange ${exchange}: exact workload response verified"
|
||||
}
|
||||
$deleted = Invoke-NativeCaptured $cli @('service', 'delete', $sandboxName, 'web') 15
|
||||
$deleted.Output | Set-Content -LiteralPath (Join-Path $resultDir 'service.delete.log') -Encoding UTF8
|
||||
if ($deleted.ExitCode -ne 0) { throw "service delete failed: $($deleted.Output -join "`n")" }
|
||||
$exposed = $false
|
||||
$response = Invoke-ServiceRequest $serviceUrl '/after-delete'
|
||||
if ($response.Status -ne 404) { throw "Deleted service still routes: status=$($response.Status)" }
|
||||
Info 'deleted service returns HTTP 404'
|
||||
} finally {
|
||||
if ($exposed) {
|
||||
try { Invoke-NativeCaptured $cli @('service', 'delete', $sandboxName, 'web') 15 | Out-Null } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Detect an agent *launch* failure (binary not found / not implemented) vs a
|
||||
# legitimate policy denial. Used to avoid false-passing a deny scenario when the
|
||||
# agent never actually ran.
|
||||
@@ -579,6 +644,13 @@ try {
|
||||
Backends = "process_container"; Kind = "forwarding"
|
||||
ReadyFile = (Join-Path $DemoDir "forwarding-port.txt")
|
||||
Description = "real MXC listener receives two TCP exchanges through authenticated OpenShell forwarding"
|
||||
},
|
||||
@{
|
||||
Name = "service-forwarding"; PolicyFile = Join-Path $policyDir "forwarding.yaml"
|
||||
SandboxId = "svc"
|
||||
Backends = "process_container"; Kind = "service-forwarding"
|
||||
ReadyFile = (Join-Path $DemoDir "service-forwarding-port.txt")
|
||||
Description = "named HTTP service routes two requests into MXC and stops routing after deletion"
|
||||
}
|
||||
)
|
||||
|
||||
@@ -650,7 +722,7 @@ try {
|
||||
} else {
|
||||
$command = @($cmdExe, "/c", "echo denied 1> $denied")
|
||||
}
|
||||
} elseif ($sc.Kind -eq "forwarding") {
|
||||
} elseif ($sc.Kind -in @("forwarding", "service-forwarding")) {
|
||||
Remove-Item -LiteralPath $sc.ReadyFile -Force -ErrorAction SilentlyContinue
|
||||
$forwardNonce = [guid]::NewGuid().ToString('N')
|
||||
$fixture = Join-Path $BinaryDir 'mxc-forwarding-agent.exe'
|
||||
@@ -663,6 +735,7 @@ try {
|
||||
$workload = Join-Path $DemoDir 'mxc-forwarding-agent.exe'
|
||||
Copy-Item -LiteralPath $fixture -Destination $workload -Force
|
||||
$command = @($workload, $sc.ReadyFile, $forwardNonce)
|
||||
if ($sc.Kind -eq "service-forwarding") { $command += 'http' }
|
||||
} else {
|
||||
$command = @($cmdExe, "/c", "exit 0")
|
||||
}
|
||||
@@ -719,12 +792,18 @@ try {
|
||||
if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure (not a policy result)" }
|
||||
$results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "artifact absent" }
|
||||
}
|
||||
} elseif ($sc.Kind -eq "forwarding") {
|
||||
} elseif ($sc.Kind -in @("forwarding", "service-forwarding")) {
|
||||
try {
|
||||
if ($createExitCode -ne 0) { throw "sandbox create failed: $createOutStr" }
|
||||
Test-Forwarding $sandboxName $sc.ReadyFile $forwardNonce
|
||||
Ok "$($sc.Name): both forwarded request/reply exchanges succeeded"
|
||||
$results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "two exact TCP replies through OpenShell" }
|
||||
if ($sc.Kind -eq 'forwarding') {
|
||||
Test-Forwarding $sandboxName $sc.ReadyFile $forwardNonce
|
||||
$reason = 'two exact TCP replies through OpenShell'
|
||||
} else {
|
||||
Test-ServiceForwarding $sandboxName $sc.ReadyFile $forwardNonce
|
||||
$reason = 'two exact HTTP replies; deleted route returns 404'
|
||||
}
|
||||
Ok "$($sc.Name): $reason"
|
||||
$results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = $reason }
|
||||
} catch {
|
||||
Bad "$($sc.Name): $($_.Exception.Message)"
|
||||
$results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = $_.Exception.Message }
|
||||
@@ -839,7 +918,8 @@ Files in this bundle ($resultDir):
|
||||
What PASS means: every selected scenario ran against real MXC and met its expected verdict - positive
|
||||
writes produced their artifact, deny writes were blocked with either a control
|
||||
write or driver-launch evidence, and forwarding verified two exact TCP replies
|
||||
through OpenShell. Missing coverage exits non-zero; network-policy proves
|
||||
through OpenShell. Service forwarding verifies two HTTP replies and HTTP 404
|
||||
after endpoint deletion. Missing coverage exits non-zero; network-policy proves
|
||||
admission and execution, not network enforcement. Forwarding proves managed
|
||||
ingress works, not that direct host ingress is blocked.
|
||||
"@
|
||||
|
||||
Reference in New Issue
Block a user