Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
83218e8
feat(middleware): add HTTP middleware protocol 2
pimlock Oct 9, 2026
b7a9096
refactor(middleware): select HTTP protocol 2 by binding operation
pimlock Oct 9, 2026
e45ca73
refactor(middleware): split EvaluateHttp into request and response RPCs
pimlock Oct 9, 2026
25eed10
refactor(middleware): call the new contract v2 HTTP hooks
pimlock Oct 9, 2026
f717d32
refactor(middleware): select HTTP session hooks by required capability
pimlock Oct 10, 2026
18cf209
fix(middleware): begin body stages on every preflight mutation
pimlock Oct 10, 2026
0aa3158
fix(network): check the policy generation before every response write
pimlock Oct 10, 2026
4d2c60b
fix(network): complete session hook messages only after every stage
pimlock Oct 10, 2026
f814a43
fix(examples): split expanded content guard output within the chunk l…
pimlock Oct 10, 2026
e62d4a7
fix(middleware): queue HTTP session hooks and explain failures to the…
pimlock Oct 13, 2026
e8feab4
fix(middleware): give each HTTP session hook RPC its own messages
pimlock Oct 14, 2026
289a556
fix(network): include the originating process in response preflights
pimlock Oct 14, 2026
2f6a4e3
chore(middleware): merge main into HTTP session hooks
pimlock Oct 15, 2026
30b0f46
refactor(middleware): retain response delivery blocking and consolida…
pimlock Oct 15, 2026
cd0c390
fix(middleware): release the preflight slot while waiting for a strea…
pimlock Oct 16, 2026
82eba61
fix(middleware): protect request Content-Encoding from session hook s…
pimlock Oct 16, 2026
b9a53d1
feat(middleware): record cannot_inspect diagnostics
pimlock Oct 16, 2026
de68986
docs(middleware): cover cannot_inspect for uninspectable traffic and …
pimlock Oct 16, 2026
75e504e
feat(middleware): let session hook stages own integrity headers
pimlock Oct 16, 2026
363e12b
docs(middleware): list cannot_inspect where proto comments name stage…
pimlock Oct 16, 2026
9fc4db2
docs(middleware): recreate sandboxes after a service changes hook ver…
pimlock Oct 16, 2026
889d94a
chore(middleware): merge main into HTTP session hooks
pimlock Oct 16, 2026
4199f43
test(e2e): find the middleware host when Docker discovers the gateway
pimlock Oct 16, 2026
5bd4e8b
refactor(middleware): deny uninspectable messages with reject
pimlock Oct 16, 2026
440de1a
chore(middleware): merge main into HTTP session hooks
pimlock Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/e2e-docker-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ on:
required: false
type: string
default: >-
[{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker-push","cmd":"mise run --no-deps --skip-deps e2e:rust:push","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"oci-image","cmd":"mise run --no-deps --skip-deps e2e:oci-image","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}]
[{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker-push","cmd":"mise run --no-deps --skip-deps e2e:rust:push","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"oci-image","cmd":"mise run --no-deps --skip-deps e2e:oci-image","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"middleware-http-session","cmd":"mise run --no-deps --skip-deps e2e:middleware-http-session","apt_packages":"","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}]

permissions:
actions: read
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 81 additions & 3 deletions crates/openshell-core/src/extension_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ pub const COMPUTE_LAUNCH_AUTHENTICATION: &str = "openshell.compute.launch-authen
/// Driver stages operator-selected external isolation routes for the supervisor.
pub const COMPUTE_ISOLATION_BACKEND: &str = "openshell.compute.isolation-backend";

/// Capability for supervisor middleware that implements HTTP session hooks.
///
/// A service selects HTTP session hooks for all of its HTTP bindings by
/// listing this capability in `required_capabilities`. Peers that predate
/// HTTP session hooks do not support it, so they reject the service at
/// `Describe` instead of calling the deprecated v1 HTTP hooks.
pub const SUPERVISOR_MIDDLEWARE_HTTP_SESSION: &str = "openshell.supervisor-middleware.http-session";

const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128;
const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128;
const MAX_CAPABILITY_BYTES: usize = 128;
Expand Down Expand Up @@ -122,9 +130,15 @@ pub enum NegotiationError {
pub fn gateway_metadata(family: ExtensionFamily) -> PeerMetadata {
let contract = family.contract_capability();
let mut supported_capabilities = vec![contract.clone()];
if family == ExtensionFamily::Compute {
supported_capabilities.push(COMPUTE_LAUNCH_AUTHENTICATION.to_string());
supported_capabilities.push(COMPUTE_ISOLATION_BACKEND.to_string());
match family {
ExtensionFamily::Compute => {
supported_capabilities.push(COMPUTE_LAUNCH_AUTHENTICATION.to_string());
supported_capabilities.push(COMPUTE_ISOLATION_BACKEND.to_string());
}
ExtensionFamily::SupervisorMiddleware => {
supported_capabilities.push(SUPERVISOR_MIDDLEWARE_HTTP_SESSION.to_string());
}
ExtensionFamily::Credentials | ExtensionFamily::GatewayInterceptor => {}
}
PeerMetadata {
protocol_version: Some(ProtocolVersion {
Expand Down Expand Up @@ -168,6 +182,27 @@ fn expected_contract(gateway: &PeerMetadata) -> String {
)
}

/// Extension metadata for supervisor middleware that implements HTTP session hooks.
///
/// It supports and requires [`SUPERVISOR_MIDDLEWARE_HTTP_SESSION`], so peers
/// without HTTP session hooks reject the service at `Describe`.
#[must_use]
pub fn http_session_middleware_metadata(
implementation_name: impl Into<String>,
implementation_version: impl Into<String>,
) -> PeerMetadata {
let mut metadata = extension_metadata(
ExtensionFamily::SupervisorMiddleware,
implementation_name,
implementation_version,
[SUPERVISOR_MIDDLEWARE_HTTP_SESSION.to_string()],
);
metadata
.required_capabilities
.push(SUPERVISOR_MIDDLEWARE_HTTP_SESSION.to_string());
metadata
}

pub fn negotiate(
family: ExtensionFamily,
configured_name: impl Into<String>,
Expand Down Expand Up @@ -487,6 +522,49 @@ mod tests {
);
}

#[test]
fn http_session_middleware_requires_a_peer_with_http_session_hooks() {
let middleware = http_session_middleware_metadata("example/guard", "test");
let gateway = gateway_metadata(ExtensionFamily::SupervisorMiddleware);
let negotiated = negotiate(
ExtensionFamily::SupervisorMiddleware,
"example/guard",
&gateway,
Some(middleware.clone()),
)
.expect("a peer with HTTP session hooks accepts the service");
assert!(
negotiated
.required_capabilities
.iter()
.any(|capability| capability == SUPERVISOR_MIDDLEWARE_HTTP_SESSION)
);

// A peer that predates HTTP session hooks advertises only the
// contract capability.
let mut older = gateway;
older
.supported_capabilities
.retain(|capability| capability != SUPERVISOR_MIDDLEWARE_HTTP_SESSION);
let error = negotiate(
ExtensionFamily::SupervisorMiddleware,
"example/guard",
&older,
Some(middleware),
)
.expect_err("a peer without HTTP session hooks rejects the service");
assert!(matches!(
error,
NegotiationError::MissingGatewayCapabilities { .. }
));
assert!(
error
.to_string()
.contains(SUPERVISOR_MIDDLEWARE_HTTP_SESSION),
"{error}"
);
}

#[test]
fn both_peers_require_capabilities_from_the_other() {
let (mut gateway, mut extension) = compatible();
Expand Down
99 changes: 90 additions & 9 deletions crates/openshell-core/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,29 @@ use tokio::sync::mpsc;
use tonic::{Request, Response, Status};

use crate::proto::{
HttpHeader, HttpRequestEvaluation, HttpRequestResult, HttpRequestTarget, HttpResponseEvent,
HttpResponseEventResult, MiddlewareDescribeRequest, MiddlewareManifest, RequestContext,
SupervisorMiddlewarePhase, ValidateConfigRequest, ValidateConfigResponse,
WebSocketSessionEvent, WebSocketSessionEventResult,
HttpHeader, HttpRequestEvaluation, HttpRequestResult, HttpRequestSessionEvent,
HttpRequestSessionResult, HttpRequestTarget, HttpResponseEvent, HttpResponseEventResult,
HttpResponseSessionEvent, HttpResponseSessionResult, MiddlewareDescribeRequest,
MiddlewareManifest, RequestContext, SupervisorMiddlewarePhase, ValidateConfigRequest,
ValidateConfigResponse, WebSocketSessionEvent, WebSocketSessionEventResult,
};

/// Transport-neutral result stream for one `EvaluateHttpRequestSession`
/// exchange.
pub type HttpRequestSessionResultStream = Pin<
Box<dyn tokio_stream::Stream<Item = Result<HttpRequestSessionResult, Status>> + Send + 'static>,
>;

/// Transport-neutral result stream for one `EvaluateHttpResponseSession`
/// exchange.
pub type HttpResponseSessionResultStream = Pin<
Box<
dyn tokio_stream::Stream<Item = Result<HttpResponseSessionResult, Status>> + Send + 'static,
>,
>;

/// v1 HTTP hooks. Removed in 0.2.0.
///
/// Transport-neutral result stream for one HTTP response middleware stage.
pub type HttpResponseResultStream = Pin<
Box<dyn tokio_stream::Stream<Item = Result<HttpResponseEventResult, Status>> + Send + 'static>,
Expand Down Expand Up @@ -65,6 +82,26 @@ pub trait SupervisorMiddlewareEndpoint: Send + Sync {
"middleware does not implement HTTP response pre-return evaluation",
))
}

/// Open one HTTP session hook request exchange (`EvaluateHttpRequestSession`).
async fn open_http_request_session(
&self,
_requests: mpsc::Receiver<HttpRequestSessionEvent>,
) -> Result<HttpRequestSessionResultStream, Status> {
Err(Status::unimplemented(
"middleware does not implement HTTP session hook requests",
))
}

/// Open one HTTP session hook response exchange (`EvaluateHttpResponseSession`).
async fn open_http_response_session(
&self,
_requests: mpsc::Receiver<HttpResponseSessionEvent>,
) -> Result<HttpResponseSessionResultStream, Status> {
Err(Status::unimplemented(
"middleware does not implement HTTP session hook responses",
))
}
}

/// Borrowed request state exposed to one in-process middleware invocation.
Expand Down Expand Up @@ -193,7 +230,7 @@ impl<'a> HttpRequestView<'a> {
/// operation: SupervisorMiddlewareOperation::HttpRequest as i32,
/// phase: SupervisorMiddlewarePhase::PreCredentials as i32,
/// max_payload_bytes: 1024,
/// request_timeout: None,
/// ..Default::default()
/// }],
/// expected_audience: String::new(),
/// extension: Some(openshell_core::extension_protocol::extension_metadata(
Expand Down Expand Up @@ -278,6 +315,33 @@ pub trait InProcessMiddleware: Send + Sync {
"middleware does not implement HTTP response pre-return evaluation",
))
}

/// Open one HTTP session hook request exchange (`EvaluateHttpRequestSession`),
/// for a request or for traffic `OpenShell` cannot inspect.
///
/// Implementations that do not require the HTTP session hooks capability
/// may keep the default unsupported response.
async fn open_http_request_session(
&self,
_requests: mpsc::Receiver<HttpRequestSessionEvent>,
) -> std::result::Result<HttpRequestSessionResultStream, Status> {
Err(Status::unimplemented(
"middleware does not implement HTTP session hook requests",
))
}

/// Open one HTTP session hook response exchange (`EvaluateHttpResponseSession`).
///
/// Implementations that do not require the HTTP session hooks capability
/// may keep the default unsupported response.
async fn open_http_response_session(
&self,
_requests: mpsc::Receiver<HttpResponseSessionEvent>,
) -> std::result::Result<HttpResponseSessionResultStream, Status> {
Err(Status::unimplemented(
"middleware does not implement HTTP session hook responses",
))
}
}

/// Default timeout for one supervisor middleware RPC.
Expand All @@ -292,12 +356,29 @@ pub const MAX_MIDDLEWARE_CHAIN_TIMEOUT: Duration = Duration::from_secs(30);
pub const MAX_MIDDLEWARE_PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(1);
/// Process-wide safety valve for concurrently buffered middleware work.
pub const MAX_CONCURRENT_MIDDLEWARE_WORK: usize = 32;
/// Process-wide safety valve for retained streaming middleware sessions.
/// Process-wide safety valve for retained WebSocket and v1 HTTP response
/// middleware sessions.
///
/// A session consumes one permit regardless of its protocol or stage fan-out.
/// Persistent middleware protocols must acquire from this shared budget before
/// opening streams and retain the permit while any stage remains active.
/// A session consumes one permit regardless of its stage fan-out. These
/// protocols acquire from this shared budget before opening streams and retain
/// the permit while any stage remains active. HTTP session hooks use
/// [`MAX_CONCURRENT_HTTP_MIDDLEWARE_SESSIONS`] and
/// [`MAX_CONCURRENT_HTTP_MIDDLEWARE_STREAMS`] instead.
pub const MAX_CONCURRENT_MIDDLEWARE_SESSIONS: usize = 32;
/// Process-wide bound on HTTP session hook messages in preflight or in a
/// BUFFERED body.
///
/// A request and its response each hold one slot, and only while a stage
/// inspects them: a message whose stages all continue at preflight releases
/// its slot then. A message waits for a slot within its chain deadline.
pub const MAX_CONCURRENT_HTTP_MIDDLEWARE_SESSIONS: usize = 64;
/// Process-wide bound on HTTP session hook messages with a STREAM stage.
///
/// A STREAM body has no total deadline, so a server-sent events response can
/// hold its slot indefinitely. This separate budget keeps such streams from
/// starving short messages. It allows a request and a response stream for each
/// of the 96 network streams the `OpenShell` sandbox runtime relays at once.
pub const MAX_CONCURRENT_HTTP_MIDDLEWARE_STREAMS: usize = 256;

/// Largest number of middleware configurations accepted in one sandbox policy.
pub const MAX_MIDDLEWARE_CONFIGS: usize = 10;
Expand Down
42 changes: 37 additions & 5 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ pub use merge::{
policy_covers_rule,
};
pub use middleware::middleware_host_matches;
pub use middleware::tls_skip_conflicts as middleware_tls_skip_conflicts;
pub use middleware::validate_json as validate_network_middleware_json;
pub use middleware::validate_json_with_config as validate_network_middleware_json_with_config;

Expand Down Expand Up @@ -3323,7 +3324,8 @@ network_policies:
},
);

let violations = validate_sandbox_policy(&policy).expect_err("tls skip conflict");
validate_sandbox_policy(&policy).expect("the tls: skip rule needs the registry");
let violations = middleware_tls_skip_conflicts(&policy, |_| false);
assert!(violations.iter().any(|violation| matches!(
violation,
PolicyViolation::MiddlewareTlsSkipConflict {
Expand Down Expand Up @@ -3354,8 +3356,36 @@ network_policies:
},
);

validate_sandbox_policy(&policy)
.expect("fail-open middleware may select uninspectable tls: skip traffic");
assert!(
middleware_tls_skip_conflicts(&policy, |_| false).is_empty(),
"fail-open middleware may select uninspectable tls: skip traffic"
);
}

#[test]
fn tls_skip_rule_exempts_middleware_that_decides_uninspectable_traffic() {
let mut policy = restrictive_default_policy();
add_middleware(&mut policy, "guard", middleware_config("example/guard"));
policy.network_policies.insert(
"api".into(),
NetworkPolicyRule {
name: "api".into(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".into(),
port: 443,
tls: openshell_core::proto::NetworkTlsMode::Skip as i32,
..Default::default()
}],
binaries: Vec::new(),
},
);

assert_eq!(middleware_tls_skip_conflicts(&policy, |_| false).len(), 1);
assert!(
middleware_tls_skip_conflicts(&policy, |middleware| middleware == "example/guard")
.is_empty(),
"HTTP session hook middleware decides about tls: skip tunnels at runtime"
);
}

#[test]
Expand All @@ -3378,7 +3408,8 @@ network_policies:
},
);

let violations = validate_sandbox_policy(&policy).expect_err("tls skip conflict");
validate_sandbox_policy(&policy).expect("the tls: skip rule needs the registry");
let violations = middleware_tls_skip_conflicts(&policy, |_| false);
assert!(violations.iter().any(|violation| matches!(
violation,
PolicyViolation::MiddlewareTlsSkipConflict { middleware_name, .. }
Expand Down Expand Up @@ -3406,7 +3437,8 @@ network_policies:
},
);

let violations = validate_sandbox_policy(&policy).expect_err("tls skip conflict");
validate_sandbox_policy(&policy).expect("the tls: skip rule needs the registry");
let violations = middleware_tls_skip_conflicts(&policy, |_| false);
assert!(violations.iter().any(|violation| matches!(
violation,
PolicyViolation::MiddlewareTlsSkipConflict {
Expand Down
Loading
Loading