diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 48ea6eae2..bbda29933 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -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: diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index ef52ba257..2755131b9 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -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. diff --git a/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs b/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs index e268ceef1..9afa9f8c1 100644 --- a/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs +++ b/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs @@ -9,12 +9,13 @@ use std::time::Duration; fn main() -> std::io::Result<()> { let arguments: Vec = 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(()) } diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 727fb7988..e8fe06dce 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -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. "@