test(mxc): cover named HTTP service forwarding

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
Drew Newberry
2026-10-08 23:31:20 -07:00
parent e71c7bc267
commit 7e0c8a67f6
4 changed files with 135 additions and 10 deletions
@@ -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:
+6
View File
@@ -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.
"@