From a0095e8e97f4a89a4d344eb08f1f20f5ea8c8754 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:48:46 -0700 Subject: [PATCH 01/17] feat(policy): accept explicit tcp endpoint protocol Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-cli/src/policy_update.rs | 24 +++++++- crates/openshell-policy/src/ambiguity.rs | 25 +++++++- crates/openshell-policy/src/l7_validate.rs | 61 +++++++++++++++++-- crates/openshell-providers/src/profiles.rs | 31 ++++++++++ .../data/sandbox-policy.rego | 9 ++- .../openshell-supervisor-network/src/opa.rs | 21 +++++++ docs/reference/policy-schema.mdx | 3 +- docs/sandboxes/policies.mdx | 6 +- proto/sandbox.proto | 3 +- 9 files changed, 166 insertions(+), 17 deletions(-) diff --git a/crates/openshell-cli/src/policy_update.rs b/crates/openshell-cli/src/policy_update.rs index e21054f46d..17ef50cdad 100644 --- a/crates/openshell-cli/src/policy_update.rs +++ b/crates/openshell-cli/src/policy_update.rs @@ -327,9 +327,9 @@ fn parse_add_endpoint_spec(spec: &str) -> Result { "--add-endpoint access segment must be one of read-only, read-write, or full; got '{access}' in '{spec}'" )); } - if !protocol.is_empty() && !matches!(protocol, "rest" | "websocket" | "sql") { + if !protocol.is_empty() && !matches!(protocol, "tcp" | "rest" | "websocket" | "sql") { return Err(miette!( - "--add-endpoint protocol segment must be 'rest', 'websocket', or 'sql'; got '{protocol}' in '{spec}'" + "--add-endpoint protocol segment must be 'tcp', 'rest', 'websocket', or 'sql'; got '{protocol}' in '{spec}'" )); } if !enforcement.is_empty() && !matches!(enforcement, "enforce" | "audit") { @@ -547,6 +547,26 @@ mod tests { assert_eq!(endpoint.enforcement, "enforce"); } + #[test] + fn parse_add_endpoint_accepts_explicit_tcp_protocol() { + let plan = build_policy_update_plan( + &["database.example.com:5432::tcp".to_string()], + &[], + &[], + &[], + &[], + &[], + None, + ) + .expect("plan should build"); + + let PolicyMergeOp::AddRule { rule, .. } = &plan.preview_operations[0] else { + panic!("expected add-rule preview"); + }; + assert_eq!(rule.endpoints[0].protocol, "tcp"); + assert!(rule.endpoints[0].access.is_empty()); + } + #[test] fn parse_add_endpoint_enables_websocket_credential_rewrite() { let plan = build_policy_update_plan( diff --git a/crates/openshell-policy/src/ambiguity.rs b/crates/openshell-policy/src/ambiguity.rs index bf97c7e736..27295f31d5 100644 --- a/crates/openshell-policy/src/ambiguity.rs +++ b/crates/openshell-policy/src/ambiguity.rs @@ -190,7 +190,7 @@ fn connection_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) -> Vec< /// cannot compete with the single L7/connection-config endpoint selected for /// that request. fn endpoint_contributes_request_pipeline_metadata(endpoint: &NetworkEndpoint) -> bool { - !endpoint.protocol.is_empty() + (!endpoint.protocol.is_empty() && !endpoint.protocol.eq_ignore_ascii_case("tcp")) || !endpoint.allowed_ips.is_empty() || !endpoint.tls.is_empty() || endpoint.credential_binding.is_some() @@ -201,8 +201,8 @@ fn request_pipeline_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) - push_conflict( &mut conflicts, "protocol", - &left.protocol.to_ascii_lowercase(), - &right.protocol.to_ascii_lowercase(), + &normalized_request_protocol(&left.protocol), + &normalized_request_protocol(&right.protocol), ); push_conflict( &mut conflicts, @@ -300,6 +300,14 @@ fn request_pipeline_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) - conflicts } +fn normalized_request_protocol(protocol: &str) -> String { + if protocol.eq_ignore_ascii_case("tcp") { + String::new() + } else { + protocol.to_ascii_lowercase() + } +} + fn websocket_graphql_policy(endpoint: &NetworkEndpoint) -> bool { let allow_rule_has_graphql_fields = endpoint.rules.iter().any(|rule| { rule.allow.as_ref().is_some_and(|allow| { @@ -1027,4 +1035,15 @@ mod tests { 1 ); } + + #[test] + fn explicit_tcp_and_omitted_protocol_are_ambiguity_equivalent() { + let mut explicit_tcp = endpoint("api.example.com", 443); + explicit_tcp.protocol = "tcp".to_string(); + explicit_tcp.tls = "skip".to_string(); + let mut omitted = endpoint("api.example.com", 443); + omitted.tls = "skip".to_string(); + + assert!(find_endpoint_ambiguities(&policy_with(explicit_tcp, omitted)).is_empty()); + } } diff --git a/crates/openshell-policy/src/l7_validate.rs b/crates/openshell-policy/src/l7_validate.rs index 18580fd2c1..c849ac6604 100644 --- a/crates/openshell-policy/src/l7_validate.rs +++ b/crates/openshell-policy/src/l7_validate.rs @@ -41,6 +41,14 @@ impl L7Protocol { } } +/// Returns whether the authored protocol explicitly selects L4 TCP handling. +/// +/// `tcp` is intentionally not an [`L7Protocol`]. It is the explicit spelling +/// of the existing L4 behavior and does not enable request inspection. +pub fn is_explicit_tcp_protocol(protocol: &str) -> bool { + protocol.eq_ignore_ascii_case("tcp") +} + /// Fields extracted from an endpoint definition needed for L7 semantic /// validation. Both profile lint and the runtime validator construct this /// from their own data representation. @@ -78,17 +86,27 @@ pub fn validate_l7_endpoint_semantics(ep: &L7EndpointFields<'_>) -> Vec let mut errors = Vec::new(); let protocol = ep.protocol; let l7_protocol = L7Protocol::parse(protocol); + let explicit_tcp = is_explicit_tcp_protocol(protocol); let jsonrpc_family = l7_protocol.is_some_and(L7Protocol::is_jsonrpc_family); let is_mcp = matches!(l7_protocol, Some(L7Protocol::Mcp)); let is_jsonrpc = matches!(l7_protocol, Some(L7Protocol::JsonRpc)); // 1. Unknown protocol - if !protocol.is_empty() && l7_protocol.is_none() { + if !protocol.is_empty() && l7_protocol.is_none() && !explicit_tcp { errors.push(format!( - "unknown protocol '{protocol}' (expected rest, websocket, graphql, sql, json-rpc, or mcp)" + "unknown protocol '{protocol}' (expected tcp, rest, websocket, graphql, sql, json-rpc, or mcp)" )); } + // Explicit TCP is an L4 marker, not an inspection protocol. Reject L7 + // policy fields instead of silently ignoring them. + if explicit_tcp && (!ep.access.is_empty() || ep.has_rules || ep.has_deny_rules) { + errors.push( + "protocol tcp does not support access, rules, or deny_rules; remove those L7 fields" + .to_string(), + ); + } + // 2. rules + access mutually exclusive if ep.has_rules && !ep.access.is_empty() { errors.push("rules and access are mutually exclusive".to_string()); @@ -119,7 +137,7 @@ pub fn validate_l7_endpoint_semantics(ep: &L7EndpointFields<'_>) -> Vec // 5. Non-MCP, non-JSON-RPC protocol requires rules or access (JSON-RPC's // dedicated message is emitted by rule 4). - if !protocol.is_empty() && !is_mcp && !is_jsonrpc && !ep.has_rules && ep.access.is_empty() { + if l7_protocol.is_some() && !is_mcp && !is_jsonrpc && !ep.has_rules && ep.access.is_empty() { errors.push("protocol requires rules or access to define allowed traffic".to_string()); } @@ -141,7 +159,7 @@ pub fn validate_l7_endpoint_semantics(ep: &L7EndpointFields<'_>) -> Vec } // 8. deny_rules require protocol - if ep.has_deny_rules && protocol.is_empty() { + if ep.has_deny_rules && l7_protocol.is_none() { errors.push("deny_rules require protocol (L7 inspection must be enabled)".to_string()); } @@ -379,6 +397,41 @@ mod tests { assert!(errors.is_empty(), "expected no errors, got: {errors:?}"); } + #[test] + fn explicit_tcp_is_valid_without_l7_fields() { + let ep = L7EndpointFields { + protocol: "tcp", + access: "", + has_rules: false, + has_deny_rules: false, + rules_would_deny_all: false, + allow_all_known_mcp_methods: false, + }; + let errors = validate_l7_endpoint_semantics(&ep); + assert!(errors.is_empty(), "expected no errors, got: {errors:?}"); + assert!(is_explicit_tcp_protocol("TCP")); + assert_eq!(L7Protocol::parse("tcp"), None); + } + + #[test] + fn explicit_tcp_rejects_l7_fields() { + let ep = L7EndpointFields { + protocol: "tcp", + access: "full", + has_rules: false, + has_deny_rules: false, + rules_would_deny_all: false, + allow_all_known_mcp_methods: false, + }; + let errors = validate_l7_endpoint_semantics(&ep); + assert_eq!( + errors, + vec![ + "protocol tcp does not support access, rules, or deny_rules; remove those L7 fields" + ] + ); + } + #[test] fn l7_protocol_parse_known_variants() { assert_eq!(L7Protocol::parse("rest"), Some(L7Protocol::Rest)); diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index c96e39674a..63f7932aea 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -4351,6 +4351,37 @@ binaries: assert!(errors.is_empty(), "unexpected errors: {errors:?}"); } + #[test] + fn validate_accepts_explicit_tcp_without_l7_fields() { + let profile = parse_profile_yaml( + r" +id: valid-tcp +display_name: Valid TCP +credentials: + - name: api_key + env_vars: [API_KEY] + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_key] +endpoints: + - host: database.example.com + port: 5432 + protocol: tcp +binaries: + - /usr/bin/psql +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("profile.yaml".to_string(), profile)]); + let errors: Vec<_> = diagnostics + .iter() + .filter(|diagnostic| diagnostic.severity == "error") + .collect(); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + } + #[test] fn validate_rejects_unknown_protocol() { let profile = parse_profile_yaml( diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index 4b2977b936..020edb5d26 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -943,10 +943,13 @@ endpoint_path_matches_request(ep, request) if { path_matches(request.path, path) } -# An endpoint has extended config if it specifies L7 protocol, allowed_ips, -# or an explicit tls mode (e.g. tls: skip). +# An endpoint has extended config if it specifies an L7 protocol, allowed_ips, +# or an explicit tls mode (e.g. tls: skip). Explicit protocol "tcp" is the +# authored spelling of plain L4 behavior and does not select an L7 config. endpoint_has_extended_config(ep) if { - ep.protocol + protocol := object.get(ep, "protocol", "") + protocol != "" + lower(protocol) != "tcp" } endpoint_has_extended_config(ep) if { diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 33c14e9bd8..3427387a1c 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -2692,6 +2692,7 @@ network_policies: name: l4_only endpoints: - { host: l4only.example.com, port: 443 } + - { host: explicit-tcp.example.com, port: 443, protocol: tcp } binaries: - { path: /usr/bin/curl } filesystem_policy: @@ -4475,6 +4476,26 @@ network_policies: assert_eq!(l7.enforcement, crate::l7::EnforcementMode::Enforce); } + #[test] + fn explicit_tcp_authorizes_as_l4_without_endpoint_config() { + let engine = l7_engine(); + let input = NetworkInput { + host: "explicit-tcp.example.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + assert!(matches!( + engine.evaluate_network_action(&input).unwrap(), + NetworkAction::Allow { .. } + )); + assert!(engine.query_endpoint_config(&input).unwrap().is_none()); + assert!(engine.query_exact_declared_endpoint_host(&input).unwrap()); + } + #[test] fn l7_endpoint_config_preserves_mcp_strict_tool_names_opt_out() { let data = r#" diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 8dd334f022..ba6ef2a24b 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -163,7 +163,7 @@ Each endpoint defines a reachable destination and optional inspection rules. | `host` | string | Yes | Hostname or IP address. Supports a `*` wildcard inside the first DNS label only: `*.example.com`, `**.example.com`, and intra-label patterns like `*-aiplatform.googleapis.com` are accepted; bare `*`/`**`, TLD wildcards (`*.com`), and wildcards outside the first label are rejected at load time. | | `port` | integer | Yes | TCP port number. | | `path` | string | No | Optional HTTP path glob used to select between L7 endpoints that share the same host and port. Empty means all paths. Use this when REST and GraphQL live under the same host, such as `/repos/**` and `/graphql`. | -| `protocol` | string | No | Set to `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, `graphql` for GraphQL-over-HTTP operation inspection, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Omit for TCP passthrough. Provider-credentialed endpoints require an inspected protocol unless `allow_uninspected_credentials` is explicitly set. | +| `protocol` | string | No | Set to `tcp` for explicit L4 TCP passthrough, `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, `graphql` for GraphQL-over-HTTP operation inspection, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Omitting the field remains equivalent to `tcp`. Direct sandbox DNS and transparent TCP capture are not enabled yet. Provider-credentialed endpoints require an inspected protocol unless `allow_uninspected_credentials` is explicitly set. | | `tls` | string | No | TLS handling mode. The proxy auto-detects TLS by peeking the first bytes of each connection and terminates it for inspected HTTPS traffic, so this field is optional in most cases. Set to `skip` to disable auto-detection for edge cases such as client-certificate mTLS or non-standard protocols. Provider-credentialed endpoints reject `tls: skip` unless `allow_uninspected_credentials` is explicitly set. The values `terminate` and `passthrough` are deprecated and log a warning; they are still accepted for backward compatibility but have no effect on behavior. | | `enforcement` | string | No | `enforce` actively blocks disallowed requests. `audit` logs violations but allows traffic through. | | `access` | string | No | Access preset. One of `read-only`, `read-write`, or `full`. Mutually exclusive with `rules`. Not valid on `protocol: mcp` or `protocol: json-rpc`; MCP uses explicit rules unless `mcp.allow_all_known_mcp_methods: true` enables the endpoint method profile, and JSON-RPC always uses explicit rules. | @@ -191,6 +191,7 @@ Each endpoint defines a reachable destination and optional inspection rules. **Validation constraints:** - `access` and `rules` are mutually exclusive; setting both is rejected. +- `protocol: tcp` is L4-only and rejects the L7-only `access`, `rules`, and `deny_rules` fields. - When `protocol` is set, at least one of `access` or `rules` is required for `rest`, `websocket`, `graphql`, and `sql`. - `mcp` and `json-rpc` reject `access` presets; use explicit `rules`. - `json-rpc` requires explicit `rules` with `allow.method`. diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 83150c39d1..77d15560d0 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -69,7 +69,7 @@ When a hot reload changes rules, the supervisor publishes a new policy generatio | `filesystem_policy` | Static | Controls which directories the agent can access on disk. Paths are split into `read_only` and `read_write` lists. Any path not listed in either list is inaccessible. Set `include_workdir: true` to automatically add the agent's working directory to `read_write`. [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforces these restrictions at the kernel level. | | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). Refer to the [Policy Schema Reference](/reference/policy-schema#landlock) for the full behavior table. | | `process` | Static | Optionally overrides the OS-level identity for the agent process. Explicit values must be `sandbox` or numeric UID/GID values from `1` through `4294967294`; root and the invalid identity sentinel are rejected. Docker and Podman may use named identities through per-field OCI `USER` fallback; Kubernetes uses its platform-selected numeric identity. The agent also runs with seccomp filters that block dangerous system calls. | -| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints without `protocol` allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | +| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints without `protocol`, or with `protocol: tcp`, allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | | `network_middlewares` | Dynamic | Declares keyed HTTP and WebSocket middleware configs. After network and L7 policy admit a request or upgrade, OpenShell matches each config's host selectors independently and runs matching entries by their unique ascending `order` before credential injection. WebSocket-capable entries continue on complete client text messages. | ## Supervisor Middleware @@ -307,7 +307,7 @@ Each segment has a fixed meaning: | `host` | Yes | Destination hostname. | | `port` | Yes | Destination port, `1` through `65535`. | | `access` | No | Access preset for L7 endpoints: `read-only`, `read-write`, or `full`. Incremental updates expand presets into protocol-specific method/path rules for REST and WebSocket endpoints. | -| `protocol` | No | L7 inspection mode accepted by `openshell policy update`: `rest`, `websocket`, or `sql`. `sql` is audit-only and not a recommended workflow today. Full policy YAML also supports `graphql`, `mcp`, and `json-rpc`. | +| `protocol` | No | Endpoint mode accepted by `openshell policy update`: `tcp`, `rest`, `websocket`, or `sql`. `tcp` explicitly selects the same L4 passthrough used when this field is omitted. `sql` is audit-only and not a recommended workflow today. Full policy YAML also supports `graphql`, `mcp`, and `json-rpc`. | | `enforcement` | No | Enforcement mode for inspected traffic: `enforce` or `audit`. | | `options` | No | Comma-separated endpoint options. Use `websocket-credential-rewrite` with `protocol: websocket` or REST compatibility endpoints that perform a WebSocket upgrade. Use `request-body-credential-rewrite` only with `protocol: rest`. | @@ -606,7 +606,7 @@ Allow `pip install` and `uv pip install` to reach PyPI: - { path: /usr/local/bin/uv } ``` -Endpoints without `protocol` use TCP passthrough, where the proxy allows the stream without inspecting payloads. If the stream is HTTP and TLS is auto-terminated, the proxy can still rewrite configured credential placeholders and closes keep-alive passthrough tunnels on policy reload before forwarding another request. Provider-credentialed endpoints cannot use this shape unless `allow_uninspected_credentials: true` records the exception. WebSocket text-frame policy requires an explicit `protocol: websocket` endpoint. WebSocket payload credential rewrite can also be enabled on a `protocol: rest` compatibility endpoint with `websocket_credential_rewrite: true`. REST request body credential rewrite requires an inspected `protocol: rest` endpoint with `request_body_credential_rewrite: true`. +Endpoints without `protocol`, or with explicit `protocol: tcp`, use TCP passthrough, where the proxy allows the stream without inspecting payloads. In this release, `protocol: tcp` is a forward-compatible spelling of the existing behavior; it does not yet enable direct sandbox DNS or transparent TCP capture. If the stream is HTTP and TLS is auto-terminated, the proxy can still rewrite configured credential placeholders and closes keep-alive passthrough tunnels on policy reload before forwarding another request. Provider-credentialed endpoints cannot use this shape unless `allow_uninspected_credentials: true` records the exception. WebSocket text-frame policy requires an explicit `protocol: websocket` endpoint. WebSocket payload credential rewrite can also be enabled on a `protocol: rest` compatibility endpoint with `websocket_credential_rewrite: true`. REST request body credential rewrite requires an inspected `protocol: rest` endpoint with `request_body_credential_rewrite: true`. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 95df265ff2..51139ba461 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -109,7 +109,8 @@ message NetworkEndpoint { // Single port (backwards compat). Use `ports` for multiple ports. // Mutually exclusive with `ports` — if both are set, `ports` takes precedence. uint32 port = 2; - // Application protocol for L7 inspection: "rest", "websocket", "graphql", "sql", or "" (L4-only). + // Endpoint protocol. "tcp" and "" select L4-only handling; "rest", + // "websocket", "graphql", "sql", "json-rpc", and "mcp" select L7 inspection. string protocol = 3; // TLS handling: "terminate" or "passthrough" (default). string tls = 4; From 0c80f75f9410a8d6a7c2e2ff209559214c7c8f6b Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:49:05 -0700 Subject: [PATCH 02/17] refactor(network): snapshot authoritative egress decisions Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- architecture/sandbox.md | 12 +- .../data/sandbox-policy.rego | 32 +++ .../openshell-supervisor-network/src/opa.rs | 102 +++++-- .../openshell-supervisor-network/src/proxy.rs | 272 +++++++----------- .../src/proxy/egress.rs | 64 ++++- .../src/proxy/relay.rs | 37 ++- .../src/proxy/tests/compatibility.rs | 52 +--- 7 files changed, 292 insertions(+), 279 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 698f88a80a..210a34880a 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -63,10 +63,10 @@ socket inode. CONNECT and absolute-form forward HTTP are explicit-proxy adapters over the same egress pipeline. Each adapter normalizes its request into an egress intent, and -the shared authorization result carries the process evidence used by destination -validation and relay selection. During the compatibility migration, endpoint -state is hydrated at the adapters' existing policy query points; it is not yet -one atomic, generation-consistent authorization result. Destination validation +the shared authorization result carries the process evidence and endpoint +metadata used by destination validation and relay selection. Network action, +matched policy, endpoint configuration, and exact-host authorization are +evaluated as one atomic snapshot from one policy generation. Destination validation returns an unopened connector so adapters retain their existing response and upstream-dial timing. CONNECT prepares a generation-pinned relay context before entering shared TLS-terminated or plaintext HTTP relays; non-HTTP traffic uses @@ -74,6 +74,10 @@ the shared raw byte relay after the existing adapter gates. Forward HTTP retains its guarded single-request relay while sharing authorization, request context, policy-pinning, and destination boundaries. Adapter-specific response and OCSF event shapes remain at the protocol boundary. +Policy authors may use `protocol: tcp` as an explicit spelling of the existing +L4 passthrough behavior. Omitting `protocol` remains equivalent. The egress +intent reserves a transparent TCP adapter and a policy-DNS-pinned address, but +DNS serving and transparent TCP capture are not active yet. Provider credential placeholders are resolved through the live provider state for each HTTP request, after destination and L7 policy admission. A static diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index 020edb5d26..c1ddf0c45f 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -214,6 +214,38 @@ network_action := "allow" if { network_policy_for_request } +# --- Authoritative egress authorization snapshot --- +# +# Rust evaluates this rule once per admitted connection. Keeping the action, +# matched policy, endpoint metadata, and exact-host signal in one result makes +# them an atomic view of one policy generation. + +default _egress_matched_policy := "" + +_egress_matched_policy := matched_network_policy if { + matched_network_policy +} + +default _egress_deny_reason := "" + +_egress_deny_reason := deny_reason if { + network_action == "deny" +} + +default _egress_exact_declared_endpoint_host := false + +_egress_exact_declared_endpoint_host := true if { + exact_declared_endpoint_host +} + +egress_authorization := { + "action": network_action, + "deny_reason": _egress_deny_reason, + "matched_policy": _egress_matched_policy, + "endpoint_configs": _matching_endpoint_configs, + "exact_declared_endpoint_host": _egress_exact_declared_endpoint_host, +} + # =========================================================================== # L7 request evaluation (queried per-request within a tunnel) # =========================================================================== diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 3427387a1c..3c620a8a86 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -50,6 +50,15 @@ pub enum NetworkAction { Deny { reason: String }, } +/// Atomic policy result used to authorize and materialize one egress request. +#[derive(Debug, Clone)] +pub struct EgressAuthorization { + pub action: NetworkAction, + pub endpoint_configs: Vec, + pub exact_declared_endpoint_host: bool, + pub generation: u64, +} + /// Input for a network access policy evaluation. pub struct NetworkInput { pub host: String, @@ -269,15 +278,6 @@ impl OpaEngine { generation } - #[cfg(test)] - pub(crate) fn poison_lock_for_test(&self) { - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _guard = self.engine.lock().expect("test engine lock"); - panic!("poison OPA engine lock for compatibility fallback test"); - })); - assert!(self.engine.is_poisoned()); - } - /// Load policy from a `.rego` rules file and data from a YAML file. /// /// Preprocesses the YAML data to expand access presets and validate L7 config. @@ -517,6 +517,13 @@ impl OpaEngine { &self, input: &NetworkInput, ) -> Result<(NetworkAction, u64)> { + let authorization = self.authorize_egress(input)?; + Ok((authorization.action, authorization.generation)) + } + + /// Authorize egress and return all connection metadata from one Rego result + /// evaluated against one policy generation. + pub fn authorize_egress(&self, input: &NetworkInput) -> Result { #[cfg(test)] record_test_opa_query(); @@ -534,34 +541,44 @@ impl OpaEngine { .map_err(|_| miette::miette!("OPA fail-closed state lock poisoned"))? .clone(); if let Some(reason) = fail_closed_reason { - return Ok((NetworkAction::Deny { reason }, generation)); + return Ok(EgressAuthorization { + action: NetworkAction::Deny { reason }, + endpoint_configs: Vec::new(), + exact_declared_endpoint_host: false, + generation, + }); } set_regorus_input(&mut engine, input_json)?; - let action_val = engine - .eval_rule("data.openshell.sandbox.network_action".into()) + let result = engine + .eval_rule("data.openshell.sandbox.egress_authorization".into()) .map_err(|e| miette::miette!("{e}"))?; - let action_str = value_to_string(&action_val); + let action_str = get_str(&result, "action").unwrap_or_default(); + let matched_policy = get_str(&result, "matched_policy").filter(|name| !name.is_empty()); + let endpoint_configs = match get_field(&result, "endpoint_configs") { + Some(regorus::Value::Array(values)) => values.to_vec(), + _ => Vec::new(), + }; + let exact_declared_endpoint_host = + get_bool(&result, "exact_declared_endpoint_host").unwrap_or(false); - let matched = engine - .eval_rule("data.openshell.sandbox.matched_network_policy".into()) - .map_err(|e| miette::miette!("{e}"))?; - let matched_policy = if matched == regorus::Value::Undefined { - None + let action = if action_str == "allow" { + NetworkAction::Allow { matched_policy } } else { - Some(value_to_string(&matched)) + NetworkAction::Deny { + reason: get_str(&result, "deny_reason") + .filter(|reason| !reason.is_empty()) + .unwrap_or_else(|| "network connections not allowed by policy".to_string()), + } }; - if action_str == "allow" { - Ok((NetworkAction::Allow { matched_policy }, generation)) - } else { - let reason_val = engine - .eval_rule("data.openshell.sandbox.deny_reason".into()) - .map_err(|e| miette::miette!("{e}"))?; - let reason = value_to_string(&reason_val); - Ok((NetworkAction::Deny { reason }, generation)) - } + Ok(EgressAuthorization { + action, + endpoint_configs, + exact_declared_endpoint_host, + generation, + }) } /// Reload policy and data from strings (data is YAML). @@ -5823,6 +5840,35 @@ process: assert_eq!(decision.matched_policy.as_deref(), Some("internal_api")); } + #[test] + fn egress_authorization_returns_one_generation_consistent_snapshot() { + let engine = allowed_ips_engine(); + let input = NetworkInput { + host: "my-service.corp.net".into(), + port: 8080, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let authorization = engine.authorize_egress(&input).unwrap(); + + assert_eq!(authorization.generation, engine.current_generation()); + assert_eq!( + authorization.action, + NetworkAction::Allow { + matched_policy: Some("internal_api".to_string()) + } + ); + assert!(authorization.exact_declared_endpoint_host); + assert_eq!(authorization.endpoint_configs.len(), 1); + assert_eq!( + get_str_array(&authorization.endpoint_configs[0], "allowed_ips"), + vec!["10.0.5.0/24"] + ); + } + #[test] fn allowed_ips_mode2_returns_allowed_ips() { let engine = allowed_ips_engine(); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index daaccd0d6e..1bd6a6e001 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1385,7 +1385,7 @@ async fn handle_tcp_connection( } let connect_generation_guard = - match relay::pin_policy_generation(&opa_engine, decision.l4_policy_generation) { + match relay::pin_policy_generation(&opa_engine, decision.policy_generation) { Ok(guard) => guard, Err(error) => { reject_stale_connect_policy( @@ -1406,13 +1406,13 @@ async fn handle_tcp_connection( // allowed_ips validation below — so an internal-address CONNECT still gets // the SSRF 403 and telemetry in degraded state — but before the upstream // connect and before `200 Connection Established`. - hydrate_tls_mode(&opa_engine, &mut decision); + hydrate_tls_mode(&mut decision); let effective_tls_skip = decision.endpoint.tls_mode == crate::l7::TlsMode::Skip; let credential_guard = query_endpoint_credential_guard(&opa_engine, &decision, &host_lc, port)?; let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); - match hydrate_destination_plan(&opa_engine, &mut decision, *trusted_host_gateway) { + match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { Ok(()) => {} Err(denial) => { deny_connect_destination( @@ -1558,9 +1558,8 @@ async fn handle_tcp_connection( } // CONNECT must use one policy generation from authorization through route - // hydration and relay startup. A later L7 lookup must never make a stale - // L4 allow appear current. - hydrate_l7_route(&opa_engine, &mut decision); + // materialization and relay startup. + hydrate_l7_route(&mut decision); let l7_route = decision.endpoint.l7_route.as_ref(); if let Err(error) = relay::validate_route_generation(l7_route, connect_generation_guard.captured_generation()) @@ -2100,7 +2099,7 @@ fn authorize_egress_intent( EgressDecision { intent: intent.clone(), action: NetworkAction::Deny { reason }, - l4_policy_generation: engine.current_generation(), + policy_generation: engine.current_generation(), identity, endpoint: EndpointDecision::default(), binary, @@ -2166,13 +2165,13 @@ fn authorize_egress_intent( cmdline_paths: cmdline_paths.clone(), }; - let result = match engine.evaluate_network_action_with_generation(&input) { - Ok((action, generation)) => EgressDecision { + let result = match engine.authorize_egress(&input) { + Ok(authorization) => EgressDecision { intent: intent.clone(), - action, - l4_policy_generation: generation, + action: authorization.action.clone(), + policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, - endpoint: EndpointDecision::default(), + endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(bin_path), binary_pid: Some(binary_pid), ancestors, @@ -2221,15 +2220,15 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres cmdline_paths: vec![], }; - match engine.evaluate_network_action_with_generation(&input) { - Ok((action, generation)) => EgressDecision { + match engine.authorize_egress(&input) { + Ok(authorization) => EgressDecision { intent, - action, - l4_policy_generation: generation, + action: authorization.action.clone(), + policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::EndpointOnlyMode, ), - endpoint: EndpointDecision::default(), + endpoint: EndpointDecision::from_authorization(&authorization), binary: None, binary_pid: None, ancestors: vec![], @@ -2240,7 +2239,7 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres action: NetworkAction::Deny { reason: format!("policy evaluation error: {e}"), }, - l4_policy_generation: engine.current_generation(), + policy_generation: engine.current_generation(), identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::EndpointOnlyMode, ), @@ -2271,7 +2270,7 @@ fn authorize_egress_intent( action: NetworkAction::Deny { reason: "identity binding unavailable on this platform".into(), }, - l4_policy_generation: engine.current_generation(), + policy_generation: engine.current_generation(), identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::UnsupportedPlatform, ), @@ -2923,27 +2922,25 @@ async fn reject_stale_connect_policy( /// /// Returns `Some(L7EndpointConfig)` if the matched endpoint has L7 config (protocol field), /// `None` for L4-only endpoints. -fn hydrate_l7_route(engine: &OpaEngine, decision: &mut EgressDecision) { +fn hydrate_l7_route(decision: &mut EgressDecision) { let host = decision.intent.destination.host.clone(); let port = decision.intent.destination.port; - decision.endpoint.l7_route = query_l7_route_snapshot(engine, decision, &host, port); + decision.endpoint.l7_route = query_l7_route_snapshot(decision, &host, port); } -fn hydrate_tls_mode(engine: &OpaEngine, decision: &mut EgressDecision) { +fn hydrate_tls_mode(decision: &mut EgressDecision) { let host = decision.intent.destination.host.clone(); let port = decision.intent.destination.port; - decision.endpoint.tls_mode = query_tls_mode(engine, decision, &host, port); + decision.endpoint.tls_mode = query_tls_mode(decision, &host, port); } fn hydrate_destination_plan( - engine: &OpaEngine, decision: &mut EgressDecision, trusted_host_gateway: Option, ) -> std::result::Result<(), DestinationDenial> { let host = decision.intent.destination.host.clone(); - let port = decision.intent.destination.port; - let raw_allowed_ips = query_allowed_ips(engine, decision, &host, port); - let exact_declared_host = query_exact_declared_endpoint_host(engine, decision, &host, port); + let raw_allowed_ips = query_allowed_ips(decision); + let exact_declared_host = decision.endpoint.exact_declared_host; let plan = build_validation_plan( &host, &host.to_ascii_lowercase(), @@ -2956,7 +2953,6 @@ fn hydrate_destination_plan( } fn query_l7_route_snapshot( - engine: &OpaEngine, decision: &EgressDecision, host: &str, port: u16, @@ -2970,46 +2966,27 @@ fn query_l7_route_snapshot( return None; } - let input = crate::opa::NetworkInput { - host: host.to_string(), - port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), - cmdline_paths: decision.cmdline_paths.clone(), - }; - - match engine.query_endpoint_configs_with_generation(&input) { - Ok((vals, generation)) => { - let configs: Vec<_> = vals - .into_iter() - .filter_map(|val| crate::l7::parse_l7_config(&val)) - .map(|config| L7ConfigSnapshot { config }) - .collect(); - debug!( - host, - port, - generation, - config_count = configs.len(), - "Forward proxy L7 route lookup complete" - ); - Some(L7RouteSnapshot { - configs, - l7_policy_generation: generation, - }) - } - Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(host, port)) - .message(format!("Failed to query L7 endpoint config: {e}")) - .build(); - ocsf_emit!(event); - None - } + let configs: Vec<_> = decision + .endpoint + .policy_configs + .iter() + .filter_map(crate::l7::parse_l7_config) + .map(|config| L7ConfigSnapshot { config }) + .collect(); + if configs.is_empty() { + return None; } + debug!( + host, + port, + generation = decision.policy_generation, + config_count = configs.len(), + "Egress L7 route materialized from authorization snapshot" + ); + Some(L7RouteSnapshot { + configs, + l7_policy_generation: decision.policy_generation, + }) } fn select_l7_config_for_path<'a>( @@ -3025,12 +3002,7 @@ fn select_l7_config_for_path<'a>( /// Query the TLS mode for an endpoint, independent of L7 config. /// /// This extracts `tls: skip` from the endpoint even when no `protocol` is set. -fn query_tls_mode( - engine: &OpaEngine, - decision: &EgressDecision, - host: &str, - port: u16, -) -> crate::l7::TlsMode { +fn query_tls_mode(decision: &EgressDecision, _host: &str, _port: u16) -> crate::l7::TlsMode { let has_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.is_some(), NetworkAction::Deny { .. } => false, @@ -3039,19 +3011,11 @@ fn query_tls_mode( return crate::l7::TlsMode::Auto; } - let input = crate::opa::NetworkInput { - host: host.to_string(), - port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), - cmdline_paths: decision.cmdline_paths.clone(), - }; - - match engine.query_endpoint_config(&input) { - Ok(Some(val)) => crate::l7::parse_tls_mode(&val), - _ => crate::l7::TlsMode::Auto, - } + decision + .endpoint + .policy_configs + .first() + .map_or(crate::l7::TlsMode::Auto, crate::l7::parse_tls_mode) } fn query_endpoint_credential_guard( @@ -3674,13 +3638,8 @@ fn parse_allowed_ips(raw: &[String]) -> std::result::Result, S } } -/// Query `allowed_ips` from the matched endpoint config for a CONNECT decision. -fn query_allowed_ips( - engine: &OpaEngine, - decision: &EgressDecision, - host: &str, - port: u16, -) -> Vec { +/// Read `allowed_ips` from the endpoint configs captured during authorization. +fn query_allowed_ips(decision: &EgressDecision) -> Vec { // Only query if action is Allow with a matched policy let has_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.is_some(), @@ -3690,71 +3649,29 @@ fn query_allowed_ips( return vec![]; } - let input = crate::opa::NetworkInput { - host: host.to_string(), - port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), - cmdline_paths: decision.cmdline_paths.clone(), - }; - - match engine.query_allowed_ips(&input) { - Ok(ips) => ips, - Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(host, port)) - .message(format!( - "Failed to query allowed_ips from endpoint config: {e}" - )) - .build(); - ocsf_emit!(event); - vec![] - } - } + decision + .endpoint + .policy_configs + .first() + .map(|config| endpoint_config_string_array(config, "allowed_ips")) + .unwrap_or_default() } -/// Query whether the matched endpoint was declared as this exact hostname. -fn query_exact_declared_endpoint_host( - engine: &OpaEngine, - decision: &EgressDecision, - host: &str, - port: u16, -) -> bool { - let has_policy = match &decision.action { - NetworkAction::Allow { matched_policy } => matched_policy.is_some(), - NetworkAction::Deny { .. } => false, +fn endpoint_config_string_array(config: ®orus::Value, key: &str) -> Vec { + let regorus::Value::Object(fields) = config else { + return Vec::new(); }; - if !has_policy { - return false; - } - - let input = crate::opa::NetworkInput { - host: host.to_string(), - port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), - cmdline_paths: decision.cmdline_paths.clone(), + let key = regorus::Value::String(key.into()); + let Some(regorus::Value::Array(values)) = fields.get(&key) else { + return Vec::new(); }; - - match engine.query_exact_declared_endpoint_host(&input) { - Ok(is_exact_declared) => is_exact_declared, - Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(host, port)) - .message(format!("Failed to query exact declared endpoint host: {e}")) - .build(); - ocsf_emit!(event); - false - } - } + values + .iter() + .filter_map(|value| match value { + regorus::Value::String(value) => Some(value.to_string()), + _ => None, + }) + .collect() } /// Canonicalize the request-target for inference pattern detection. @@ -4533,7 +4450,7 @@ async fn handle_forward_proxy( binary = %binary_str, binary_pid = %pid_str, matched_policy = %policy_str, - l4_policy_generation = decision.l4_policy_generation, + policy_generation = decision.policy_generation, current_generation = opa_engine.current_generation(), action = ?decision.action, "Forward proxy L4 policy decision" @@ -4541,14 +4458,14 @@ async fn handle_forward_proxy( let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); let forward_generation_guard = match relay::pin_policy_generation( &opa_engine, - decision.l4_policy_generation, + decision.policy_generation, ) { Ok(guard) => guard, Err(e) => { warn!( host = %host_lc, port, - l4_policy_generation = decision.l4_policy_generation, + policy_generation = decision.policy_generation, current_generation = opa_engine.current_generation(), error = %e, "Forward proxy rejected request because policy generation changed after L4 decision" @@ -4589,7 +4506,7 @@ async fn handle_forward_proxy( // connection, so a single evaluation suffices. The shared HTTP relay // strips hop-by-hop `Connection` headers and drops the upstream after // the response instead of asking the upstream to close it. - hydrate_l7_route(&opa_engine, &mut decision); + hydrate_l7_route(&mut decision); let canonicalize_options = crate::l7::path::CanonicalizeOptions { allow_encoded_slash: decision.endpoint.l7_route.as_ref().is_some_and(|route| { route @@ -4658,7 +4575,7 @@ async fn handle_forward_proxy( warn!( host = %host_lc, port, - l4_policy_generation = decision.l4_policy_generation, + policy_generation = decision.policy_generation, l4_guard_generation = forward_generation_guard.captured_generation(), l7_policy_generation = route.l7_policy_generation, current_generation = opa_engine.current_generation(), @@ -5091,7 +5008,7 @@ async fn handle_forward_proxy( // - Otherwise: reject internal IPs, allow public IPs through. // When the policy host is already a literal IP address, treat it as // implicitly allowed — the user explicitly declared the destination. - match hydrate_destination_plan(&opa_engine, &mut decision, *trusted_host_gateway) { + match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { Ok(()) => {} Err(denial) => { deny_forward_destination( @@ -7358,21 +7275,28 @@ network_policies: ) { let policy = include_str!("../data/sandbox-policy.rego"); let engine = OpaEngine::from_strings(policy, data).unwrap(); + let authorization = engine + .authorize_egress(&crate::opa::NetworkInput { + host: host.to_string(), + port, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: String::new(), + ancestors: vec![], + cmdline_paths: vec![], + }) + .expect("authorize egress"); let decision = EgressDecision { intent: EgressIntent::forward_http(host.to_string(), port), - action: NetworkAction::Allow { - matched_policy: Some(policy_name.to_string()), - }, - l4_policy_generation: engine.current_generation(), + action: authorization.action.clone(), + policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, - endpoint: EndpointDecision::default(), + endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(PathBuf::from("/usr/bin/node")), binary_pid: None, ancestors: vec![], cmdline_paths: vec![], }; - let route = - query_l7_route_snapshot(&engine, &decision, host, port).expect("L7 route should match"); + let route = query_l7_route_snapshot(&decision, host, port).expect("L7 route should match"); let config = select_l7_config_for_path(&route.configs, path) .expect("path-specific L7 config should match") .config @@ -11553,10 +11477,8 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], }; - let (action, generation) = engine - .evaluate_network_action_with_generation(&input) - .expect("evaluate"); - match &action { + let authorization = engine.authorize_egress(&input).expect("evaluate"); + match &authorization.action { NetworkAction::Allow { matched_policy } => { assert!(matched_policy.is_some(), "allow must carry the policy name"); } @@ -11566,16 +11488,16 @@ network_policies: } let decision = EgressDecision { intent: EgressIntent::connect("203.0.113.10".to_string(), 443), - action, - l4_policy_generation: generation, + action: authorization.action.clone(), + policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, - endpoint: EndpointDecision::default(), + endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(input.binary_path), binary_pid: Some(1), ancestors: vec![], cmdline_paths: vec![], }; - query_tls_mode(&engine, &decision, "203.0.113.10", 443) + query_tls_mode(&decision, "203.0.113.10", 443) }; assert_eq!( diff --git a/crates/openshell-supervisor-network/src/proxy/egress.rs b/crates/openshell-supervisor-network/src/proxy/egress.rs index f059175cfa..62ec473842 100644 --- a/crates/openshell-supervisor-network/src/proxy/egress.rs +++ b/crates/openshell-supervisor-network/src/proxy/egress.rs @@ -11,6 +11,7 @@ use super::destination::DestinationValidationPlan; use crate::opa::NetworkAction; +use std::net::IpAddr; use std::path::PathBuf; #[derive(Debug, Clone)] @@ -27,14 +28,18 @@ pub(super) struct L7RouteSnapshot { /// Endpoint metadata materialized for an allowed egress decision. /// -/// The migration hydrates these fields at the same points the legacy handlers -/// queried them so policy-reload and upstream-connect timing remain unchanged. +/// Adapters materialize these fields from the authoritative policy snapshot at +/// their existing timing boundaries so upstream-connect behavior stays stable. #[derive(Debug, Clone)] pub(super) struct EndpointDecision { pub(super) tls_mode: crate::l7::TlsMode, pub(super) l7_route: Option, - /// Destination authorization selected at the legacy hydration point. + /// Destination authorization selected from the captured endpoint metadata. pub(super) destination: Option, + /// Raw endpoint configs returned with the authoritative egress decision. + pub(super) policy_configs: Vec, + /// Whether policy matched the requested hostname exactly (not by glob). + pub(super) exact_declared_host: bool, } impl Default for EndpointDecision { @@ -43,6 +48,18 @@ impl Default for EndpointDecision { tls_mode: crate::l7::TlsMode::Auto, l7_route: None, destination: None, + policy_configs: Vec::new(), + exact_declared_host: false, + } + } +} + +impl EndpointDecision { + pub(super) fn from_authorization(authorization: &crate::opa::EgressAuthorization) -> Self { + Self { + policy_configs: authorization.endpoint_configs.clone(), + exact_declared_host: authorization.exact_declared_endpoint_host, + ..Self::default() } } } @@ -52,6 +69,9 @@ impl Default for EndpointDecision { pub(super) enum EgressTransport { Connect, ForwardHttp, + /// Future transparent TCP adapter fed by the policy DNS registry. + #[allow(dead_code, reason = "constructed when transparent TCP adapter lands")] + TransparentTcp, } /// Destination requested by an explicit proxy adapter. @@ -59,6 +79,9 @@ pub(super) enum EgressTransport { pub(super) struct RequestedDestination { pub(super) host: String, pub(super) port: u16, + /// Address selected from a policy-authorized DNS answer. Explicit proxy + /// adapters leave this empty; the transparent adapter will require it. + pub(super) pinned_ip: Option, } /// Transport-neutral description of an external egress request. @@ -77,10 +100,26 @@ impl EgressIntent { Self::new(EgressTransport::ForwardHttp, host, port) } + #[cfg(test)] + pub(super) fn transparent_tcp(host: String, port: u16, pinned_ip: IpAddr) -> Self { + Self { + transport: EgressTransport::TransparentTcp, + destination: RequestedDestination { + host, + port, + pinned_ip: Some(pinned_ip), + }, + } + } + fn new(transport: EgressTransport, host: String, port: u16) -> Self { Self { transport, - destination: RequestedDestination { host, port }, + destination: RequestedDestination { + host, + port, + pinned_ip: None, + }, } } } @@ -105,15 +144,13 @@ pub(super) enum ProcessIdentityEvidence { /// Result of authorizing a normalized egress intent. /// -/// The identity fields intentionally mirror the former CONNECT-specific -/// decision during the compatibility migration. Endpoint configuration is -/// hydrated at the legacy query points without changing lookup precedence or -/// failure defaults. +/// The policy action and endpoint metadata are one atomic snapshot. Adapters +/// may parse that metadata later, but they never query a second generation. pub(super) struct EgressDecision { pub(super) intent: EgressIntent, pub(super) action: NetworkAction, - /// Policy generation used for the L4 network decision. - pub(super) l4_policy_generation: u64, + /// Policy generation used for the complete authorization snapshot. + pub(super) policy_generation: u64, /// Whether process identity evidence was available to policy evaluation. pub(super) identity: ProcessIdentityEvidence, /// Endpoint behavior hydrated for destination validation and relays. @@ -140,7 +177,14 @@ mod tests { assert_eq!(connect.transport, EgressTransport::Connect); assert_eq!(connect.destination.host, "api.example.com"); assert_eq!(connect.destination.port, 443); + assert_eq!(connect.destination.pinned_ip, None); assert_eq!(forward.transport, EgressTransport::ForwardHttp); assert_eq!(forward.destination.port, 80); + + let pinned_ip = "203.0.113.8".parse().unwrap(); + let transparent = + EgressIntent::transparent_tcp("db.example.com".to_string(), 5432, pinned_ip); + assert_eq!(transparent.transport, EgressTransport::TransparentTcp); + assert_eq!(transparent.destination.pinned_ip, Some(pinned_ip)); } } diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 1ec122d11f..70c3b8c52e 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -134,7 +134,7 @@ pub(super) fn prepare_http_relay<'a>( decision: &EgressDecision, request: &'a L7EvalContext, ) -> Option> { - if let Err(error) = validate_route_generation(route, decision.l4_policy_generation) { + if let Err(error) = validate_route_generation(route, decision.policy_generation) { emit_l7_tunnel_close_after_policy_change( &decision.intent.destination.host, decision.intent.destination.port, @@ -144,7 +144,7 @@ pub(super) fn prepare_http_relay<'a>( } let policy = if let Some(route) = route.filter(|route| !route.configs.is_empty()) { - let evaluator = match pin_l7_evaluator(opa_engine, decision.l4_policy_generation) { + let evaluator = match pin_l7_evaluator(opa_engine, decision.policy_generation) { Ok(evaluator) => evaluator, Err(error) => { emit_l7_tunnel_close_after_policy_change( @@ -165,18 +165,17 @@ pub(super) fn prepare_http_relay<'a>( evaluator: Box::new(evaluator), } } else { - let generation_guard = - match pin_policy_generation(opa_engine, decision.l4_policy_generation) { - Ok(guard) => guard, - Err(error) => { - emit_l7_tunnel_close_after_policy_change( - &decision.intent.destination.host, - decision.intent.destination.port, - error, - ); - return None; - } - }; + let generation_guard = match pin_policy_generation(opa_engine, decision.policy_generation) { + Ok(guard) => guard, + Err(error) => { + emit_l7_tunnel_close_after_policy_change( + &decision.intent.destination.host, + decision.intent.destination.port, + error, + ); + return None; + } + }; PreparedHttpPolicy::Passthrough { generation_guard } }; @@ -195,7 +194,7 @@ pub(super) fn prepare_raw_relay( opa_engine: &OpaEngine, decision: &EgressDecision, ) -> Option { - if let Err(error) = validate_route_generation(route, decision.l4_policy_generation) { + if let Err(error) = validate_route_generation(route, decision.policy_generation) { emit_l7_tunnel_close_after_policy_change( &decision.intent.destination.host, decision.intent.destination.port, @@ -204,7 +203,7 @@ pub(super) fn prepare_raw_relay( return None; } - match pin_policy_generation(opa_engine, decision.l4_policy_generation) { + match pin_policy_generation(opa_engine, decision.policy_generation) { Ok(guard) => Some(guard), Err(error) => { emit_l7_tunnel_close_after_policy_change( @@ -324,13 +323,13 @@ mod tests { const POLICY_REGO: &str = include_str!("../../data/sandbox-policy.rego"); const EMPTY_POLICY_DATA: &str = "network_policies: {}\n"; - fn decision(l4_policy_generation: u64) -> EgressDecision { + fn decision(policy_generation: u64) -> EgressDecision { EgressDecision { intent: EgressIntent::connect("example.com".to_string(), 80), action: NetworkAction::Allow { matched_policy: Some("test".to_string()), }, - l4_policy_generation, + policy_generation, identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::default(), binary: None, @@ -373,7 +372,7 @@ mod tests { assert_eq!( generation_guard.captured_generation(), - decision.l4_policy_generation + decision.policy_generation ); } diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index b7abb31268..5e863445fa 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -13,7 +13,7 @@ fn allowed_decision(intent: EgressIntent) -> EgressDecision { action: NetworkAction::Allow { matched_policy: Some("proxy_compatibility".to_string()), }, - l4_policy_generation: 0, + policy_generation: 0, identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::default(), binary: Some(PathBuf::from("/usr/bin/curl")), @@ -279,65 +279,31 @@ fn representative_adapter_allows_preserve_ocsf_fields() { ); } -fn poisoned_engine() -> OpaEngine { - let engine = OpaEngine::from_strings( - include_str!("../../../data/sandbox-policy.rego"), - r#" -network_policies: - proxy_compatibility: - name: proxy_compatibility - endpoints: - - host: target.example - port: 443 - protocol: rest - enforcement: enforce - tls: skip - allowed_ips: ["10.0.0.0/8"] - rules: - - allow: { method: GET, path: "/**" } - binaries: - - path: /usr/bin/curl -"#, - ) - .unwrap(); - engine.poison_lock_for_test(); - engine -} - #[test] -fn l7_query_failure_preserves_l4_only_fallback() { - let engine = poisoned_engine(); +fn missing_authorized_l7_metadata_preserves_l4_only_fallback() { let decision = allowed_decision(EgressIntent::connect("target.example".to_string(), 443)); - assert!(query_l7_route_snapshot(&engine, &decision, "target.example", 443).is_none()); + assert!(query_l7_route_snapshot(&decision, "target.example", 443).is_none()); } #[test] -fn tls_query_failure_preserves_auto_fallback() { - let engine = poisoned_engine(); +fn missing_authorized_tls_metadata_preserves_auto_fallback() { let decision = allowed_decision(EgressIntent::connect("target.example".to_string(), 443)); assert_eq!( - query_tls_mode(&engine, &decision, "target.example", 443), + query_tls_mode(&decision, "target.example", 443), crate::l7::TlsMode::Auto ); } #[test] -fn allowed_ips_query_failure_preserves_empty_fallback() { - let engine = poisoned_engine(); +fn missing_authorized_allowed_ips_preserves_empty_fallback() { let decision = allowed_decision(EgressIntent::connect("target.example".to_string(), 443)); - assert!(query_allowed_ips(&engine, &decision, "target.example", 443).is_empty()); + assert!(query_allowed_ips(&decision).is_empty()); } #[test] -fn exact_host_query_failure_preserves_false_fallback() { - let engine = poisoned_engine(); +fn missing_authorized_exact_host_preserves_false_fallback() { let decision = allowed_decision(EgressIntent::connect("target.example".to_string(), 443)); - assert!(!query_exact_declared_endpoint_host( - &engine, - &decision, - "target.example", - 443 - )); + assert!(!decision.endpoint.exact_declared_host); } #[test] From 4d67f0680704466f6ceebaf42329fbe96f8d13cc Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:28:56 -0700 Subject: [PATCH 03/17] docs(policy): document explicit tcp protocol Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .agents/skills/generate-sandbox-policy/SKILL.md | 12 ++++++++---- .agents/skills/openshell-cli/cli-reference.md | 3 +++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.agents/skills/generate-sandbox-policy/SKILL.md b/.agents/skills/generate-sandbox-policy/SKILL.md index 20e562064c..a4bca4c5d1 100644 --- a/.agents/skills/generate-sandbox-policy/SKILL.md +++ b/.agents/skills/generate-sandbox-policy/SKILL.md @@ -42,7 +42,10 @@ For this tier, default to: - `access: read-only` when the user says "read", "browse", "view", "query", "fetch" - `access: read-write` when the user says "read-write", "create", "update" (but not "delete") - `access: full` when the user says "full access", "everything", "unrestricted" -- L4-only (no `protocol`) when the user says "just allow it", "pass through", "no inspection" +- L4-only (omit `protocol`, or use explicit `protocol: tcp`) when the user says + "just allow it", "pass through", "no inspection". Prefer omission unless the + user wants the transport intent stated explicitly; both currently have the + same host/port enforcement behavior. ### Moderate Tier (host + partial path knowledge) @@ -188,7 +191,7 @@ Follow this decision tree based on the detail tier and user intent: ``` Is L7 inspection needed? ├─ No (user wants pass-through / "just allow it") -│ └─ Generate L4-only policy (no protocol, no tls, no rules/access) +│ └─ Generate L4-only policy (no protocol, or protocol: tcp; no tls/rules/access) │ └─ Yes (user wants method/path control) │ @@ -376,7 +379,8 @@ Before presenting the policy to the user, verify correctness **and** flag breadt ### Hard Errors (would block sandbox startup) - [ ] `rules` and `access` are NOT both present on the same endpoint -- [ ] If `protocol` is set, either `rules` or `access` is also present +- [ ] If an L7 `protocol` is set, either `rules` or `access` is also present; + `protocol: tcp` is L4-only and must not contain either field - [ ] If `tls: terminate` is set, `protocol` is also set - [ ] `rules` list is not empty when present - [ ] If `protocol: sql`, `enforcement` is not `enforce` @@ -408,7 +412,7 @@ Evaluate the generated policy for overly broad access and **include warnings in | Condition | Warning to show | |-----------|----------------| -| **L4-only** (no `protocol`) | "This policy allows all HTTP methods and paths without inspection. The proxy will only check host:port and binary identity. Consider adding `protocol: rest` with a preset if you want method-level control." | +| **L4-only** (no `protocol`, or `protocol: tcp`) | "This policy allows all HTTP methods and paths without inspection. The proxy will only check host:port and binary identity. Consider adding `protocol: rest` with a preset if you want method-level control." | | **`access: full`** | "This policy allows all HTTP methods (including DELETE) on all paths. If you don't need DELETE, `read-write` is safer. If you only need to read, `read-only` is the most restrictive option." | | **`access: full` + `enforcement: audit`** | "Full access in audit mode provides no actual restriction — all traffic flows through. This is effectively a monitoring-only policy." | | **`access: read-write`** when user hasn't confirmed write need | "This policy allows POST, PUT, and PATCH on all paths. If you only need to read data, `read-only` is more restrictive." | diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 2cd5881ab9..d1418c71dd 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -385,6 +385,9 @@ Notes: - The sandbox name defaults to the last-used sandbox. - `--add-endpoint` options are comma-separated: `allowed-ip=`, `websocket-credential-rewrite`, `request-body-credential-rewrite`, and `allow-uninspected-credentials`. The last option is a security-sensitive exception for provider-credentialed L4-only, `tls: skip`, or otherwise uninspectable traffic. +- `protocol` accepts `tcp` for explicit L4-only host/port policy. It is + currently equivalent to omitting the protocol and cannot be combined with + `access`, `rules`, or L7 enforcement options. - `--add-allow` and `--add-deny` operate on REST and WebSocket endpoints. Use full YAML for JSON-RPC, MCP, SQL, or other policy structure. - `--wait` cannot be combined with `--dry-run`. - Use `policy set` when replacing the full policy or changing static sections. From d093699f774c2c2c30fa68ccdde46e23b3bf475f Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:35:10 -0700 Subject: [PATCH 04/17] docs(policy): defer transparent TCP release guidance Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- docs/reference/policy-schema.mdx | 3 +-- docs/sandboxes/policies.mdx | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index ba6ef2a24b..8dd334f022 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -163,7 +163,7 @@ Each endpoint defines a reachable destination and optional inspection rules. | `host` | string | Yes | Hostname or IP address. Supports a `*` wildcard inside the first DNS label only: `*.example.com`, `**.example.com`, and intra-label patterns like `*-aiplatform.googleapis.com` are accepted; bare `*`/`**`, TLD wildcards (`*.com`), and wildcards outside the first label are rejected at load time. | | `port` | integer | Yes | TCP port number. | | `path` | string | No | Optional HTTP path glob used to select between L7 endpoints that share the same host and port. Empty means all paths. Use this when REST and GraphQL live under the same host, such as `/repos/**` and `/graphql`. | -| `protocol` | string | No | Set to `tcp` for explicit L4 TCP passthrough, `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, `graphql` for GraphQL-over-HTTP operation inspection, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Omitting the field remains equivalent to `tcp`. Direct sandbox DNS and transparent TCP capture are not enabled yet. Provider-credentialed endpoints require an inspected protocol unless `allow_uninspected_credentials` is explicitly set. | +| `protocol` | string | No | Set to `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, `graphql` for GraphQL-over-HTTP operation inspection, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Omit for TCP passthrough. Provider-credentialed endpoints require an inspected protocol unless `allow_uninspected_credentials` is explicitly set. | | `tls` | string | No | TLS handling mode. The proxy auto-detects TLS by peeking the first bytes of each connection and terminates it for inspected HTTPS traffic, so this field is optional in most cases. Set to `skip` to disable auto-detection for edge cases such as client-certificate mTLS or non-standard protocols. Provider-credentialed endpoints reject `tls: skip` unless `allow_uninspected_credentials` is explicitly set. The values `terminate` and `passthrough` are deprecated and log a warning; they are still accepted for backward compatibility but have no effect on behavior. | | `enforcement` | string | No | `enforce` actively blocks disallowed requests. `audit` logs violations but allows traffic through. | | `access` | string | No | Access preset. One of `read-only`, `read-write`, or `full`. Mutually exclusive with `rules`. Not valid on `protocol: mcp` or `protocol: json-rpc`; MCP uses explicit rules unless `mcp.allow_all_known_mcp_methods: true` enables the endpoint method profile, and JSON-RPC always uses explicit rules. | @@ -191,7 +191,6 @@ Each endpoint defines a reachable destination and optional inspection rules. **Validation constraints:** - `access` and `rules` are mutually exclusive; setting both is rejected. -- `protocol: tcp` is L4-only and rejects the L7-only `access`, `rules`, and `deny_rules` fields. - When `protocol` is set, at least one of `access` or `rules` is required for `rest`, `websocket`, `graphql`, and `sql`. - `mcp` and `json-rpc` reject `access` presets; use explicit `rules`. - `json-rpc` requires explicit `rules` with `allow.method`. diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 77d15560d0..83150c39d1 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -69,7 +69,7 @@ When a hot reload changes rules, the supervisor publishes a new policy generatio | `filesystem_policy` | Static | Controls which directories the agent can access on disk. Paths are split into `read_only` and `read_write` lists. Any path not listed in either list is inaccessible. Set `include_workdir: true` to automatically add the agent's working directory to `read_write`. [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforces these restrictions at the kernel level. | | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). Refer to the [Policy Schema Reference](/reference/policy-schema#landlock) for the full behavior table. | | `process` | Static | Optionally overrides the OS-level identity for the agent process. Explicit values must be `sandbox` or numeric UID/GID values from `1` through `4294967294`; root and the invalid identity sentinel are rejected. Docker and Podman may use named identities through per-field OCI `USER` fallback; Kubernetes uses its platform-selected numeric identity. The agent also runs with seccomp filters that block dangerous system calls. | -| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints without `protocol`, or with `protocol: tcp`, allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | +| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints without `protocol` allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | | `network_middlewares` | Dynamic | Declares keyed HTTP and WebSocket middleware configs. After network and L7 policy admit a request or upgrade, OpenShell matches each config's host selectors independently and runs matching entries by their unique ascending `order` before credential injection. WebSocket-capable entries continue on complete client text messages. | ## Supervisor Middleware @@ -307,7 +307,7 @@ Each segment has a fixed meaning: | `host` | Yes | Destination hostname. | | `port` | Yes | Destination port, `1` through `65535`. | | `access` | No | Access preset for L7 endpoints: `read-only`, `read-write`, or `full`. Incremental updates expand presets into protocol-specific method/path rules for REST and WebSocket endpoints. | -| `protocol` | No | Endpoint mode accepted by `openshell policy update`: `tcp`, `rest`, `websocket`, or `sql`. `tcp` explicitly selects the same L4 passthrough used when this field is omitted. `sql` is audit-only and not a recommended workflow today. Full policy YAML also supports `graphql`, `mcp`, and `json-rpc`. | +| `protocol` | No | L7 inspection mode accepted by `openshell policy update`: `rest`, `websocket`, or `sql`. `sql` is audit-only and not a recommended workflow today. Full policy YAML also supports `graphql`, `mcp`, and `json-rpc`. | | `enforcement` | No | Enforcement mode for inspected traffic: `enforce` or `audit`. | | `options` | No | Comma-separated endpoint options. Use `websocket-credential-rewrite` with `protocol: websocket` or REST compatibility endpoints that perform a WebSocket upgrade. Use `request-body-credential-rewrite` only with `protocol: rest`. | @@ -606,7 +606,7 @@ Allow `pip install` and `uv pip install` to reach PyPI: - { path: /usr/local/bin/uv } ``` -Endpoints without `protocol`, or with explicit `protocol: tcp`, use TCP passthrough, where the proxy allows the stream without inspecting payloads. In this release, `protocol: tcp` is a forward-compatible spelling of the existing behavior; it does not yet enable direct sandbox DNS or transparent TCP capture. If the stream is HTTP and TLS is auto-terminated, the proxy can still rewrite configured credential placeholders and closes keep-alive passthrough tunnels on policy reload before forwarding another request. Provider-credentialed endpoints cannot use this shape unless `allow_uninspected_credentials: true` records the exception. WebSocket text-frame policy requires an explicit `protocol: websocket` endpoint. WebSocket payload credential rewrite can also be enabled on a `protocol: rest` compatibility endpoint with `websocket_credential_rewrite: true`. REST request body credential rewrite requires an inspected `protocol: rest` endpoint with `request_body_credential_rewrite: true`. +Endpoints without `protocol` use TCP passthrough, where the proxy allows the stream without inspecting payloads. If the stream is HTTP and TLS is auto-terminated, the proxy can still rewrite configured credential placeholders and closes keep-alive passthrough tunnels on policy reload before forwarding another request. Provider-credentialed endpoints cannot use this shape unless `allow_uninspected_credentials: true` records the exception. WebSocket text-frame policy requires an explicit `protocol: websocket` endpoint. WebSocket payload credential rewrite can also be enabled on a `protocol: rest` compatibility endpoint with `websocket_credential_rewrite: true`. REST request body credential rewrite requires an inspected `protocol: rest` endpoint with `request_body_credential_rewrite: true`.
From f090eb466038335035e7b0483a1723bc4183be4c Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:42:31 -0700 Subject: [PATCH 05/17] chore(go): regenerate sandbox protobuf bindings Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- sdk/go/proto/sandboxv1/sandbox.pb.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 25ad8295ce..8da143ebaa 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -652,7 +652,8 @@ type NetworkEndpoint struct { // Single port (backwards compat). Use `ports` for multiple ports. // Mutually exclusive with `ports` — if both are set, `ports` takes precedence. Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` - // Application protocol for L7 inspection: "rest", "websocket", "graphql", "sql", or "" (L4-only). + // Endpoint protocol. "tcp" and "" select L4-only handling; "rest", + // "websocket", "graphql", "sql", "json-rpc", and "mcp" select L7 inspection. Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"` // TLS handling: "terminate" or "passthrough" (default). Tls string `protobuf:"bytes,4,opt,name=tls,proto3" json:"tls,omitempty"` From 24404a851b093dbb6ff95419518b74d0ae155703 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:31:53 -0700 Subject: [PATCH 06/17] fix(network): complete tcp egress foundation Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-cli/src/policy_update.rs | 25 ++++ crates/openshell-policy/src/ambiguity.rs | 21 ++- crates/openshell-policy/src/l7_validate.rs | 30 ++++ crates/openshell-policy/src/lib.rs | 5 +- crates/openshell-providers/src/profiles.rs | 132 ++++++++++++++++- .../data/sandbox-policy.rego | 25 ++++ .../src/l7/mod.rs | 128 ++++++++++++++++- .../openshell-supervisor-network/src/opa.rs | 135 ++++++++++++++++++ .../src/proxy/destination.rs | 53 ++++++- .../src/proxy/egress.rs | 5 + 10 files changed, 553 insertions(+), 6 deletions(-) diff --git a/crates/openshell-cli/src/policy_update.rs b/crates/openshell-cli/src/policy_update.rs index 17ef50cdad..d6ba02e40c 100644 --- a/crates/openshell-cli/src/policy_update.rs +++ b/crates/openshell-cli/src/policy_update.rs @@ -332,6 +332,11 @@ fn parse_add_endpoint_spec(spec: &str) -> Result { "--add-endpoint protocol segment must be 'tcp', 'rest', 'websocket', or 'sql'; got '{protocol}' in '{spec}'" )); } + if protocol == "tcp" && (!access.is_empty() || !enforcement.is_empty()) { + return Err(miette!( + "--add-endpoint protocol 'tcp' does not support access or enforcement in '{spec}'" + )); + } if !enforcement.is_empty() && !matches!(enforcement, "enforce" | "audit") { return Err(miette!( "--add-endpoint enforcement segment must be 'enforce' or 'audit'; got '{enforcement}' in '{spec}'" @@ -857,6 +862,26 @@ mod tests { ); } + #[test] + fn parse_add_endpoint_rejects_l7_fields_with_tcp() { + let error = build_policy_update_plan( + &["database.example.com:5432::tcp:enforce".to_string()], + &[], + &[], + &[], + &[], + &[], + None, + ) + .expect_err("TCP must reject L7 enforcement"); + + assert!( + error + .to_string() + .contains("does not support access or enforcement") + ); + } + #[test] fn parse_remove_endpoint_rejects_out_of_range_port() { let error = build_policy_update_plan( diff --git a/crates/openshell-policy/src/ambiguity.rs b/crates/openshell-policy/src/ambiguity.rs index 27295f31d5..5c2b9d9177 100644 --- a/crates/openshell-policy/src/ambiguity.rs +++ b/crates/openshell-policy/src/ambiguity.rs @@ -163,6 +163,12 @@ fn overlapping_ports(left: &NetworkEndpoint, right: &NetworkEndpoint) -> Vec Vec { let mut conflicts = Vec::new(); + push_conflict( + &mut conflicts, + "transparent_tcp_eligible", + &is_explicit_tcp(&left.protocol), + &is_explicit_tcp(&right.protocol), + ); push_conflict( &mut conflicts, "tls", @@ -184,6 +190,10 @@ fn connection_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) -> Vec< conflicts } +fn is_explicit_tcp(protocol: &str) -> bool { + protocol.eq_ignore_ascii_case("tcp") +} + /// Keep request-pipeline ambiguity checks aligned with Rego's /// `endpoint_has_extended_config` predicate. Plain L4 endpoints authorize a /// destination but do not participate in endpoint-config selection, so they @@ -1037,13 +1047,20 @@ mod tests { } #[test] - fn explicit_tcp_and_omitted_protocol_are_ambiguity_equivalent() { + fn explicit_tcp_and_omitted_protocol_are_ambiguous_for_native_tcp_eligibility() { let mut explicit_tcp = endpoint("api.example.com", 443); explicit_tcp.protocol = "tcp".to_string(); explicit_tcp.tls = "skip".to_string(); let mut omitted = endpoint("api.example.com", 443); omitted.tls = "skip".to_string(); - assert!(find_endpoint_ambiguities(&policy_with(explicit_tcp, omitted)).is_empty()); + let ambiguities = find_endpoint_ambiguities(&policy_with(explicit_tcp, omitted)); + assert_eq!(ambiguities.len(), 1); + assert!( + ambiguities[0] + .conflicts + .iter() + .any(|conflict| conflict.contains("transparent_tcp_eligible")) + ); } } diff --git a/crates/openshell-policy/src/l7_validate.rs b/crates/openshell-policy/src/l7_validate.rs index c849ac6604..d60491c4bb 100644 --- a/crates/openshell-policy/src/l7_validate.rs +++ b/crates/openshell-policy/src/l7_validate.rs @@ -49,6 +49,26 @@ pub fn is_explicit_tcp_protocol(protocol: &str) -> bool { protocol.eq_ignore_ascii_case("tcp") } +/// Reject additional L7-only fields represented outside +/// [`L7EndpointFields`] by the runtime and provider-profile schemas. +/// +/// Callers pass only authored fields with a non-default value. Keeping the +/// diagnostic construction here ensures both activation paths use the same +/// explicit-TCP contract. +pub fn validate_explicit_tcp_additional_fields( + protocol: &str, + present_fields: &[&str], +) -> Vec { + if !is_explicit_tcp_protocol(protocol) || present_fields.is_empty() { + return Vec::new(); + } + + vec![format!( + "protocol tcp does not support L7-only fields: {}; remove those fields", + present_fields.join(", ") + )] +} + /// Fields extracted from an endpoint definition needed for L7 semantic /// validation. Both profile lint and the runtime validator construct this /// from their own data representation. @@ -432,6 +452,16 @@ mod tests { ); } + #[test] + fn explicit_tcp_rejects_additional_l7_fields() { + let errors = + validate_explicit_tcp_additional_fields("tcp", &["enforcement", "credential_signing"]); + + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("enforcement, credential_signing")); + assert!(validate_explicit_tcp_additional_fields("rest", &["enforcement"]).is_empty()); + } + #[test] fn l7_protocol_parse_known_variants() { assert_eq!(L7Protocol::parse("rest"), Some(L7Protocol::Rest)); diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index fdfb337232..7e4e7d7f40 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -34,7 +34,10 @@ pub use compose::{ PROVIDER_RULE_NAME_PREFIX, ProviderPolicyLayer, compose_effective_policy, is_provider_rule_name, provider_rule_name, strip_provider_rule_names, }; -pub use l7_validate::{L7EndpointFields, L7Protocol, validate_l7_endpoint_semantics}; +pub use l7_validate::{ + L7EndpointFields, L7Protocol, validate_explicit_tcp_additional_fields, + validate_l7_endpoint_semantics, +}; pub use merge::{ PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning, generated_rule_name, merge_policy, policy_covers_rule, diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 63f7932aea..d8c6fa543e 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -13,7 +13,9 @@ use openshell_core::proto::{ ProviderProfileCredential, ProviderProfileDiscovery, }; use openshell_core::secrets::uses_reserved_revision_namespace; -use openshell_policy::{L7EndpointFields, validate_l7_endpoint_semantics}; +use openshell_policy::{ + L7EndpointFields, validate_explicit_tcp_additional_fields, validate_l7_endpoint_semantics, +}; use serde::ser::SerializeStruct; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::collections::{HashMap, HashSet}; @@ -1923,6 +1925,17 @@ pub fn validate_profile_set( msg, )); } + for msg in validate_explicit_tcp_additional_fields( + &endpoint.protocol, + &additional_l7_profile_fields(endpoint), + ) { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + format!("endpoints[{index}]"), + msg, + )); + } if endpoint.protocol == "mcp" { let strict_tool_names = endpoint @@ -2263,6 +2276,49 @@ fn endpoint_is_valid(endpoint: &EndpointProfile) -> bool { (1..=65_535).contains(&endpoint.port) } +fn additional_l7_profile_fields(endpoint: &EndpointProfile) -> Vec<&'static str> { + let mut fields = Vec::new(); + for (name, present) in [ + ("enforcement", !endpoint.enforcement.is_empty()), + ("path", !endpoint.path.is_empty()), + ("allow_encoded_slash", endpoint.allow_encoded_slash), + ( + "websocket_credential_rewrite", + endpoint.websocket_credential_rewrite, + ), + ( + "request_body_credential_rewrite", + endpoint.request_body_credential_rewrite, + ), + ("persisted_queries", !endpoint.persisted_queries.is_empty()), + ( + "graphql_persisted_queries", + !endpoint.graphql_persisted_queries.is_empty(), + ), + ( + "graphql_max_body_bytes", + endpoint.graphql_max_body_bytes > 0, + ), + ( + "json_rpc_max_body_bytes", + endpoint.json_rpc_max_body_bytes > 0, + ), + ("mcp", endpoint.mcp.is_some()), + ( + "credential_signing", + !endpoint.credential_signing.is_empty(), + ), + ("signing_service", !endpoint.signing_service.is_empty()), + ("signing_region", !endpoint.signing_region.is_empty()), + ] { + if present { + fields.push(name); + } + } + + fields +} + #[derive(Debug, Clone)] struct TokenGrantOverrideBinding { override_index: usize, @@ -4368,6 +4424,8 @@ endpoints: - host: database.example.com port: 5432 protocol: tcp + tls: skip + allowed_ips: [10.0.0.0/8] binaries: - /usr/bin/psql ", @@ -4382,6 +4440,78 @@ binaries: assert!(errors.is_empty(), "unexpected errors: {errors:?}"); } + #[test] + fn validate_rejects_additional_l7_field_families_with_explicit_tcp() { + let profile = parse_profile_yaml( + r" +id: invalid-tcp-l7 +display_name: Invalid TCP L7 +credentials: + - name: api_key + env_vars: [API_KEY] + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_key] +endpoints: + - host: database.example.com + port: 5432 + protocol: tcp + enforcement: enforce + path: /query + allow_encoded_slash: true + websocket_credential_rewrite: true + request_body_credential_rewrite: true + persisted_queries: allow_registered + graphql_persisted_queries: + hash: + operation_type: query + graphql_max_body_bytes: 1024 + json_rpc_max_body_bytes: 1024 + mcp: + strict_tool_names: false + credential_signing: sigv4 + signing_service: rds + signing_region: us-west-2 +binaries: + - /usr/bin/psql +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("profile.yaml".to_string(), profile)]); + let tcp_error = diagnostics + .iter() + .find(|diagnostic| { + diagnostic + .message + .contains("protocol tcp does not support L7-only fields") + }) + .expect("explicit TCP should reject additional L7 fields"); + + for field in [ + "enforcement", + "path", + "allow_encoded_slash", + "websocket_credential_rewrite", + "request_body_credential_rewrite", + "persisted_queries", + "graphql_persisted_queries", + "graphql_max_body_bytes", + "json_rpc_max_body_bytes", + "mcp", + "credential_signing", + "signing_service", + "signing_region", + ] { + assert!( + tcp_error.message.contains(field), + "missing {field}: {}", + tcp_error.message + ); + } + } + #[test] fn validate_rejects_unknown_protocol() { let profile = parse_profile_yaml( diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index c1ddf0c45f..2c8204974a 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -243,6 +243,7 @@ egress_authorization := { "deny_reason": _egress_deny_reason, "matched_policy": _egress_matched_policy, "endpoint_configs": _matching_endpoint_configs, + "matched_endpoints": _matching_endpoint_records, "exact_declared_endpoint_host": _egress_exact_declared_endpoint_host, } @@ -896,6 +897,30 @@ _matching_endpoint_configs := [cfg | endpoint_has_extended_config(cfg) ] +# Full matched endpoint records are kept separate from the legacy +# endpoint-config list, which intentionally contains only connection/L7 +# metadata. The policy name and array index identify the endpoint within this +# policy generation while the complete endpoint preserves explicit protocol +# markers needed by later policy-DNS correlation. + +_policy_endpoint_records(policy_name, policy) := [record | + some endpoint_index + ep := policy.endpoints[endpoint_index] + endpoint_matches_request(ep, input.network) + record := { + "policy_name": policy_name, + "endpoint_index": endpoint_index, + "endpoint": ep, + } +] + +_matching_endpoint_records := [record | + some pname + _matching_policy_names[pname] + records := _policy_endpoint_records(pname, data.network_policies[pname]) + record := records[_] +] + matched_endpoint_config := _matching_endpoint_configs[0] if { count(_matching_endpoint_configs) > 0 } diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 9279d3f089..70a980ba2d 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -22,7 +22,9 @@ pub(crate) mod token_grant_injection; pub(crate) mod websocket; pub use openshell_policy::L7Protocol; -use openshell_policy::{L7EndpointFields, validate_l7_endpoint_semantics}; +use openshell_policy::{ + L7EndpointFields, validate_explicit_tcp_additional_fields, validate_l7_endpoint_semantics, +}; pub(crate) fn build_credential_endpoint_mismatch_finding( policy_name: &str, @@ -1000,6 +1002,69 @@ fn json_endpoint_has_graphql_policy(ep: &serde_json::Value) -> bool { /// /// Returns a list of errors and warnings. Errors should prevent sandbox startup; /// warnings are logged but don't block. +fn additional_l7_fields(ep: &serde_json::Value) -> Vec<&'static str> { + let mut fields = Vec::new(); + let non_empty_string = |name| { + ep.get(name) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.is_empty()) + }; + let enabled = |name| { + ep.get(name) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }; + + for (name, present) in [ + ("enforcement", non_empty_string("enforcement")), + ("path", non_empty_string("path")), + ("allow_encoded_slash", enabled("allow_encoded_slash")), + ( + "websocket_credential_rewrite", + enabled("websocket_credential_rewrite"), + ), + ( + "request_body_credential_rewrite", + enabled("request_body_credential_rewrite"), + ), + ("persisted_queries", non_empty_string("persisted_queries")), + ( + "graphql_persisted_queries", + ep.get("graphql_persisted_queries").is_some(), + ), + ( + "graphql_max_body_bytes", + ep.get("graphql_max_body_bytes").is_some(), + ), + ( + "json_rpc_max_body_bytes", + ep.get("json_rpc_max_body_bytes").is_some(), + ), + ( + "mcp.strict_tool_names", + ep.get("mcp_strict_tool_names").is_some(), + ), + ( + "mcp.allow_all_known_mcp_methods", + ep.get("mcp_allow_all_known_mcp_methods").is_some(), + ), + ("credential_signing", non_empty_string("credential_signing")), + ("signing_service", non_empty_string("signing_service")), + ("signing_region", non_empty_string("signing_region")), + ( + "credential_binding", + ep.get("credential_binding") + .is_some_and(|value| !value.is_null()), + ), + ] { + if present { + fields.push(name); + } + } + + fields +} + pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec) { let mut errors = Vec::new(); let mut warnings = Vec::new(); @@ -1132,6 +1197,10 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< for msg in validate_l7_endpoint_semantics(&l7_fields) { errors.push(format!("{loc}: {msg}")); } + for msg in validate_explicit_tcp_additional_fields(protocol, &additional_l7_fields(ep)) + { + errors.push(format!("{loc}: {msg}")); + } if let Some(mode) = ep.get("persisted_queries").and_then(|v| v.as_str()) && !mode.is_empty() @@ -1915,6 +1984,63 @@ mod tests { ); } + #[test] + fn validate_explicit_tcp_rejects_additional_l7_field_families() { + let data = serde_json::json!({ + "network_policies": { + "test": { + "endpoints": [{ + "host": "database.example.com", + "port": 5432, + "protocol": "tcp", + "enforcement": "enforce", + "path": "/query", + "allow_encoded_slash": true, + "websocket_credential_rewrite": true, + "request_body_credential_rewrite": true, + "persisted_queries": "allow_registered", + "graphql_persisted_queries": {}, + "graphql_max_body_bytes": 1024, + "json_rpc_max_body_bytes": 1024, + "mcp_strict_tool_names": false, + "mcp_allow_all_known_mcp_methods": false, + "credential_signing": "sigv4", + "signing_service": "rds", + "signing_region": "us-west-2", + "credential_binding": {"provider": "database"} + }], + "binaries": [] + } + } + }); + + let (errors, _) = validate_l7_policies(&data); + let tcp_error = errors + .iter() + .find(|error| error.contains("protocol tcp does not support L7-only fields")) + .expect("explicit TCP should reject additional L7 fields"); + + for field in [ + "enforcement", + "path", + "allow_encoded_slash", + "websocket_credential_rewrite", + "request_body_credential_rewrite", + "persisted_queries", + "graphql_persisted_queries", + "graphql_max_body_bytes", + "json_rpc_max_body_bytes", + "mcp.strict_tool_names", + "mcp.allow_all_known_mcp_methods", + "credential_signing", + "signing_service", + "signing_region", + "credential_binding", + ] { + assert!(tcp_error.contains(field), "missing {field}: {tcp_error}"); + } + } + #[test] fn validate_request_body_credential_rewrite_warns_unless_rest() { let data = serde_json::json!({ diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 3c620a8a86..3cdd53a0e7 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -50,11 +50,20 @@ pub enum NetworkAction { Deny { reason: String }, } +/// Endpoint identity and metadata captured with one policy generation. +#[derive(Debug, Clone)] +pub struct MatchedEndpoint { + pub policy_name: String, + pub endpoint_index: usize, + pub endpoint: regorus::Value, +} + /// Atomic policy result used to authorize and materialize one egress request. #[derive(Debug, Clone)] pub struct EgressAuthorization { pub action: NetworkAction, pub endpoint_configs: Vec, + pub matched_endpoints: Vec, pub exact_declared_endpoint_host: bool, pub generation: u64, } @@ -544,6 +553,7 @@ impl OpaEngine { return Ok(EgressAuthorization { action: NetworkAction::Deny { reason }, endpoint_configs: Vec::new(), + matched_endpoints: Vec::new(), exact_declared_endpoint_host: false, generation, }); @@ -560,6 +570,12 @@ impl OpaEngine { Some(regorus::Value::Array(values)) => values.to_vec(), _ => Vec::new(), }; + let matched_endpoints = match get_field(&result, "matched_endpoints") { + Some(regorus::Value::Array(values)) => { + values.iter().filter_map(parse_matched_endpoint).collect() + } + _ => Vec::new(), + }; let exact_declared_endpoint_host = get_bool(&result, "exact_declared_endpoint_host").unwrap_or(false); @@ -576,6 +592,7 @@ impl OpaEngine { Ok(EgressAuthorization { action, endpoint_configs, + matched_endpoints, exact_declared_endpoint_host, generation, }) @@ -1172,6 +1189,21 @@ fn get_field<'a>(val: &'a regorus::Value, key: &str) -> Option<&'a regorus::Valu } } +fn parse_matched_endpoint(value: ®orus::Value) -> Option { + let policy_name = get_str(value, "policy_name")?; + let endpoint_index = match get_field(value, "endpoint_index")? { + regorus::Value::Number(number) => usize::try_from(number.as_i64()?).ok()?, + _ => return None, + }; + let endpoint = get_field(value, "endpoint")?.clone(); + + Some(MatchedEndpoint { + policy_name, + endpoint_index, + endpoint, + }) +} + fn regorus_value_to_struct(value: ®orus::Value) -> prost_types::Struct { let regorus::Value::Object(map) = value else { return prost_types::Struct::default(); @@ -1844,6 +1876,11 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St if !e.signing_region.is_empty() { ep["signing_region"] = e.signing_region.clone().into(); } + if let Some(binding) = &e.credential_binding { + ep["credential_binding"] = serde_json::json!({ + "provider": binding.provider.clone(), + }); + } if !e.persisted_queries.is_empty() { ep["persisted_queries"] = e.persisted_queries.clone().into(); } @@ -5398,6 +5435,18 @@ process: } ); + let authorization = engine.authorize_egress(&input).unwrap(); + let mut endpoint_identities = authorization + .matched_endpoints + .iter() + .map(|matched| (matched.policy_name.as_str(), matched.endpoint_index)) + .collect::>(); + endpoint_identities.sort_unstable(); + assert_eq!( + endpoint_identities, + [("allow_192_168_1_100_8567", 0), ("test_server", 0),] + ); + let (configs, generation) = engine .query_endpoint_configs_with_generation(&input) .unwrap(); @@ -5867,6 +5916,92 @@ process: get_str_array(&authorization.endpoint_configs[0], "allowed_ips"), vec!["10.0.5.0/24"] ); + assert_eq!(authorization.matched_endpoints.len(), 1); + assert_eq!( + authorization.matched_endpoints[0].policy_name, + "internal_api" + ); + assert_eq!(authorization.matched_endpoints[0].endpoint_index, 0); + assert_eq!( + get_str(&authorization.matched_endpoints[0].endpoint, "host").as_deref(), + Some("my-service.corp.net") + ); + } + + #[test] + fn egress_authorization_preserves_explicit_tcp_endpoint_identity() { + let engine = OpaEngine::from_strings( + TEST_POLICY, + r#" +network_policies: + native_tcp: + name: native_tcp + endpoints: + - host: database.example.com + port: 5432 + protocol: tcp + binaries: + - path: /usr/bin/client +filesystem_policy: + include_workdir: true + read_only: [] + read_write: [] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +"#, + ) + .expect("explicit TCP policy should load"); + let input = NetworkInput { + host: "database.example.com".into(), + port: 5432, + binary_path: PathBuf::from("/usr/bin/client"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let authorization = engine.authorize_egress(&input).unwrap(); + + assert!(authorization.endpoint_configs.is_empty()); + assert_eq!(authorization.matched_endpoints.len(), 1); + let matched = &authorization.matched_endpoints[0]; + assert_eq!(matched.policy_name, "native_tcp"); + assert_eq!(matched.endpoint_index, 0); + assert_eq!( + get_str(&matched.endpoint, "protocol").as_deref(), + Some("tcp") + ); + } + + #[test] + fn proto_activation_rejects_explicit_tcp_credential_binding() { + let proto = openshell_policy::parse_sandbox_policy( + r#" +version: 1 +network_policies: + native_tcp: + name: native_tcp + endpoints: + - host: database.example.com + port: 5432 + protocol: tcp + credential_binding: + provider: database + binaries: + - path: /usr/bin/client +"#, + ) + .expect("policy should parse before semantic validation"); + + let error = OpaEngine::from_proto(&proto) + .err() + .expect("explicit TCP must reject credential binding during activation"); + + assert!(error.to_string().contains("credential_binding")); + assert!(error.to_string().contains("protocol tcp")); } #[test] diff --git a/crates/openshell-supervisor-network/src/proxy/destination.rs b/crates/openshell-supervisor-network/src/proxy/destination.rs index ea47cf7d06..4f94a08743 100644 --- a/crates/openshell-supervisor-network/src/proxy/destination.rs +++ b/crates/openshell-supervisor-network/src/proxy/destination.rs @@ -20,7 +20,14 @@ pub(super) enum AddressAuthorization { ExplicitAllowedIps(Vec), ExactDeclaredHost, ImplicitIpLiteral(IpAddr), - TrustedGatewayAlias { expected_ip: IpAddr }, + TrustedGatewayAlias { + expected_ip: IpAddr, + }, + /// Addresses already resolved and authorized by policy DNS. This mode must + /// never resolve `DestinationRequest::host` again before constructing the + /// unopened connector. + #[allow(dead_code, reason = "used when the policy DNS adapter lands")] + PinnedResolved(Vec), } /// Fully materialized input to shared destination validation. @@ -94,6 +101,24 @@ pub(super) fn build_validation_plan( }) } +/// Build the destination mode used by policy DNS after it has validated and +/// pinned a non-empty answer set for an endpoint. +#[allow(dead_code, reason = "used when the policy DNS adapter lands")] +pub(super) fn build_pinned_validation_plan( + addresses: Vec, +) -> Result { + if addresses.is_empty() { + return Err(DestinationDenial::new( + DestinationDenialKind::InvalidAllowedIps, + "policy DNS produced an empty pinned address set".to_string(), + )); + } + + Ok(DestinationValidationPlan { + address_authorization: AddressAuthorization::PinnedResolved(addresses), + }) +} + /// Validated, but not yet opened, upstream destination. /// /// The explicit proxy adapter controls when `connect` is called so CONNECT and @@ -179,6 +204,11 @@ pub(super) async fn validate_destination( DestinationDenial::new(DestinationDenialKind::InternalAddress, reason) })? } + AddressAuthorization::PinnedResolved(addresses) => addresses + .iter() + .copied() + .map(|address| SocketAddr::new(address, port)) + .collect(), }; Ok(UpstreamConnector::new(host, port, addrs)) @@ -266,6 +296,27 @@ mod tests { assert_eq!(denial.kind, DestinationDenialKind::TrustedGateway); } + #[tokio::test] + async fn pinned_addresses_construct_connector_without_resolving_host() { + let pinned_ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)); + let plan = build_pinned_validation_plan(vec![pinned_ip]).unwrap(); + + let connector = validate_destination(request("does-not-resolve.invalid", &plan)) + .await + .expect("pinned mode must not resolve the hostname"); + + assert_eq!(connector.addrs(), &[SocketAddr::new(pinned_ip, 80)]); + } + + #[test] + fn pinned_addresses_must_not_be_empty() { + let denial = build_pinned_validation_plan(Vec::new()) + .expect_err("an empty pinned answer set must be rejected"); + + assert_eq!(denial.kind, DestinationDenialKind::InvalidAllowedIps); + assert!(denial.reason.contains("empty pinned address set")); + } + #[test] fn validation_mode_precedence_is_explicit_and_stable() { let trusted_ip = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)); diff --git a/crates/openshell-supervisor-network/src/proxy/egress.rs b/crates/openshell-supervisor-network/src/proxy/egress.rs index 62ec473842..460c157cad 100644 --- a/crates/openshell-supervisor-network/src/proxy/egress.rs +++ b/crates/openshell-supervisor-network/src/proxy/egress.rs @@ -38,6 +38,9 @@ pub(super) struct EndpointDecision { pub(super) destination: Option, /// Raw endpoint configs returned with the authoritative egress decision. pub(super) policy_configs: Vec, + /// Full endpoint identities and metadata captured in the same generation. + #[allow(dead_code, reason = "consumed when the policy DNS adapter lands")] + pub(super) matched_endpoints: Vec, /// Whether policy matched the requested hostname exactly (not by glob). pub(super) exact_declared_host: bool, } @@ -49,6 +52,7 @@ impl Default for EndpointDecision { l7_route: None, destination: None, policy_configs: Vec::new(), + matched_endpoints: Vec::new(), exact_declared_host: false, } } @@ -58,6 +62,7 @@ impl EndpointDecision { pub(super) fn from_authorization(authorization: &crate::opa::EgressAuthorization) -> Self { Self { policy_configs: authorization.endpoint_configs.clone(), + matched_endpoints: authorization.matched_endpoints.clone(), exact_declared_host: authorization.exact_declared_endpoint_host, ..Self::default() } From bf99b85807e1f3c3ecfff774e5355dfcd3daf1a7 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:50:31 -0700 Subject: [PATCH 07/17] docs(policy): document explicit tcp contract Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- docs/reference/policy-schema.mdx | 3 ++- docs/sandboxes/policies.mdx | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 8dd334f022..52edfd273d 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -163,7 +163,7 @@ Each endpoint defines a reachable destination and optional inspection rules. | `host` | string | Yes | Hostname or IP address. Supports a `*` wildcard inside the first DNS label only: `*.example.com`, `**.example.com`, and intra-label patterns like `*-aiplatform.googleapis.com` are accepted; bare `*`/`**`, TLD wildcards (`*.com`), and wildcards outside the first label are rejected at load time. | | `port` | integer | Yes | TCP port number. | | `path` | string | No | Optional HTTP path glob used to select between L7 endpoints that share the same host and port. Empty means all paths. Use this when REST and GraphQL live under the same host, such as `/repos/**` and `/graphql`. | -| `protocol` | string | No | Set to `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, `graphql` for GraphQL-over-HTTP operation inspection, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Omit for TCP passthrough. Provider-credentialed endpoints require an inspected protocol unless `allow_uninspected_credentials` is explicitly set. | +| `protocol` | string | No | Set to `tcp` for explicit L4 TCP passthrough without payload inspection. Omitting the field has the same L4 behavior. Set to `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, `graphql` for GraphQL-over-HTTP operation inspection, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Provider-credentialed endpoints require an inspected protocol unless `allow_uninspected_credentials` is explicitly set. | | `tls` | string | No | TLS handling mode. The proxy auto-detects TLS by peeking the first bytes of each connection and terminates it for inspected HTTPS traffic, so this field is optional in most cases. Set to `skip` to disable auto-detection for edge cases such as client-certificate mTLS or non-standard protocols. Provider-credentialed endpoints reject `tls: skip` unless `allow_uninspected_credentials` is explicitly set. The values `terminate` and `passthrough` are deprecated and log a warning; they are still accepted for backward compatibility but have no effect on behavior. | | `enforcement` | string | No | `enforce` actively blocks disallowed requests. `audit` logs violations but allows traffic through. | | `access` | string | No | Access preset. One of `read-only`, `read-write`, or `full`. Mutually exclusive with `rules`. Not valid on `protocol: mcp` or `protocol: json-rpc`; MCP uses explicit rules unless `mcp.allow_all_known_mcp_methods: true` enables the endpoint method profile, and JSON-RPC always uses explicit rules. | @@ -191,6 +191,7 @@ Each endpoint defines a reachable destination and optional inspection rules. **Validation constraints:** - `access` and `rules` are mutually exclusive; setting both is rejected. +- `protocol: tcp` rejects L7-only fields, including `path`, `enforcement`, `access`, `rules`, `deny_rules`, request rewriting and credential signing fields, and GraphQL, JSON-RPC, or MCP options. - When `protocol` is set, at least one of `access` or `rules` is required for `rest`, `websocket`, `graphql`, and `sql`. - `mcp` and `json-rpc` reject `access` presets; use explicit `rules`. - `json-rpc` requires explicit `rules` with `allow.method`. diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 83150c39d1..90796fe626 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -69,7 +69,7 @@ When a hot reload changes rules, the supervisor publishes a new policy generatio | `filesystem_policy` | Static | Controls which directories the agent can access on disk. Paths are split into `read_only` and `read_write` lists. Any path not listed in either list is inaccessible. Set `include_workdir: true` to automatically add the agent's working directory to `read_write`. [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforces these restrictions at the kernel level. | | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). Refer to the [Policy Schema Reference](/reference/policy-schema#landlock) for the full behavior table. | | `process` | Static | Optionally overrides the OS-level identity for the agent process. Explicit values must be `sandbox` or numeric UID/GID values from `1` through `4294967294`; root and the invalid identity sentinel are rejected. Docker and Podman may use named identities through per-field OCI `USER` fallback; Kubernetes uses its platform-selected numeric identity. The agent also runs with seccomp filters that block dangerous system calls. | -| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints without `protocol` allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | +| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints without `protocol`, or with `protocol: tcp`, allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | | `network_middlewares` | Dynamic | Declares keyed HTTP and WebSocket middleware configs. After network and L7 policy admit a request or upgrade, OpenShell matches each config's host selectors independently and runs matching entries by their unique ascending `order` before credential injection. WebSocket-capable entries continue on complete client text messages. | ## Supervisor Middleware @@ -307,7 +307,7 @@ Each segment has a fixed meaning: | `host` | Yes | Destination hostname. | | `port` | Yes | Destination port, `1` through `65535`. | | `access` | No | Access preset for L7 endpoints: `read-only`, `read-write`, or `full`. Incremental updates expand presets into protocol-specific method/path rules for REST and WebSocket endpoints. | -| `protocol` | No | L7 inspection mode accepted by `openshell policy update`: `rest`, `websocket`, or `sql`. `sql` is audit-only and not a recommended workflow today. Full policy YAML also supports `graphql`, `mcp`, and `json-rpc`. | +| `protocol` | No | Endpoint mode accepted by `openshell policy update`: `tcp`, `rest`, `websocket`, or `sql`. `tcp` explicitly selects the same L4 passthrough used when this field is omitted. `sql` is audit-only and not a recommended workflow today. Full policy YAML also supports `graphql`, `mcp`, and `json-rpc`. | | `enforcement` | No | Enforcement mode for inspected traffic: `enforce` or `audit`. | | `options` | No | Comma-separated endpoint options. Use `websocket-credential-rewrite` with `protocol: websocket` or REST compatibility endpoints that perform a WebSocket upgrade. Use `request-body-credential-rewrite` only with `protocol: rest`. | @@ -317,6 +317,7 @@ Examples: |---|---| | `pypi.org:443` | Add a plain L4 endpoint. The proxy allows the TCP stream and does not inspect HTTP requests. | | `telemetry.example.com:443::::allow-uninspected-credentials` | Explicitly allow a provider-credentialed L4 endpoint after accepting that OpenShell cannot inspect or rewrite its traffic. | +| `db.internal.example:5432::tcp` | Add an explicit L4 endpoint. The empty `access` segment is required before `tcp`. | | `api.github.com:443:read-only:rest:enforce` | Add a REST endpoint with the `read-only` preset expanded by the policy engine into GET, HEAD, and OPTIONS access. | | `api.example.com:443:read-write:rest:enforce:request-body-credential-rewrite` | Add a REST endpoint that rewrites credential placeholders in supported text request bodies. | | `realtime.example.com:443:read-write:websocket:enforce` | Add a WebSocket endpoint with the `read-write` preset expanded by the policy engine into the upgrade `GET` and client `WEBSOCKET_TEXT` access. | @@ -340,6 +341,7 @@ Binding](/sandboxes/providers-v2#understand-static-credential-endpoint-binding). For example: +- `db.internal.example:5432::tcp` is valid. - `api.github.com:443:read-only:rest` is valid. - `realtime.example.com:443:read-write:websocket` is valid. - `api.github.com:443::rest` is invalid. It does not mean "allow all traffic." An L7 endpoint with `protocol` but no `access` or `rules` is rejected when the policy loads. @@ -606,7 +608,7 @@ Allow `pip install` and `uv pip install` to reach PyPI: - { path: /usr/local/bin/uv } ``` -Endpoints without `protocol` use TCP passthrough, where the proxy allows the stream without inspecting payloads. If the stream is HTTP and TLS is auto-terminated, the proxy can still rewrite configured credential placeholders and closes keep-alive passthrough tunnels on policy reload before forwarding another request. Provider-credentialed endpoints cannot use this shape unless `allow_uninspected_credentials: true` records the exception. WebSocket text-frame policy requires an explicit `protocol: websocket` endpoint. WebSocket payload credential rewrite can also be enabled on a `protocol: rest` compatibility endpoint with `websocket_credential_rewrite: true`. REST request body credential rewrite requires an inspected `protocol: rest` endpoint with `request_body_credential_rewrite: true`. +Endpoints without `protocol`, or with explicit `protocol: tcp`, use TCP passthrough, where the proxy allows the stream without inspecting payloads. Explicit `protocol: tcp` does not enable direct sandbox DNS or transparent TCP capture at this stage. If the stream is HTTP and TLS is auto-terminated, the proxy can still rewrite configured credential placeholders and closes keep-alive passthrough tunnels on policy reload before forwarding another request. Provider-credentialed endpoints cannot use this shape unless `allow_uninspected_credentials: true` records the exception. WebSocket text-frame policy requires an explicit `protocol: websocket` endpoint. WebSocket payload credential rewrite can also be enabled on a `protocol: rest` compatibility endpoint with `websocket_credential_rewrite: true`. REST request body credential rewrite requires an inspected `protocol: rest` endpoint with `request_body_credential_rewrite: true`.
From 35431e88b94dd326abed4d1d3849fd196a92c80c Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:28:45 -0700 Subject: [PATCH 08/17] fix(network): fail closed on authorization errors Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/proxy/egress.rs | 26 ++++---------- .../src/proxy/tests/compatibility.rs | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy/egress.rs b/crates/openshell-supervisor-network/src/proxy/egress.rs index 460c157cad..2d88d74995 100644 --- a/crates/openshell-supervisor-network/src/proxy/egress.rs +++ b/crates/openshell-supervisor-network/src/proxy/egress.rs @@ -11,7 +11,6 @@ use super::destination::DestinationValidationPlan; use crate::opa::NetworkAction; -use std::net::IpAddr; use std::path::PathBuf; #[derive(Debug, Clone)] @@ -84,9 +83,6 @@ pub(super) enum EgressTransport { pub(super) struct RequestedDestination { pub(super) host: String, pub(super) port: u16, - /// Address selected from a policy-authorized DNS answer. Explicit proxy - /// adapters leave this empty; the transparent adapter will require it. - pub(super) pinned_ip: Option, } /// Transport-neutral description of an external egress request. @@ -106,25 +102,17 @@ impl EgressIntent { } #[cfg(test)] - pub(super) fn transparent_tcp(host: String, port: u16, pinned_ip: IpAddr) -> Self { + pub(super) fn transparent_tcp(host: String, port: u16) -> Self { Self { transport: EgressTransport::TransparentTcp, - destination: RequestedDestination { - host, - port, - pinned_ip: Some(pinned_ip), - }, + destination: RequestedDestination { host, port }, } } fn new(transport: EgressTransport, host: String, port: u16) -> Self { Self { transport, - destination: RequestedDestination { - host, - port, - pinned_ip: None, - }, + destination: RequestedDestination { host, port }, } } } @@ -182,14 +170,12 @@ mod tests { assert_eq!(connect.transport, EgressTransport::Connect); assert_eq!(connect.destination.host, "api.example.com"); assert_eq!(connect.destination.port, 443); - assert_eq!(connect.destination.pinned_ip, None); assert_eq!(forward.transport, EgressTransport::ForwardHttp); assert_eq!(forward.destination.port, 80); - let pinned_ip = "203.0.113.8".parse().unwrap(); - let transparent = - EgressIntent::transparent_tcp("db.example.com".to_string(), 5432, pinned_ip); + let transparent = EgressIntent::transparent_tcp("db.example.com".to_string(), 5432); assert_eq!(transparent.transport, EgressTransport::TransparentTcp); - assert_eq!(transparent.destination.pinned_ip, Some(pinned_ip)); + assert_eq!(transparent.destination.host, "db.example.com"); + assert_eq!(transparent.destination.port, 5432); } } diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 5e863445fa..186d156086 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -306,6 +306,40 @@ fn missing_authorized_exact_host_preserves_false_fallback() { assert!(!decision.endpoint.exact_declared_host); } +#[test] +fn authoritative_evaluation_error_denies_without_metadata_fallback() { + let engine = OpaEngine::from_strings( + include_str!("../../../data/sandbox-policy.rego"), + r#" +network_policies: + proxy_compatibility: + name: proxy_compatibility + endpoints: + - host: "*.example.com" + port: 443 + binaries: + - path: /** +"#, + ) + .unwrap(); + + // Regorus rejects the NUL byte used internally by its glob matcher. The + // combined authorization query must deny rather than preserve the old + // multi-query behavior that could fall back to an L4-only allow. + let decision = evaluate_endpoint_only_opa( + &engine, + EgressIntent::connect("sub\0.example.com".to_string(), 443), + ); + + let NetworkAction::Deny { reason } = decision.action else { + panic!("evaluation errors must deny the request"); + }; + assert!(reason.starts_with("policy evaluation error:")); + assert!(decision.endpoint.policy_configs.is_empty()); + assert!(decision.endpoint.matched_endpoints.is_empty()); + assert!(decision.endpoint.destination.is_none()); +} + #[test] fn identity_required_policy_accepts_real_binary_and_rejects_empty_exec_path() { let engine = OpaEngine::from_strings( From feecc0f5fb31a2975c5cfe1bfecc5e34f31ec0a4 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:17:28 -0700 Subject: [PATCH 09/17] fix(podman): fence delayed exit events before restart Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-driver-podman/src/driver.rs | 28 +++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 23175b3bf4..39989e287e 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -955,9 +955,16 @@ impl PodmanComputeDriver { .ok_or(ComputeDriverError::NotFound)?; let container_id = container.id; if container.state == "stopping" { - return self - .wait_for_container_stopped(sandbox_id, &container_id) - .await; + self.wait_for_container_stopped(sandbox_id, &container_id) + .await?; + let stopped = self + .client + .inspect_container(&container_id) + .await + .map_err(ComputeDriverError::from)?; + self.lifecycle_event_fences + .record_previous_exit(sandbox_id, stopped.state.finished_at.as_deref()); + return Ok(()); } if container.state != "running" { return Ok(()); @@ -975,7 +982,22 @@ impl PodmanComputeDriver { // the same sandbox to Starting, causing it to regress to Error. Wait // for the terminal container state before allowing a restart. self.wait_for_container_stopped(sandbox_id, &container_id) + .await?; + + // Record the completed run before returning the stop RPC. The server + // may begin a restart as soon as this method returns, while Podman's + // stop/die event can still be queued. Recording the fence here keeps + // that delayed event from regressing the new run from Starting to + // Error. Keep the start-side recording as a fallback for restarts + // after a driver or gateway process restart. + let stopped = self + .client + .inspect_container(&container_id) .await + .map_err(ComputeDriverError::from)?; + self.lifecycle_event_fences + .record_previous_exit(sandbox_id, stopped.state.finished_at.as_deref()); + Ok(()) } /// Start a previously stopped sandbox container. From 700b1ea491d72db5dc5c18ad20faf56d9a421ca9 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:19:28 -0700 Subject: [PATCH 10/17] fix(policy): validate network endpoint destinations Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-policy/src/lib.rs | 133 +++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 7e4e7d7f40..a23ea07575 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -1172,6 +1172,16 @@ pub enum PolicyViolation { TooManyPaths { count: usize }, /// A network endpoint uses a TLD wildcard (e.g. `*.com`). TldWildcard { policy_name: String, host: String }, + /// A network endpoint has no hostname. + MissingEndpointHost { policy_name: String }, + /// A network endpoint has no effective destination port. + MissingEndpointPort { policy_name: String, host: String }, + /// A network endpoint contains a port outside the TCP/UDP range. + InvalidEndpointPort { + policy_name: String, + host: String, + port: u32, + }, /// A network endpoint uses a wildcard shape that does not match runtime semantics. InvalidHostWildcard { policy_name: String, host: String }, /// `credential_signing` is set but `signing_service` is missing. @@ -1241,6 +1251,28 @@ impl fmt::Display for PolicyViolation { use subdomain wildcards like '*.example.com' instead" ) } + Self::MissingEndpointHost { policy_name } => { + write!( + f, + "network policy '{policy_name}': endpoint host must not be empty" + ) + } + Self::MissingEndpointPort { policy_name, host } => { + write!( + f, + "network policy '{policy_name}': endpoint '{host}' must declare at least one port" + ) + } + Self::InvalidEndpointPort { + policy_name, + host, + port, + } => { + write!( + f, + "network policy '{policy_name}': endpoint '{host}' has invalid port {port}; expected 1..=65535" + ) + } Self::InvalidHostWildcard { policy_name, host } => { write!( f, @@ -1410,6 +1442,31 @@ pub fn validate_sandbox_policy( rule.name.clone() }; for ep in &rule.endpoints { + if ep.host.trim().is_empty() { + violations.push(PolicyViolation::MissingEndpointHost { + policy_name: name.clone(), + }); + } + let effective_ports: Vec = if ep.ports.is_empty() { + (ep.port != 0).then_some(ep.port).into_iter().collect() + } else { + ep.ports.clone() + }; + if effective_ports.is_empty() { + violations.push(PolicyViolation::MissingEndpointPort { + policy_name: name.clone(), + host: ep.host.clone(), + }); + } + for port in effective_ports { + if !(1..=u16::MAX.into()).contains(&port) { + violations.push(PolicyViolation::InvalidEndpointPort { + policy_name: name.clone(), + host: ep.host.clone(), + port, + }); + } + } if ep.host.contains('*') && (ep.host.starts_with("*.") || ep.host.starts_with("**.")) { let label_count = ep.host.split('.').count(); if label_count <= 2 { @@ -2400,6 +2457,82 @@ network_policies: assert!(validate_sandbox_policy(&policy).is_ok()); } + #[test] + fn validate_rejects_yaml_endpoint_without_host_or_port() { + let policy = parse_sandbox_policy( + r#" +version: 1 +network_policies: + invalid: + endpoints: + - host: "" + protocol: tcp +"#, + ) + .expect("policy syntax should parse before semantic validation"); + + let violations = validate_sandbox_policy(&policy).expect_err("endpoint is incomplete"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::MissingEndpointHost { policy_name } if policy_name == "invalid" + ))); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::MissingEndpointPort { policy_name, .. } if policy_name == "invalid" + ))); + } + + #[test] + fn validate_rejects_raw_endpoint_zero_and_out_of_range_ports() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "invalid".into(), + NetworkPolicyRule { + name: "invalid".into(), + endpoints: vec![NetworkEndpoint { + host: "database.example.com".into(), + ports: vec![0, u32::from(u16::MAX) + 1], + protocol: "tcp".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy).expect_err("ports are invalid"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::InvalidEndpointPort { port: 0, .. } + ))); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::InvalidEndpointPort { port: 65_536, .. } + ))); + } + + #[test] + fn validate_rejects_raw_endpoint_without_effective_port() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "invalid".into(), + NetworkPolicyRule { + name: "invalid".into(), + endpoints: vec![NetworkEndpoint { + host: "database.example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy).expect_err("port is missing"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::MissingEndpointPort { policy_name, host } + if policy_name == "invalid" && host == "database.example.com" + ))); + } + #[test] fn validate_accepts_empty_process() { let policy = SandboxPolicy { From 23eb091edcdf05b7cf7b7681f0f0abcf81662465 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:19:34 -0700 Subject: [PATCH 11/17] test(providers): opt in tcp credential fixture Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-providers/src/profiles.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index d8c6fa543e..243d34bc1c 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -4425,6 +4425,7 @@ endpoints: port: 5432 protocol: tcp tls: skip + allow_uninspected_credentials: true allowed_ips: [10.0.0.0/8] binaries: - /usr/bin/psql From 6b01545702a7a2da2384bf319fceb489a887eb27 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:02:39 -0700 Subject: [PATCH 12/17] fix(policy): require dns host for transparent tcp Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../skills/generate-sandbox-policy/SKILL.md | 11 +- .agents/skills/openshell-cli/cli-reference.md | 8 +- Cargo.lock | 98 +++++++- Cargo.toml | 1 + architecture/sandbox.md | 8 +- crates/openshell-driver-podman/src/driver.rs | 24 +- crates/openshell-policy/Cargo.toml | 1 + crates/openshell-policy/src/lib.rs | 220 +++++++++++++++++- crates/openshell-sandbox/src/lib.rs | 10 + docs/reference/policy-schema.mdx | 7 +- examples/governance-interceptor/Cargo.lock | 215 ++++++++++++++++- 11 files changed, 558 insertions(+), 45 deletions(-) diff --git a/.agents/skills/generate-sandbox-policy/SKILL.md b/.agents/skills/generate-sandbox-policy/SKILL.md index a4bca4c5d1..f6a540d3de 100644 --- a/.agents/skills/generate-sandbox-policy/SKILL.md +++ b/.agents/skills/generate-sandbox-policy/SKILL.md @@ -44,8 +44,9 @@ For this tier, default to: - `access: full` when the user says "full access", "everything", "unrestricted" - L4-only (omit `protocol`, or use explicit `protocol: tcp`) when the user says "just allow it", "pass through", "no inspection". Prefer omission unless the - user wants the transport intent stated explicitly; both currently have the - same host/port enforcement behavior. + user wants the transport intent stated explicitly. Explicit TCP requires a + valid DNS hostname; omit `protocol` for a legacy hostless `allowed_ips` + proxy endpoint. ### Moderate Tier (host + partial path knowledge) @@ -191,7 +192,7 @@ Follow this decision tree based on the detail tier and user intent: ``` Is L7 inspection needed? ├─ No (user wants pass-through / "just allow it") -│ └─ Generate L4-only policy (no protocol, or protocol: tcp; no tls/rules/access) +│ └─ Generate L4-only policy (no protocol, or protocol: tcp with a DNS hostname; no tls/rules/access) │ └─ Yes (user wants method/path control) │ @@ -381,6 +382,8 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] `rules` and `access` are NOT both present on the same endpoint - [ ] If an L7 `protocol` is set, either `rules` or `access` is also present; `protocol: tcp` is L4-only and must not contain either field +- [ ] Every `protocol: tcp` endpoint has a valid DNS hostname; it is not + hostless, an IP literal, a trailing-dot name, or a malformed DNS selector - [ ] If `tls: terminate` is set, `protocol` is also set - [ ] `rules` list is not empty when present - [ ] If `protocol: sql`, `enforcement` is not `enforce` @@ -419,7 +422,7 @@ Evaluate the generated policy for overly broad access and **include warnings in | **Wildcard binary** (`*` or `**` in binary path) | "This policy allows any binary matching the glob pattern. A compromised or unexpected binary in that directory could use this policy. Consider listing specific binary paths." | | **`**` path glob** on all explicit rules | "All rules use `**` path patterns, which match any URL path. This is equivalent to a preset — consider using `access: read-only` (or similar) for clarity, or narrowing paths if you know the API structure." | | **Multiple broad endpoints** in one policy | "This policy grants the same broad access to N different hosts. If any of these hosts needs tighter restrictions later, you'll need to split the policy." | -| **Hostless `allowed_ips`** (no `host` field) | "This endpoint has no `host` — any domain resolving to the allowed IP range on this port will be permitted. Consider adding a `host` field to restrict which domains can use this allowlist." | +| **Hostless `allowed_ips`** (no `host` field and no `protocol: tcp`) | "This endpoint has no `host` — any domain resolving to the allowed IP range on this port will be permitted through the legacy proxy. Consider adding a `host` field to restrict which domains can use this allowlist." | | **Broad CIDR** in `allowed_ips` (e.g., `10.0.0.0/8`) | "This `allowed_ips` entry covers a very broad range. Consider narrowing to a specific subnet (e.g., `10.0.5.0/24`) to minimize exposure." | | **`on_error: fail_open`** | "This middleware can be bypassed when it is unavailable, rejects configuration, returns an invalid result, or exceeds its body limit. Use `fail_closed` unless availability is more important than this control." | | **Broad middleware host selector** | "This middleware attaches independently of the admitting network rule to every matching destination, then runs only for operation bindings its implementation advertises. Narrow `endpoints.include` or add exclusions if the attachment is not required for every matching host." | diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index d1418c71dd..fd23808fa4 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -385,9 +385,11 @@ Notes: - The sandbox name defaults to the last-used sandbox. - `--add-endpoint` options are comma-separated: `allowed-ip=`, `websocket-credential-rewrite`, `request-body-credential-rewrite`, and `allow-uninspected-credentials`. The last option is a security-sensitive exception for provider-credentialed L4-only, `tls: skip`, or otherwise uninspectable traffic. -- `protocol` accepts `tcp` for explicit L4-only host/port policy. It is - currently equivalent to omitting the protocol and cannot be combined with - `access`, `rules`, or L7 enforcement options. +- `protocol` accepts `tcp` for explicit L4-only host/port policy. It has the + same payload-handling behavior as omitting the protocol, but it requires a + valid DNS hostname and rejects hostless `allowed_ips` or literal-IP + selectors. It cannot be combined with `access`, `rules`, or L7 enforcement + options. - `--add-allow` and `--add-deny` operate on REST and WebSocket endpoints. Use full YAML for JSON-RPC, MCP, SQL, or other policy structure. - `--wait` cannot be combined with `--dry-run`. - Use `policy set` when replacing the full policy or changing static sections. diff --git a/Cargo.lock b/Cargo.lock index c30f890914..cf15ebb610 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,7 +127,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -138,7 +138,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1204,6 +1204,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1728,7 +1734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2187,6 +2193,25 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "rand 0.10.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -2784,6 +2809,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -3446,7 +3501,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3624,6 +3679,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -3975,6 +4034,7 @@ dependencies = [ name = "openshell-policy" version = "0.0.0" dependencies = [ + "hickory-proto", "miette", "openshell-core", "prost-types", @@ -5650,7 +5710,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5720,7 +5780,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls 0.23.38", @@ -5730,7 +5790,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6210,6 +6270,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -6260,7 +6336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6757,7 +6833,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6793,7 +6869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7790,7 +7866,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 150df10d69..c484ec95b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,6 +116,7 @@ glob = "0.3" # Utilities futures = "0.3" bytes = "1" +hickory-proto = "0.26.1" pin-project-lite = "0.2" tokio-stream = "0.1" protoc-bin-vendored = "3.2.0" diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 210a34880a..9842d74748 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -75,9 +75,11 @@ its guarded single-request relay while sharing authorization, request context, policy-pinning, and destination boundaries. Adapter-specific response and OCSF event shapes remain at the protocol boundary. Policy authors may use `protocol: tcp` as an explicit spelling of the existing -L4 passthrough behavior. Omitting `protocol` remains equivalent. The egress -intent reserves a transparent TCP adapter and a policy-DNS-pinned address, but -DNS serving and transparent TCP capture are not active yet. +L4 passthrough behavior. Explicit TCP endpoints require a valid DNS hostname; +hostless `allowed_ips` and literal-IP selectors remain available only to the +legacy forward-proxy path when `protocol` is omitted. The egress intent reserves +a transparent TCP adapter and a policy-DNS-pinned address, but DNS serving and +transparent TCP capture are not active yet. Provider credential placeholders are resolved through the live provider state for each HTTP request, after destination and L7 policy admission. A static diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 39989e287e..6a4b7f643c 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -921,7 +921,7 @@ impl PodmanComputeDriver { &self, sandbox_id: &str, container_id: &str, - ) -> Result<(), ComputeDriverError> { + ) -> Result, ComputeDriverError> { let timeout = Duration::from_secs(u64::from(self.config.stop_timeout_secs)) + STOP_COMPLETION_TIMEOUT_HEADROOM; let deadline = tokio::time::Instant::now() + timeout; @@ -933,7 +933,7 @@ impl PodmanComputeDriver { .await .map_err(ComputeDriverError::from)?; if matches!(inspect.state.status.as_str(), "exited" | "stopped") { - return Ok(()); + return Ok(inspect.state.finished_at); } let now = tokio::time::Instant::now(); @@ -955,15 +955,11 @@ impl PodmanComputeDriver { .ok_or(ComputeDriverError::NotFound)?; let container_id = container.id; if container.state == "stopping" { - self.wait_for_container_stopped(sandbox_id, &container_id) + let finished_at = self + .wait_for_container_stopped(sandbox_id, &container_id) .await?; - let stopped = self - .client - .inspect_container(&container_id) - .await - .map_err(ComputeDriverError::from)?; self.lifecycle_event_fences - .record_previous_exit(sandbox_id, stopped.state.finished_at.as_deref()); + .record_previous_exit(sandbox_id, finished_at.as_deref()); return Ok(()); } if container.state != "running" { @@ -981,7 +977,8 @@ impl PodmanComputeDriver { // event from the previous run can arrive after the gateway has moved // the same sandbox to Starting, causing it to regress to Error. Wait // for the terminal container state before allowing a restart. - self.wait_for_container_stopped(sandbox_id, &container_id) + let finished_at = self + .wait_for_container_stopped(sandbox_id, &container_id) .await?; // Record the completed run before returning the stop RPC. The server @@ -990,13 +987,8 @@ impl PodmanComputeDriver { // that delayed event from regressing the new run from Starting to // Error. Keep the start-side recording as a fallback for restarts // after a driver or gateway process restart. - let stopped = self - .client - .inspect_container(&container_id) - .await - .map_err(ComputeDriverError::from)?; self.lifecycle_event_fences - .record_previous_exit(sandbox_id, stopped.state.finished_at.as_deref()); + .record_previous_exit(sandbox_id, finished_at.as_deref()); Ok(()) } diff --git a/crates/openshell-policy/Cargo.toml b/crates/openshell-policy/Cargo.toml index b69da8d2b5..cb32584186 100644 --- a/crates/openshell-policy/Cargo.toml +++ b/crates/openshell-policy/Cargo.toml @@ -11,6 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] +hickory-proto = { workspace = true } openshell-core = { path = "../openshell-core", default-features = false } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index a23ea07575..c8c5dd99c3 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -16,12 +16,14 @@ mod middleware; use std::collections::{BTreeMap, HashMap}; use std::fmt; +use std::net::IpAddr; use std::path::Path; mod ambiguity; pub use ambiguity::{EndpointAmbiguity, find_endpoint_ambiguities}; +use hickory_proto::rr::Name; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, @@ -1174,6 +1176,16 @@ pub enum PolicyViolation { TldWildcard { policy_name: String, host: String }, /// A network endpoint has no hostname. MissingEndpointHost { policy_name: String }, + /// An explicit TCP endpoint has no DNS hostname. + MissingTcpEndpointHost { policy_name: String }, + /// An explicit TCP endpoint uses an IP literal instead of a DNS hostname. + TcpEndpointIpLiteral { policy_name: String, host: String }, + /// An explicit TCP endpoint has a hostname that policy DNS cannot resolve. + InvalidTcpEndpointHost { + policy_name: String, + host: String, + reason: String, + }, /// A network endpoint has no effective destination port. MissingEndpointPort { policy_name: String, host: String }, /// A network endpoint contains a port outside the TCP/UDP range. @@ -1254,7 +1266,29 @@ impl fmt::Display for PolicyViolation { Self::MissingEndpointHost { policy_name } => { write!( f, - "network policy '{policy_name}': endpoint host must not be empty" + "network policy '{policy_name}': endpoint host must not be empty unless allowed_ips constrains a non-TCP proxy endpoint" + ) + } + Self::MissingTcpEndpointHost { policy_name } => { + write!( + f, + "network policy '{policy_name}': protocol tcp requires a DNS hostname; hostless allowed_ips endpoints are supported only by the forward proxy" + ) + } + Self::TcpEndpointIpLiteral { policy_name, host } => { + write!( + f, + "network policy '{policy_name}': protocol tcp endpoint '{host}' must use a DNS hostname, not an IP literal; direct IP connections bypass policy DNS and are blocked" + ) + } + Self::InvalidTcpEndpointHost { + policy_name, + host, + reason, + } => { + write!( + f, + "network policy '{policy_name}': protocol tcp endpoint has invalid DNS host selector '{host}': {reason}" ) } Self::MissingEndpointPort { policy_name, host } => { @@ -1442,10 +1476,28 @@ pub fn validate_sandbox_policy( rule.name.clone() }; for ep in &rule.endpoints { - if ep.host.trim().is_empty() { + let explicit_tcp = l7_validate::is_explicit_tcp_protocol(&ep.protocol); + if ep.host.trim().is_empty() && explicit_tcp { + violations.push(PolicyViolation::MissingTcpEndpointHost { + policy_name: name.clone(), + }); + } else if ep.host.trim().is_empty() && ep.allowed_ips.is_empty() { violations.push(PolicyViolation::MissingEndpointHost { policy_name: name.clone(), }); + } else if explicit_tcp { + if ep.host.parse::().is_ok() { + violations.push(PolicyViolation::TcpEndpointIpLiteral { + policy_name: name.clone(), + host: ep.host.clone(), + }); + } else if let Err(reason) = validate_tcp_dns_host_selector(&ep.host) { + violations.push(PolicyViolation::InvalidTcpEndpointHost { + policy_name: name.clone(), + host: ep.host.clone(), + reason, + }); + } } let effective_ports: Vec = if ep.ports.is_empty() { (ep.port != 0).then_some(ep.port).into_iter().collect() @@ -1537,6 +1589,39 @@ fn host_wildcard_shape_invalid(host: &str) -> bool { .any(|label| label.contains("**") || (label.contains('*') && label != "*")) } +/// Validate that an explicit-TCP host selector can produce names accepted by +/// policy DNS. Wildcards are replaced with a representative DNS label before +/// parsing because the authored selector itself is not a concrete DNS name. +fn validate_tcp_dns_host_selector(host: &str) -> std::result::Result<(), String> { + if host.trim() != host { + return Err("leading or trailing whitespace is not allowed".to_string()); + } + if host.ends_with('.') { + return Err("omit the trailing DNS root dot".to_string()); + } + + openshell_core::host_pattern::HostSelector::new(&[host.to_string()], &[])?; + + let representative = host + .split('.') + .map(|label| { + if label == "**" { + "x".to_string() + } else { + label.replace('*', "x") + } + }) + .collect::>() + .join("."); + let absolute = format!("{representative}."); + let parsed = Name::from_ascii(&absolute) + .map_err(|error| format!("selector cannot represent a valid DNS name: {error}"))?; + if parsed.is_root() { + return Err("DNS root is not a destination hostname".to_string()); + } + Ok(()) +} + /// Truncate a string for safe inclusion in error messages. fn truncate_for_display(s: &str) -> String { if s.len() <= 80 { @@ -2458,7 +2543,7 @@ network_policies: } #[test] - fn validate_rejects_yaml_endpoint_without_host_or_port() { + fn validate_rejects_yaml_tcp_endpoint_without_host_or_port() { let policy = parse_sandbox_policy( r#" version: 1 @@ -2474,14 +2559,141 @@ network_policies: let violations = validate_sandbox_policy(&policy).expect_err("endpoint is incomplete"); assert!(violations.iter().any(|violation| matches!( violation, - PolicyViolation::MissingEndpointHost { policy_name } if policy_name == "invalid" + PolicyViolation::MissingTcpEndpointHost { policy_name } if policy_name == "invalid" ))); + assert!(violations.iter().any(|violation| { + violation.to_string().contains( + "protocol tcp requires a DNS hostname; hostless allowed_ips endpoints are supported only by the forward proxy", + ) + })); assert!(violations.iter().any(|violation| matches!( violation, PolicyViolation::MissingEndpointPort { policy_name, .. } if policy_name == "invalid" ))); } + #[test] + fn validate_accepts_hostless_allowed_ips_for_non_tcp_proxy_endpoint() { + let policy = parse_sandbox_policy( + r" +version: 1 +network_policies: + legacy-proxy: + endpoints: + - port: 9443 + allowed_ips: + - 10.0.5.0/24 +", + ) + .expect("policy syntax should parse before semantic validation"); + + validate_sandbox_policy(&policy) + .expect("hostless allowed_ips remains valid for non-TCP proxy endpoints"); + } + + #[test] + fn validate_rejects_hostless_allowed_ips_for_explicit_tcp() { + let policy = parse_sandbox_policy( + r" +version: 1 +network_policies: + native-tcp: + endpoints: + - port: 6379 + protocol: tcp + allowed_ips: + - 10.0.5.0/24 +", + ) + .expect("policy syntax should parse before semantic validation"); + + let violations = + validate_sandbox_policy(&policy).expect_err("transparent TCP requires a DNS hostname"); + let violation = violations + .iter() + .find(|violation| matches!(violation, PolicyViolation::MissingTcpEndpointHost { .. })) + .expect("missing TCP hostname violation"); + assert_eq!( + violation.to_string(), + "network policy 'native-tcp': protocol tcp requires a DNS hostname; hostless allowed_ips endpoints are supported only by the forward proxy" + ); + } + + #[test] + fn validate_rejects_ip_literal_hosts_for_explicit_tcp() { + for host in ["192.0.2.10", "2001:db8::10"] { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "native-tcp".into(), + NetworkPolicyRule { + name: "native-tcp".into(), + endpoints: vec![NetworkEndpoint { + host: host.into(), + port: 6379, + protocol: "tcp".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy) + .expect_err("transparent TCP must reject direct IP destinations"); + let violation = violations + .iter() + .find(|violation| matches!(violation, PolicyViolation::TcpEndpointIpLiteral { .. })) + .expect("TCP IP-literal violation"); + assert!( + violation + .to_string() + .contains("direct IP connections bypass policy DNS and are blocked"), + "unexpected diagnostic: {violation}" + ); + } + } + + #[test] + fn validate_rejects_malformed_dns_selectors_for_explicit_tcp() { + for (host, expected_reason) in [ + (" db.example.com", "leading or trailing whitespace"), + ("db.example.com.", "omit the trailing DNS root dot"), + ("db..example.com", "empty DNS labels"), + ("bad name.example.com", "whitespace"), + ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com", + "cannot represent a valid DNS name", + ), + ] { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "native-tcp".into(), + NetworkPolicyRule { + name: "native-tcp".into(), + endpoints: vec![NetworkEndpoint { + host: host.into(), + port: 6379, + protocol: "tcp".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy) + .expect_err("malformed transparent TCP hostname must be rejected"); + let violation = violations + .iter() + .find(|violation| { + matches!(violation, PolicyViolation::InvalidTcpEndpointHost { .. }) + }) + .expect("invalid TCP hostname violation"); + assert!( + violation.to_string().contains(expected_reason), + "expected {expected_reason:?} in diagnostic for {host:?}, got {violation}" + ); + } + } + #[test] fn validate_rejects_raw_endpoint_zero_and_out_of_range_ports() { let mut policy = restrictive_default_policy(); diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index c1dbada149..56a380b19c 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -5495,12 +5495,22 @@ filesystem_policy: "fail_closed" ); assert_eq!(config["unmapped"]["previous_policy_active"], false); + assert_eq!( + config["unmapped"]["validation_error"], + "conflicting tls metadata" + ); assert!( config["message"] .as_str() .unwrap() .contains("previous policy IS NOT active") ); + assert!( + config["message"] + .as_str() + .unwrap() + .contains("error:conflicting tls metadata") + ); let finding = finding.to_json().unwrap(); assert_eq!(finding["class_uid"], 2004); diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 52edfd273d..a81e597f59 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -160,16 +160,16 @@ Each endpoint defines a reachable destination and optional inspection rules. | Field | Type | Required | Description | |---|---|---|---| -| `host` | string | Yes | Hostname or IP address. Supports a `*` wildcard inside the first DNS label only: `*.example.com`, `**.example.com`, and intra-label patterns like `*-aiplatform.googleapis.com` are accepted; bare `*`/`**`, TLD wildcards (`*.com`), and wildcards outside the first label are rejected at load time. | +| `host` | string | Conditional | Hostname or IP address. Required for `protocol: tcp`; transparent TCP requires a valid DNS hostname and rejects literal IPs. A non-TCP proxy endpoint may omit `host` only when `allowed_ips` supplies the destination constraint. Supports a `*` wildcard inside the first DNS label only: `*.example.com`, `**.example.com`, and intra-label patterns like `*-aiplatform.googleapis.com` are accepted; bare `*`/`**`, TLD wildcards (`*.com`), and wildcards outside the first label are rejected at load time. | | `port` | integer | Yes | TCP port number. | | `path` | string | No | Optional HTTP path glob used to select between L7 endpoints that share the same host and port. Empty means all paths. Use this when REST and GraphQL live under the same host, such as `/repos/**` and `/graphql`. | -| `protocol` | string | No | Set to `tcp` for explicit L4 TCP passthrough without payload inspection. Omitting the field has the same L4 behavior. Set to `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, `graphql` for GraphQL-over-HTTP operation inspection, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Provider-credentialed endpoints require an inspected protocol unless `allow_uninspected_credentials` is explicitly set. | +| `protocol` | string | No | Set to `tcp` for explicit L4 TCP passthrough without payload inspection. Omitting the field has the same payload-handling behavior, but only explicit `tcp` reserves the transparent-TCP path and therefore requires a valid DNS hostname. Set to `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, `graphql` for GraphQL-over-HTTP operation inspection, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Provider-credentialed endpoints require an inspected protocol unless `allow_uninspected_credentials` is explicitly set. | | `tls` | string | No | TLS handling mode. The proxy auto-detects TLS by peeking the first bytes of each connection and terminates it for inspected HTTPS traffic, so this field is optional in most cases. Set to `skip` to disable auto-detection for edge cases such as client-certificate mTLS or non-standard protocols. Provider-credentialed endpoints reject `tls: skip` unless `allow_uninspected_credentials` is explicitly set. The values `terminate` and `passthrough` are deprecated and log a warning; they are still accepted for backward compatibility but have no effect on behavior. | | `enforcement` | string | No | `enforce` actively blocks disallowed requests. `audit` logs violations but allows traffic through. | | `access` | string | No | Access preset. One of `read-only`, `read-write`, or `full`. Mutually exclusive with `rules`. Not valid on `protocol: mcp` or `protocol: json-rpc`; MCP uses explicit rules unless `mcp.allow_all_known_mcp_methods: true` enables the endpoint method profile, and JSON-RPC always uses explicit rules. | | `rules` | list of allow rule objects | No | Fine-grained protocol-specific allow rules. Mutually exclusive with `access`. | | `deny_rules` | list of deny rule objects | No | L7 deny rules that block specific requests even when allowed by `access` or `rules`. Deny rules take precedence over allow rules. | -| `allowed_ips` | list of string | No | CIDR or IP allowlist for SSRF override. Exact user-declared hostname endpoints may resolve to RFC 1918 private addresses without this field, but wildcard, hostless, and policy-advisor-proposed endpoints still require `allowed_ips` for private resolved IPs. Entries overlapping loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), or unspecified (`0.0.0.0`) are rejected at load time. | +| `allowed_ips` | list of string | No | CIDR or IP allowlist for SSRF override. Exact user-declared hostname endpoints may resolve to RFC 1918 private addresses without this field, but wildcard, hostless, and policy-advisor-proposed endpoints still require `allowed_ips` for private resolved IPs. A hostless allowlist is valid only for the legacy proxy path and cannot be combined with `protocol: tcp`. Entries overlapping loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), or unspecified (`0.0.0.0`) are rejected at load time. | | `allow_encoded_slash` | bool | No | When `true`, L7 request parsing preserves `%2F` inside path segments instead of rejecting it. Use this for registries and APIs such as npm scoped packages (`/@scope%2Fname`). Defaults to `false`. | | `websocket_credential_rewrite` | bool | No | When `true` on a `protocol: rest` or `protocol: websocket` endpoint, OpenShell rewrites credential placeholders in client-to-server WebSocket text messages after an allowed HTTP `101` upgrade. On provider-credentialed endpoints without `allow_uninspected_credentials`, OpenShell uses the parsed relay and rejects binary frames; text frames containing placeholders fail closed when rewrite is disabled. Defaults to `false`. | | `request_body_credential_rewrite` | bool | No | When `true` on a `protocol: rest` endpoint, OpenShell rewrites credential placeholders in UTF-8 `application/json`, `application/x-www-form-urlencoded`, and `text/*` request bodies before forwarding upstream. The proxy buffers at most 256 KiB and updates `Content-Length` after rewriting. For chunked requests, the limit counts framing, extensions, and trailers. When rewrite is disabled and the sandbox has provider credentials, ordinary bodies continue to stream, but a reserved credential placeholder is rejected before its marker reaches upstream, including for providers without endpoint profiles. Defaults to `false`. Mutually exclusive with `credential_signing`. | @@ -191,6 +191,7 @@ Each endpoint defines a reachable destination and optional inspection rules. **Validation constraints:** - `access` and `rules` are mutually exclusive; setting both is rejected. +- `protocol: tcp` requires a valid DNS hostname. Hostless `allowed_ips`, IP-literal hosts, trailing-dot names, and malformed DNS selectors are rejected with a policy-validation error. - `protocol: tcp` rejects L7-only fields, including `path`, `enforcement`, `access`, `rules`, `deny_rules`, request rewriting and credential signing fields, and GraphQL, JSON-RPC, or MCP options. - When `protocol` is set, at least one of `access` or `rules` is required for `rest`, `websocket`, `graphql`, and `sql`. - `mcp` and `json-rpc` reject `access` presets; use explicit `rules`. diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index 02aaefe8a7..97f97aeccd 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -177,6 +177,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -202,6 +223,21 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crypto-common" version = "0.1.7" @@ -212,6 +248,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deranged" version = "0.5.8" @@ -371,6 +413,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core", ] [[package]] @@ -425,6 +468,25 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand", + "ring", + "thiserror", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "http" version = "1.4.2" @@ -665,6 +727,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -868,6 +979,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "openshell-core" @@ -931,6 +1046,7 @@ dependencies = [ name = "openshell-policy" version = "0.0.0" dependencies = [ + "hickory-proto", "miette", "openshell-core", "prost-types", @@ -1050,6 +1166,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1249,6 +1371,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rcgen" version = "0.13.2" @@ -1320,6 +1459,15 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1392,6 +1540,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1430,6 +1587,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1505,7 +1668,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1525,6 +1688,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -1713,6 +1892,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -1979,6 +2173,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2039,6 +2243,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" From 50d8d9991a5a48a9c2345e4eec3c5098769f4472 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:17:58 -0700 Subject: [PATCH 13/17] feat(network): add policy DNS correlation foundation Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- Cargo.lock | 1 + .../openshell-supervisor-network/Cargo.toml | 1 + .../data/sandbox-policy.rego | 25 + .../openshell-supervisor-network/src/lib.rs | 1 + .../openshell-supervisor-network/src/opa.rs | 117 +++ .../src/policy_dns/mod.rs | 542 ++++++++++++++ .../src/policy_dns/name.rs | 72 ++ .../src/policy_dns/resolver.rs | 479 ++++++++++++ .../src/policy_dns/store.rs | 699 ++++++++++++++++++ .../src/policy_dns/wire.rs | 280 +++++++ .../openshell-supervisor-network/src/proxy.rs | 2 +- .../src/proxy/destination.rs | 245 +++++- 12 files changed, 2439 insertions(+), 25 deletions(-) create mode 100644 crates/openshell-supervisor-network/src/policy_dns/mod.rs create mode 100644 crates/openshell-supervisor-network/src/policy_dns/name.rs create mode 100644 crates/openshell-supervisor-network/src/policy_dns/resolver.rs create mode 100644 crates/openshell-supervisor-network/src/policy_dns/store.rs create mode 100644 crates/openshell-supervisor-network/src/policy_dns/wire.rs diff --git a/Cargo.lock b/Cargo.lock index cf15ebb610..e3a3fe2d34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4291,6 +4291,7 @@ dependencies = [ "futures", "glob", "hex", + "hickory-proto", "http 1.4.0", "ipnet", "libc", diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index ae36554be7..0f3191c5d8 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -27,6 +27,7 @@ bytes = { workspace = true } flate2 = "1" glob = { workspace = true } hex = "0.4" +hickory-proto = "0.26.1" ipnet = "2" miette = { workspace = true } prost-types = { workspace = true } diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index 2c8204974a..87469cc6dd 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -921,6 +921,31 @@ _matching_endpoint_records := [record | record := records[_] ] +# Endpoints eligible for policy DNS are a policy-data snapshot, not an +# authorization decision. In particular, they do not depend on input.exec or +# grant access to any process. Only endpoints that explicitly opt into raw TCP +# and provide a resolvable host plus concrete ports are materialized. +policy_dns_eligible_endpoint_records := [record | + some policy_name + policy := data.network_policies[policy_name] + some endpoint_index + ep := policy.endpoints[endpoint_index] + lower(object.get(ep, "protocol", "")) == "tcp" + object.get(ep, "host", "") != "" + ports := object.get(ep, "ports", []) + count(ports) > 0 + every port in ports { + is_number(port) + port >= 1 + port <= 65535 + } + record := { + "policy_name": policy_name, + "endpoint_index": endpoint_index, + "endpoint": ep, + } +] + matched_endpoint_config := _matching_endpoint_configs[0] if { count(_matching_endpoint_configs) > 0 } diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index f5d0205e3a..ccb0a4d166 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -12,6 +12,7 @@ pub mod identity; pub mod inference_routes; pub mod l7; pub mod opa; +pub(crate) mod policy_dns; pub mod policy_local; pub mod procfs; pub mod proxy; diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 3cdd53a0e7..f5a7d77de1 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -58,6 +58,16 @@ pub struct MatchedEndpoint { pub endpoint: regorus::Value, } +/// Policy-DNS eligible endpoint metadata captured from one policy generation. +/// +/// This is policy data only. It deliberately contains no process identity or +/// network authorization decision. +#[derive(Debug, Clone)] +pub struct PolicyDnsEligibilitySnapshot { + pub endpoints: Vec, + pub generation: u64, +} + /// Atomic policy result used to authorize and materialize one egress request. #[derive(Debug, Clone)] pub struct EgressAuthorization { @@ -598,6 +608,48 @@ impl OpaEngine { }) } + /// Return all explicit TCP endpoints eligible for policy DNS. + /// + /// The owned endpoint records and generation are captured while holding + /// the engine lock, so reloads cannot mix data from one generation with + /// the generation number of another. Fail-closed quarantine produces an + /// empty snapshot for its quarantine generation. + pub fn policy_dns_eligibility_snapshot(&self) -> Result { + let mut engine = self + .engine + .lock() + .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; + let generation = self.current_generation(); + + if self + .fail_closed_reason + .read() + .map_err(|_| miette::miette!("OPA fail-closed state lock poisoned"))? + .is_some() + { + return Ok(PolicyDnsEligibilitySnapshot { + endpoints: Vec::new(), + generation, + }); + } + + let value = engine + .eval_rule("data.openshell.sandbox.policy_dns_eligible_endpoint_records".into()) + .map_err(|error| miette::miette!("{error}"))?; + let endpoints = match value { + regorus::Value::Array(values) => { + values.iter().filter_map(parse_matched_endpoint).collect() + } + regorus::Value::Undefined => Vec::new(), + other => parse_matched_endpoint(&other).into_iter().collect(), + }; + + Ok(PolicyDnsEligibilitySnapshot { + endpoints, + generation, + }) + } + /// Reload policy and data from strings (data is YAML). /// /// Designed for future gRPC hot-reload from the openshell gateway. @@ -2074,6 +2126,71 @@ mod tests { } } + const POLICY_DNS_SNAPSHOT_DATA: &str = r#" +network_policies: + dns_transport: + name: dns_transport + endpoints: + - { host: resolver.example, ports: [53, 853], protocol: tcp } + - { host: web.example, port: 443, protocol: rest, access: full } + - { host: implicit.example, port: 443 } + - { host: "", port: 53, protocol: tcp, allowed_ips: [8.8.8.8] } + - { host: secondary.example, port: 5353, protocol: tcp } + binaries: + - { path: /usr/bin/one-process } +filesystem_policy: + include_workdir: true + read_only: [] + read_write: [] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +"#; + + #[test] + fn policy_dns_snapshot_is_tcp_only_stable_and_generation_consistent() { + let engine = OpaEngine::from_strings(TEST_POLICY, POLICY_DNS_SNAPSHOT_DATA).unwrap(); + + let snapshot = engine.policy_dns_eligibility_snapshot().unwrap(); + + assert_eq!(snapshot.generation, engine.current_generation()); + assert_eq!(snapshot.endpoints.len(), 2); + assert_eq!(snapshot.endpoints[0].policy_name, "dns_transport"); + assert_eq!(snapshot.endpoints[0].endpoint_index, 0); + assert_eq!( + get_str(&snapshot.endpoints[0].endpoint, "host").as_deref(), + Some("resolver.example") + ); + let Some(regorus::Value::Array(ports)) = + get_field(&snapshot.endpoints[0].endpoint, "ports") + else { + panic!("eligible endpoint must retain concrete ports"); + }; + assert_eq!(ports.as_ref(), &[53.into(), 853.into()]); + assert_eq!(snapshot.endpoints[1].endpoint_index, 4); + + engine + .reload(TEST_POLICY, POLICY_DNS_SNAPSHOT_DATA) + .unwrap(); + let reloaded = engine.policy_dns_eligibility_snapshot().unwrap(); + assert_eq!(reloaded.generation, snapshot.generation + 1); + assert_eq!(reloaded.endpoints.len(), 2); + assert_eq!(reloaded.endpoints[1].endpoint_index, 4); + } + + #[test] + fn policy_dns_snapshot_is_empty_during_fail_closed_quarantine() { + let engine = OpaEngine::from_strings(TEST_POLICY, POLICY_DNS_SNAPSHOT_DATA).unwrap(); + let generation = engine.enter_fail_closed("invalid candidate").unwrap(); + + let snapshot = engine.policy_dns_eligibility_snapshot().unwrap(); + + assert_eq!(snapshot.generation, generation); + assert!(snapshot.endpoints.is_empty()); + } + #[test] fn allowed_binary_and_endpoint() { let engine = test_engine(); diff --git a/crates/openshell-supervisor-network/src/policy_dns/mod.rs b/crates/openshell-supervisor-network/src/policy_dns/mod.rs new file mode 100644 index 0000000000..f9ef3cc557 --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_dns/mod.rs @@ -0,0 +1,542 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![allow( + clippy::redundant_pub_crate, + reason = "the crate-private API is consumed by the runtime activation slice" +)] + +//! Dormant policy-gated DNS and synthetic resolved-endpoint correlation. +//! +//! This module implements the DNS security boundary and mapping state only. +//! Runtime listener startup, resolver injection, and transparent TCP capture +//! intentionally land in later stack entries. + +#![allow( + dead_code, + unused_imports, + reason = "PR2 exposes a dormant library boundary consumed by PR3 runtime wiring" +)] + +mod name; +mod resolver; +mod store; +mod wire; + +pub(crate) use name::NormalizedName; +pub(crate) use resolver::{AddressFamily, SocketTrustedResolver, TrustedAnswer, TrustedResolver}; +pub(crate) use store::{ + MappingLookup, MappingLookupError, PolicyDnsMetricsSnapshot, PolicyEndpointId, PublishError, + PublishRequest, ResolvedEndpointRecord, ResolvedEndpointStore, ResolvedPortContract, + StoreConfig, SyntheticPools, +}; + +use crate::opa::OpaEngine; +use crate::proxy::destination::{build_validation_plan, filter_resolved_addresses}; +use openshell_core::host_pattern::HostSelector; +use openshell_ocsf::{ + ActionId, ActivityId, ConfigStateChangeBuilder, DispositionId, Endpoint, + NetworkActivityBuilder, SeverityId, StateId, StatusId, ocsf_emit, +}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +pub(crate) const MIN_MAPPING_TTL: Duration = Duration::from_secs(1); +pub(crate) const MAX_MAPPING_TTL: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SyntheticAnswer { + pub(crate) address: std::net::IpAddr, + pub(crate) ttl: Duration, + pub(crate) mapping_id: uuid::Uuid, + pub(crate) mapping_generation: u64, + pub(crate) policy_generation: u64, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum PolicyDnsError { + #[error("DNS query name is invalid")] + InvalidName, + #[error("DNS name is not eligible for policy DNS")] + Ineligible, + #[error("trusted resolver failed: {0}")] + Resolver(#[from] resolver::ResolveError), + #[error("no trusted resolver address passed endpoint destination policy")] + NoValidAddress, + #[error("policy generation changed before DNS mapping publication")] + StalePolicy, + #[error("resolved endpoint mapping could not be published: {0}")] + Publish(#[from] PublishError), + #[error("policy DNS eligibility snapshot failed: {0}")] + Policy(String), +} + +/// Policy-gated DNS evaluator and synthetic mapping publisher. +/// +/// No socket is bound by this type. A later runtime adapter owns listener and +/// namespace lifecycle and calls the bounded wire helpers in this module. +pub(crate) struct PolicyDnsService { + policy: Arc, + resolver: R, + store: Arc, +} + +impl PolicyDnsService { + pub(crate) fn new( + policy: Arc, + resolver: R, + store: Arc, + ) -> Self { + Self { + policy, + resolver, + store, + } + } + + pub(crate) async fn answer_query( + &self, + raw_name: &str, + family: AddressFamily, + now: Instant, + ) -> Result { + self.store.note_query(); + let normalized_name = + NormalizedName::parse(raw_name).map_err(|_| PolicyDnsError::InvalidName)?; + let snapshot = self + .policy + .policy_dns_eligibility_snapshot() + .map_err(|error| PolicyDnsError::Policy(error.to_string()))?; + let eligible = eligible_endpoints(&snapshot.endpoints, &normalized_name)?; + if eligible.is_empty() { + self.store.note_refused(); + emit_dns_denial( + &normalized_name, + "policy_dns_ineligible", + "Policy DNS refused a name that is not eligible in the active policy", + ); + return Err(PolicyDnsError::Ineligible); + } + + // The trusted resolver is invoked only after the immutable snapshot + // proved policy eligibility. It never consults sandbox resolver state. + self.store.note_upstream_query(); + let trusted_answer = self.resolver.resolve(&normalized_name, family).await?; + let ttl = clamp_mapping_ttl(trusted_answer.ttl); + let allocation_identity = allocation_identity(&eligible); + let mut contracts = Vec::new(); + for endpoint in eligible { + for port in endpoint.ports { + let Ok(pinned_addresses) = filter_resolved_addresses( + &endpoint.destination_plan, + normalized_name.as_str(), + port, + &trusted_answer.addresses, + ) else { + continue; + }; + contracts.push(ResolvedPortContract { + endpoint_id: endpoint.endpoint_id.clone(), + port, + destination_plan: endpoint.destination_plan.clone(), + pinned_addresses, + }); + } + } + contracts.sort_by(|left, right| { + (&left.endpoint_id, left.port).cmp(&(&right.endpoint_id, right.port)) + }); + if contracts.is_empty() { + self.store.note_no_valid_address(); + emit_dns_denial( + &normalized_name, + "policy_dns_no_valid_address", + "Policy DNS rejected every trusted resolver address", + ); + return Err(PolicyDnsError::NoValidAddress); + } + + let current_generation = self.policy.current_generation(); + if current_generation != snapshot.generation { + return Err(PolicyDnsError::StalePolicy); + } + let record = self.store.publish( + PublishRequest { + normalized_name: normalized_name.clone(), + family, + allocation_identity, + policy_generation: snapshot.generation, + ttl, + contracts, + }, + current_generation, + now, + )?; + emit_mapping_publication(&record); + Ok(SyntheticAnswer { + address: record.synthetic_address, + ttl, + mapping_id: record.mapping_id, + mapping_generation: record.mapping_generation, + policy_generation: record.policy_generation, + }) + } + + pub(crate) fn store(&self) -> &Arc { + &self.store + } +} + +struct EligibleEndpoint { + endpoint_id: PolicyEndpointId, + ports: Vec, + destination_plan: crate::proxy::destination::DestinationValidationPlan, + contract_fingerprint: String, +} + +fn eligible_endpoints( + endpoints: &[crate::opa::MatchedEndpoint], + name: &NormalizedName, +) -> Result, PolicyDnsError> { + let mut eligible = Vec::new(); + for endpoint in endpoints { + let Some(pattern) = value_string(&endpoint.endpoint, "host") else { + continue; + }; + let pattern = pattern.trim_end_matches('.').to_ascii_lowercase(); + let selector = HostSelector::new(std::slice::from_ref(&pattern), &[]) + .map_err(PolicyDnsError::Policy)?; + if !selector.matches(name.as_str()) { + continue; + } + let ports = value_ports(&endpoint.endpoint); + if ports.is_empty() { + continue; + } + let raw_allowed_ips = value_string_array(&endpoint.endpoint, "allowed_ips"); + let exact_declared_host = !pattern.contains('*') && pattern == name.as_str(); + let destination_plan = build_validation_plan( + name.as_str(), + name.as_str(), + None, + &raw_allowed_ips, + exact_declared_host, + ) + .map_err(|error| PolicyDnsError::Policy(error.reason))?; + eligible.push(EligibleEndpoint { + endpoint_id: PolicyEndpointId { + policy_name: endpoint.policy_name.clone(), + endpoint_index: endpoint.endpoint_index, + }, + ports, + destination_plan, + contract_fingerprint: endpoint.endpoint.to_string(), + }); + } + Ok(eligible) +} + +fn allocation_identity(endpoints: &[EligibleEndpoint]) -> [u8; 32] { + let mut contracts = endpoints + .iter() + .map(|endpoint| { + format!( + "{}\0{}\0{}", + endpoint.endpoint_id.policy_name, + endpoint.endpoint_id.endpoint_index, + endpoint.contract_fingerprint + ) + }) + .collect::>(); + contracts.sort(); + let mut hasher = Sha256::new(); + for contract in contracts { + hasher.update(contract.as_bytes()); + hasher.update([0xff]); + } + hasher.finalize().into() +} + +fn value_field<'a>(value: &'a regorus::Value, key: &str) -> Option<&'a regorus::Value> { + let regorus::Value::Object(fields) = value else { + return None; + }; + fields.get(®orus::Value::String(key.into())) +} + +fn value_string(value: ®orus::Value, key: &str) -> Option { + match value_field(value, key) { + Some(regorus::Value::String(value)) => Some(value.to_string()), + _ => None, + } +} + +fn value_string_array(value: ®orus::Value, key: &str) -> Vec { + match value_field(value, key) { + Some(regorus::Value::Array(values)) => values + .iter() + .filter_map(|value| match value { + regorus::Value::String(value) => Some(value.to_string()), + _ => None, + }) + .collect(), + _ => Vec::new(), + } +} + +fn value_ports(value: ®orus::Value) -> Vec { + let mut ports = match value_field(value, "ports") { + Some(regorus::Value::Array(values)) => values + .iter() + .filter_map(|value| match value { + regorus::Value::Number(number) => number + .as_i64() + .and_then(|port| u16::try_from(port).ok()) + .filter(|port| *port != 0), + _ => None, + }) + .collect::>(), + _ => Vec::new(), + }; + ports.sort_unstable(); + ports.dedup(); + ports +} + +fn clamp_mapping_ttl(ttl: Duration) -> Duration { + ttl.max(MIN_MAPPING_TTL).min(MAX_MAPPING_TTL) +} + +fn emit_dns_denial(name: &NormalizedName, detail: &str, message: &str) { + ocsf_emit!( + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Refuse) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(name.as_str(), 53)) + .status_detail(detail) + .message(message) + .build() + ); +} + +fn emit_mapping_publication(record: &ResolvedEndpointRecord) { + ocsf_emit!( + ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "published") + .unmapped("normalized_name", record.normalized_name.as_str()) + .unmapped("address_family", format!("{:?}", record.family)) + .unmapped("allowed_port_count", record.allowed_ports().len() as u64) + .unmapped("policy_generation", record.policy_generation) + .unmapped("mapping_generation", record.mapping_generation) + .unmapped("mapping_id", record.mapping_id.to_string()) + .message("Policy DNS resolved-endpoint mapping published") + .build() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::sync::Notify; + + struct FakeResolver { + calls: AtomicUsize, + answer: TrustedAnswer, + } + + impl TrustedResolver for FakeResolver { + async fn resolve( + &self, + _name: &NormalizedName, + _family: AddressFamily, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(self.answer.clone()) + } + } + + fn service(policy_yaml: &str, addresses: Vec) -> PolicyDnsService { + let policy = Arc::new( + OpaEngine::from_strings(include_str!("../../data/sandbox-policy.rego"), policy_yaml) + .unwrap(), + ); + let pools = SyntheticPools::new( + Ipv4Addr::new(198, 18, 0, 1)..=Ipv4Addr::new(198, 18, 0, 8), + "fd00:1::1".parse::().unwrap()..="fd00:1::8".parse::().unwrap(), + ) + .unwrap(); + PolicyDnsService::new( + policy, + FakeResolver { + calls: AtomicUsize::new(0), + answer: TrustedAnswer { + addresses, + ttl: Duration::from_secs(300), + }, + }, + Arc::new(ResolvedEndpointStore::new( + StoreConfig::new(pools, 16).unwrap(), + )), + ) + } + + const BASE_POLICY: &str = r" +network_policies: + database: + name: database + endpoints: + - { host: db.example, port: 5432, protocol: tcp } + binaries: [{ path: /usr/bin/psql }] +filesystem_policy: { include_workdir: true, read_only: [], read_write: [] } +landlock: { compatibility: best_effort } +process: { run_as_user: sandbox, run_as_group: sandbox } +"; + + #[tokio::test] + async fn refuses_ineligible_name_before_upstream_resolution() { + let service = service(BASE_POLICY, vec!["8.8.8.8".parse().unwrap()]); + let result = service + .answer_query("other.example", AddressFamily::Ipv4, Instant::now()) + .await; + assert!(matches!(result, Err(PolicyDnsError::Ineligible))); + assert_eq!(service.resolver.calls.load(Ordering::SeqCst), 0); + assert_eq!(service.store.metrics(Instant::now()).refused, 1); + } + + #[tokio::test] + async fn eligible_name_filters_answers_and_publishes_bounded_mapping() { + let service = service( + BASE_POLICY, + vec!["127.0.0.1".parse().unwrap(), "10.2.3.4".parse().unwrap()], + ); + let now = Instant::now(); + let answer = service + .answer_query("DB.EXAMPLE.", AddressFamily::Ipv4, now) + .await + .unwrap(); + assert_eq!(answer.ttl, MAX_MAPPING_TTL); + let mapping = service + .store + .lookup(answer.address, 5432, answer.policy_generation, now) + .unwrap(); + assert_eq!(mapping.record.normalized_name.as_str(), "db.example"); + assert_eq!( + mapping.record.contracts[0].pinned_addresses, + ["10.2.3.4".parse::().unwrap()] + ); + } + + #[tokio::test] + async fn wildcard_is_eligible_but_uses_public_only_destination_rules() { + let yaml = BASE_POLICY.replace("db.example", "'*.example.com'"); + let service = service(&yaml, vec!["10.2.3.4".parse().unwrap()]); + let result = service + .answer_query("db.example.com", AddressFamily::Ipv4, Instant::now()) + .await; + assert!(matches!(result, Err(PolicyDnsError::NoValidAddress))); + } + + #[tokio::test] + async fn allowed_ips_filters_each_answer_without_rejecting_usable_addresses() { + let yaml = + BASE_POLICY.replace("protocol: tcp", "protocol: tcp, allowed_ips: [10.2.0.0/16]"); + let service = service( + &yaml, + vec!["10.3.4.5".parse().unwrap(), "10.2.3.4".parse().unwrap()], + ); + let now = Instant::now(); + let answer = service + .answer_query("db.example", AddressFamily::Ipv4, now) + .await + .unwrap(); + let mapping = service + .store + .lookup(answer.address, 5432, answer.policy_generation, now) + .unwrap(); + assert_eq!( + mapping.record.contracts[0].pinned_addresses, + ["10.2.3.4".parse::().unwrap()] + ); + } + + struct BlockingResolver { + started: Arc, + release: Arc, + } + + impl TrustedResolver for BlockingResolver { + async fn resolve( + &self, + _name: &NormalizedName, + _family: AddressFamily, + ) -> Result { + self.started.notify_one(); + self.release.notified().await; + Ok(TrustedAnswer { + addresses: vec!["8.8.8.8".parse().unwrap()], + ttl: Duration::from_secs(10), + }) + } + } + + #[tokio::test] + async fn policy_reload_during_resolution_publishes_nothing() { + let policy = Arc::new( + OpaEngine::from_strings(include_str!("../../data/sandbox-policy.rego"), BASE_POLICY) + .unwrap(), + ); + let pools = SyntheticPools::new( + Ipv4Addr::new(198, 18, 0, 1)..=Ipv4Addr::new(198, 18, 0, 2), + "fd00:1::1".parse::().unwrap()..="fd00:1::2".parse::().unwrap(), + ) + .unwrap(); + let store = Arc::new(ResolvedEndpointStore::new( + StoreConfig::new(pools, 4).unwrap(), + )); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let service = Arc::new(PolicyDnsService::new( + policy.clone(), + BlockingResolver { + started: started.clone(), + release: release.clone(), + }, + store.clone(), + )); + let query = tokio::spawn(async move { + service + .answer_query("db.example", AddressFamily::Ipv4, Instant::now()) + .await + }); + started.notified().await; + policy + .reload(include_str!("../../data/sandbox-policy.rego"), BASE_POLICY) + .unwrap(); + release.notify_one(); + assert!(matches!( + query.await.unwrap(), + Err(PolicyDnsError::StalePolicy) + )); + let metrics = store.metrics(Instant::now()); + assert_eq!(metrics.active_mappings, 0); + assert_eq!(metrics.allocated_identities, 0); + } + + #[test] + fn ttl_is_floored_and_capped() { + assert_eq!(clamp_mapping_ttl(Duration::ZERO), MIN_MAPPING_TTL); + assert_eq!( + clamp_mapping_ttl(Duration::from_secs(10)), + Duration::from_secs(10) + ); + assert_eq!(clamp_mapping_ttl(Duration::from_secs(300)), MAX_MAPPING_TTL); + } +} diff --git a/crates/openshell-supervisor-network/src/policy_dns/name.rs b/crates/openshell-supervisor-network/src/policy_dns/name.rs new file mode 100644 index 0000000000..a2c9acb66d --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_dns/name.rs @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Canonical DNS-name handling for policy lookup and correlation keys. + +use hickory_proto::rr::Name; +use std::fmt; + +/// A lower-case absolute DNS name without its presentation trailing dot. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct NormalizedName(String); + +impl NormalizedName { + pub(crate) fn parse(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.parse::().is_ok() { + return Err(NameError); + } + + let absolute = if trimmed.ends_with('.') { + trimmed.to_string() + } else { + format!("{trimmed}.") + }; + let parsed = Name::from_ascii(&absolute).map_err(|_| NameError)?; + if parsed.is_root() { + return Err(NameError); + } + + let normalized = parsed.to_ascii().trim_end_matches('.').to_ascii_lowercase(); + if normalized.is_empty() { + return Err(NameError); + } + Ok(Self(normalized)) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } + + pub(crate) fn as_absolute_name(&self) -> Name { + Name::from_ascii(format!("{}.", self.0)).expect("normalized DNS name must remain valid") + } +} + +impl fmt::Display for NormalizedName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct NameError; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_case_and_trailing_dot() { + let lower = NormalizedName::parse("Db.Example.COM.").unwrap(); + assert_eq!(lower.as_str(), "db.example.com"); + assert_eq!(NormalizedName::parse("db.example.com").unwrap(), lower); + } + + #[test] + fn rejects_empty_root_ip_literals_and_invalid_labels() { + for raw in ["", ".", "192.0.2.10", "2001:db8::1", "bad name.example"] { + assert!(NormalizedName::parse(raw).is_err(), "accepted {raw:?}"); + } + } +} diff --git a/crates/openshell-supervisor-network/src/policy_dns/resolver.rs b/crates/openshell-supervisor-network/src/policy_dns/resolver.rs new file mode 100644 index 0000000000..8a31f77856 --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_dns/resolver.rs @@ -0,0 +1,479 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Bounded DNS exchange with an explicitly configured trusted resolver. +//! +//! This module never reads sandbox resolver state or `/etc/hosts`. The caller +//! supplies an already-parsed resolver socket address, and Hickory owns all DNS +//! wire encoding and decoding. + +use super::name::NormalizedName; +use hickory_proto::op::{Message, MessageType, OpCode, Query, ResponseCode}; +use hickory_proto::rr::{Name, RData, RecordType}; +use openshell_core::net::connect_tcp_nodelay_best_effort; +use std::collections::{BTreeMap, BTreeSet}; +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UdpSocket; +use tokio::time::timeout; + +pub(crate) const DEFAULT_EXCHANGE_TIMEOUT: Duration = Duration::from_secs(2); +pub(crate) const MAX_DNS_MESSAGE_BYTES: usize = 8 * 1024; +pub(crate) const MAX_RETAINED_ADDRESSES: usize = 16; +pub(crate) const MAX_CNAME_HOPS: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum AddressFamily { + Ipv4, + Ipv6, +} + +impl AddressFamily { + pub(crate) fn record_type(self) -> RecordType { + match self { + Self::Ipv4 => RecordType::A, + Self::Ipv6 => RecordType::AAAA, + } + } + + pub(crate) fn accepts(self, address: IpAddr) -> bool { + matches!( + (self, address), + (Self::Ipv4, IpAddr::V4(_)) | (Self::Ipv6, IpAddr::V6(_)) + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TrustedAnswer { + pub(crate) addresses: Vec, + pub(crate) ttl: Duration, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ResolveError { + #[error("trusted DNS exchange timed out")] + Timeout, + #[error("trusted DNS I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("trusted DNS response exceeded the configured size bound")] + Oversized, + #[error("trusted DNS response was malformed or did not match the query")] + Malformed, + #[error("trusted DNS returned NXDOMAIN")] + NxDomain, + #[error("trusted DNS returned response code {0:?}")] + Response(ResponseCode), + #[error("trusted DNS returned no usable address records")] + NoData, + #[error("trusted DNS CNAME chain looped or exceeded the hop limit")] + CnameLimit, +} + +#[allow(async_fn_in_trait)] +pub(crate) trait TrustedResolver: Send + Sync { + async fn resolve( + &self, + name: &NormalizedName, + family: AddressFamily, + ) -> Result; +} + +/// A DNS client pinned to one operator-supplied upstream socket address. +pub(crate) struct SocketTrustedResolver { + server: SocketAddr, + exchange_timeout: Duration, +} + +impl SocketTrustedResolver { + pub(crate) fn new(server: SocketAddr) -> Self { + Self { + server, + exchange_timeout: DEFAULT_EXCHANGE_TIMEOUT, + } + } + + #[cfg(test)] + pub(crate) fn with_timeout(server: SocketAddr, exchange_timeout: Duration) -> Self { + Self { + server, + exchange_timeout, + } + } + + async fn exchange(&self, name: Name, record_type: RecordType) -> Result { + let query = Query::query(name, record_type); + let mut request = Message::query(); + let id = request.metadata.id; + request.metadata.recursion_desired = true; + request.queries.push(query.clone()); + let wire = request.to_vec().map_err(|_| ResolveError::Malformed)?; + + let udp_response = self.udp_exchange(&wire).await?; + let response = parse_response(&udp_response, id, &query)?; + if response.metadata.truncation { + let tcp_response = self.tcp_exchange(&wire).await?; + parse_response(&tcp_response, id, &query) + } else { + Ok(response) + } + } + + async fn udp_exchange(&self, request: &[u8]) -> Result, ResolveError> { + let bind = if self.server.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + }; + let socket = UdpSocket::bind(bind).await?; + socket.connect(self.server).await?; + + timeout(self.exchange_timeout, socket.send(request)) + .await + .map_err(|_| ResolveError::Timeout)??; + let mut response = vec![0_u8; MAX_DNS_MESSAGE_BYTES + 1]; + let received = timeout(self.exchange_timeout, socket.recv(&mut response)) + .await + .map_err(|_| ResolveError::Timeout)??; + if received > MAX_DNS_MESSAGE_BYTES { + return Err(ResolveError::Oversized); + } + response.truncate(received); + Ok(response) + } + + async fn tcp_exchange(&self, request: &[u8]) -> Result, ResolveError> { + let mut stream = timeout( + self.exchange_timeout, + connect_tcp_nodelay_best_effort(&[self.server]), + ) + .await + .map_err(|_| ResolveError::Timeout)??; + + let request_len = u16::try_from(request.len()).map_err(|_| ResolveError::Oversized)?; + timeout(self.exchange_timeout, stream.write_u16(request_len)) + .await + .map_err(|_| ResolveError::Timeout)??; + timeout(self.exchange_timeout, stream.write_all(request)) + .await + .map_err(|_| ResolveError::Timeout)??; + + let response_len = timeout(self.exchange_timeout, stream.read_u16()) + .await + .map_err(|_| ResolveError::Timeout)?? as usize; + if response_len > MAX_DNS_MESSAGE_BYTES { + return Err(ResolveError::Oversized); + } + let mut response = vec![0_u8; response_len]; + timeout(self.exchange_timeout, stream.read_exact(&mut response)) + .await + .map_err(|_| ResolveError::Timeout)??; + Ok(response) + } +} + +impl TrustedResolver for SocketTrustedResolver { + async fn resolve( + &self, + name: &NormalizedName, + family: AddressFamily, + ) -> Result { + let mut current = name.as_absolute_name(); + let mut visited = BTreeSet::new(); + let mut chain_ttl = u32::MAX; + let mut cname_hops = 0; + visited.insert(canonical_name(¤t)); + + for _ in 0..=MAX_CNAME_HOPS { + let current_key = canonical_name(¤t); + let response = self.exchange(current.clone(), family.record_type()).await?; + let parsed = parse_answer_records(&response, family); + let mut cursor = current_key; + + loop { + if let Some(records) = parsed.addresses.get(&cursor) { + let mut addresses = records + .iter() + .map(|(address, _)| *address) + .collect::>(); + addresses.sort_unstable(); + addresses.dedup(); + addresses.truncate(MAX_RETAINED_ADDRESSES); + let address_ttl = records.iter().map(|(_, ttl)| *ttl).min().unwrap_or(1); + return Ok(TrustedAnswer { + addresses, + ttl: Duration::from_secs(u64::from(chain_ttl.min(address_ttl))), + }); + } + + let Some((target, ttl)) = parsed.cnames.get(&cursor) else { + return Err(ResolveError::NoData); + }; + cname_hops += 1; + if cname_hops > MAX_CNAME_HOPS { + return Err(ResolveError::CnameLimit); + } + chain_ttl = chain_ttl.min(*ttl); + let target_key = canonical_name(target); + if !visited.insert(target_key.clone()) { + return Err(ResolveError::CnameLimit); + } + cursor = target_key; + + if !parsed.addresses.contains_key(&cursor) && !parsed.cnames.contains_key(&cursor) { + current = target.clone(); + break; + } + } + } + + Err(ResolveError::CnameLimit) + } +} + +fn parse_response(wire: &[u8], id: u16, query: &Query) -> Result { + if wire.len() > MAX_DNS_MESSAGE_BYTES { + return Err(ResolveError::Oversized); + } + let response = Message::from_vec(wire).map_err(|_| ResolveError::Malformed)?; + if response.metadata.id != id + || response.metadata.message_type != MessageType::Response + || response.metadata.op_code != OpCode::Query + || response.queries.len() != 1 + || response.queries.first() != Some(query) + { + return Err(ResolveError::Malformed); + } + match response.metadata.response_code { + ResponseCode::NoError => Ok(response), + ResponseCode::NXDomain => Err(ResolveError::NxDomain), + code => Err(ResolveError::Response(code)), + } +} + +struct ParsedRecords { + addresses: BTreeMap>, + cnames: BTreeMap, +} + +fn parse_answer_records(message: &Message, family: AddressFamily) -> ParsedRecords { + let mut parsed = ParsedRecords { + addresses: BTreeMap::new(), + cnames: BTreeMap::new(), + }; + + for record in &message.answers { + let owner = canonical_name(&record.name); + match &record.data { + RData::A(value) if family == AddressFamily::Ipv4 => { + parsed + .addresses + .entry(owner) + .or_default() + .push((IpAddr::V4(value.0), record.ttl)); + } + RData::AAAA(value) if family == AddressFamily::Ipv6 => { + parsed + .addresses + .entry(owner) + .or_default() + .push((IpAddr::V6(value.0), record.ttl)); + } + RData::CNAME(target) => { + parsed + .cnames + .entry(owner) + .or_insert_with(|| (target.0.clone(), record.ttl)); + } + _ => {} + } + } + parsed +} + +fn canonical_name(name: &Name) -> String { + name.to_ascii().trim_end_matches('.').to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + use hickory_proto::rr::Record; + use hickory_proto::rr::rdata::{A, CNAME}; + use openshell_core::net::set_tcp_nodelay_best_effort; + use tokio::net::TcpListener; + + #[test] + fn answer_parser_keeps_only_requested_family_and_bounds_are_constants() { + let owner = Name::from_ascii("db.example.").unwrap(); + let mut message = Message::response(1, OpCode::Query); + message.add_answer(Record::from_rdata( + owner.clone(), + 120, + RData::A(A::new(203, 0, 113, 10)), + )); + message.add_answer(Record::from_rdata( + owner, + 120, + RData::AAAA("2001:db8::10".parse::().unwrap().into()), + )); + + let parsed = parse_answer_records(&message, AddressFamily::Ipv4); + assert_eq!(parsed.addresses["db.example"].len(), 1); + assert_eq!(MAX_RETAINED_ADDRESSES, 16); + assert_eq!(MAX_DNS_MESSAGE_BYTES, 8192); + } + + #[test] + fn parser_retains_cname_owner_target_and_ttl() { + let owner = Name::from_ascii("db.example.").unwrap(); + let target = Name::from_ascii("target.example.").unwrap(); + let mut message = Message::response(1, OpCode::Query); + message.add_answer(Record::from_rdata( + owner, + 17, + RData::CNAME(CNAME(target.clone())), + )); + let parsed = parse_answer_records(&message, AddressFamily::Ipv4); + assert_eq!(parsed.cnames["db.example"], (target, 17)); + } + + #[test] + fn response_validation_rejects_wrong_transaction_or_question() { + let query = Query::query(Name::from_ascii("db.example.").unwrap(), RecordType::A); + let mut response = Message::response(9, OpCode::Query); + response.queries.push(query.clone()); + let wire = response.to_vec().unwrap(); + assert!(matches!( + parse_response(&wire, 10, &query), + Err(ResolveError::Malformed) + )); + } + + #[tokio::test] + async fn truncated_udp_retries_over_tcp_and_follows_cname() { + let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let server = udp.local_addr().unwrap(); + let tcp = TcpListener::bind(server).await.unwrap(); + + let udp_task = tokio::spawn(async move { + let mut wire = [0_u8; MAX_DNS_MESSAGE_BYTES]; + let (length, peer) = udp.recv_from(&mut wire).await.unwrap(); + let request = Message::from_vec(&wire[..length]).unwrap(); + let mut response = Message::response(request.metadata.id, OpCode::Query); + response.metadata.truncation = true; + response.queries = request.queries; + udp.send_to(&response.to_vec().unwrap(), peer) + .await + .unwrap(); + }); + let tcp_task = tokio::spawn(async move { + let (mut stream, _) = tcp.accept().await.unwrap(); + set_tcp_nodelay_best_effort(&stream); + let length = stream.read_u16().await.unwrap() as usize; + let mut wire = vec![0_u8; length]; + stream.read_exact(&mut wire).await.unwrap(); + let request = Message::from_vec(&wire).unwrap(); + let requested = request.queries[0].name.clone(); + let canonical = Name::from_ascii("canonical.example.").unwrap(); + let mut response = Message::response(request.metadata.id, OpCode::Query); + response.queries = request.queries; + response.add_answer(Record::from_rdata( + requested, + 12, + RData::CNAME(CNAME(canonical.clone())), + )); + response.add_answer(Record::from_rdata( + canonical, + 20, + RData::A(A::new(8, 8, 8, 8)), + )); + let wire = response.to_vec().unwrap(); + stream + .write_u16(u16::try_from(wire.len()).unwrap()) + .await + .unwrap(); + stream.write_all(&wire).await.unwrap(); + }); + + let resolver = SocketTrustedResolver::new(server); + let answer = resolver + .resolve( + &NormalizedName::parse("db.example").unwrap(), + AddressFamily::Ipv4, + ) + .await + .unwrap(); + assert_eq!(answer.addresses, ["8.8.8.8".parse::().unwrap()]); + assert_eq!(answer.ttl, Duration::from_secs(12)); + udp_task.await.unwrap(); + tcp_task.await.unwrap(); + } + + #[tokio::test] + async fn cname_hop_overflow_fails_closed() { + let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let server = udp.local_addr().unwrap(); + let task = tokio::spawn(async move { + let mut wire = [0_u8; MAX_DNS_MESSAGE_BYTES]; + let (length, peer) = udp.recv_from(&mut wire).await.unwrap(); + let request = Message::from_vec(&wire[..length]).unwrap(); + let mut response = Message::response(request.metadata.id, OpCode::Query); + response.queries = request.queries.clone(); + let mut owner = request.queries[0].name.clone(); + for index in 0..=MAX_CNAME_HOPS { + let target = Name::from_ascii(format!("hop{index}.example.")).unwrap(); + response.add_answer(Record::from_rdata( + owner, + 10, + RData::CNAME(CNAME(target.clone())), + )); + owner = target; + } + response.add_answer(Record::from_rdata(owner, 10, RData::A(A::new(8, 8, 8, 8)))); + udp.send_to(&response.to_vec().unwrap(), peer) + .await + .unwrap(); + }); + let resolver = SocketTrustedResolver::new(server); + assert!(matches!( + resolver + .resolve( + &NormalizedName::parse("db.example").unwrap(), + AddressFamily::Ipv4 + ) + .await, + Err(ResolveError::CnameLimit) + )); + task.await.unwrap(); + } + + #[tokio::test] + async fn trusted_exchange_timeout_is_bounded() { + let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let resolver = SocketTrustedResolver::with_timeout( + udp.local_addr().unwrap(), + Duration::from_millis(10), + ); + assert!(matches!( + resolver + .resolve( + &NormalizedName::parse("db.example").unwrap(), + AddressFamily::Ipv4 + ) + .await, + Err(ResolveError::Timeout) + )); + drop(udp); + } + + #[test] + fn oversized_response_is_rejected_before_decode() { + let query = Query::query(Name::from_ascii("db.example.").unwrap(), RecordType::A); + assert!(matches!( + parse_response(&vec![0; MAX_DNS_MESSAGE_BYTES + 1], 1, &query), + Err(ResolveError::Oversized) + )); + } +} diff --git a/crates/openshell-supervisor-network/src/policy_dns/store.rs b/crates/openshell-supervisor-network/src/policy_dns/store.rs new file mode 100644 index 0000000000..07190af9a8 --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_dns/store.rs @@ -0,0 +1,699 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Synthetic-address allocation and resolved endpoint mappings. + +use super::name::NormalizedName; +use super::resolver::AddressFamily; +use crate::proxy::destination::{ + DestinationRequest, DestinationValidationPlan, UpstreamConnector, build_pinned_validation_plan, + validate_destination, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::ops::RangeInclusive; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct PolicyEndpointId { + pub(crate) policy_name: String, + pub(crate) endpoint_index: usize, +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedPortContract { + pub(crate) endpoint_id: PolicyEndpointId, + pub(crate) port: u16, + pub(crate) destination_plan: DestinationValidationPlan, + pub(crate) pinned_addresses: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct PublishRequest { + pub(crate) normalized_name: NormalizedName, + pub(crate) family: AddressFamily, + /// Digest of every compatible endpoint identity and its policy metadata. + /// A changed endpoint contract receives a new synthetic identity even when + /// it selects the same normalized name. + pub(crate) allocation_identity: [u8; 32], + pub(crate) policy_generation: u64, + pub(crate) ttl: Duration, + pub(crate) contracts: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedEndpointRecord { + pub(crate) synthetic_address: IpAddr, + pub(crate) normalized_name: NormalizedName, + pub(crate) family: AddressFamily, + pub(crate) policy_generation: u64, + pub(crate) mapping_generation: u64, + pub(crate) mapping_id: Uuid, + pub(crate) created_at: Instant, + pub(crate) expires_at: Instant, + pub(crate) contracts: Vec, +} + +impl ResolvedEndpointRecord { + pub(crate) fn allowed_ports(&self) -> BTreeSet { + self.contracts + .iter() + .map(|contract| contract.port) + .collect() + } +} + +#[derive(Debug, Clone)] +pub(crate) struct MappingLookup { + pub(crate) record: ResolvedEndpointRecord, + pub(crate) port: u16, +} + +impl MappingLookup { + pub(crate) fn endpoint_ids(&self) -> impl Iterator { + self.record + .contracts + .iter() + .filter(move |contract| contract.port == self.port) + .map(|contract| &contract.endpoint_id) + } + + /// Build the unopened connector for a process-authorized endpoint. + /// + /// Selecting by endpoint identity prevents a compatible endpoint record + /// from becoming a new policy precedence rule. The pinned destination mode + /// never resolves `normalized_name` again. + pub(crate) async fn connector_for( + &self, + endpoint_id: &PolicyEndpointId, + ) -> Result { + let addresses = self + .record + .contracts + .iter() + .filter(|contract| contract.port == self.port && &contract.endpoint_id == endpoint_id) + .flat_map(|contract| contract.pinned_addresses.iter().copied()) + .collect::>() + .into_iter() + .collect::>(); + if addresses.is_empty() { + return Err(MappingLookupError::EndpointMismatch); + } + let plan = build_pinned_validation_plan(addresses) + .map_err(|_| MappingLookupError::InvalidMapping)?; + validate_destination(DestinationRequest { + host: self.record.normalized_name.as_str(), + port: self.port, + sandbox_entrypoint_pid: 0, + plan: &plan, + }) + .await + .map_err(|_| MappingLookupError::InvalidMapping) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct SyntheticPools { + ipv4: RangeInclusive, + ipv6: RangeInclusive, +} + +impl SyntheticPools { + /// Construct injectable pools. Production runtime ranges are deliberately + /// selected only after PR3 checks route collisions in each namespace. + pub(crate) fn new( + ipv4: RangeInclusive, + ipv6: RangeInclusive, + ) -> Result { + if ipv4.is_empty() || ipv6.is_empty() { + return Err(StoreConfigError::InvalidPool); + } + for address in [IpAddr::V4(*ipv4.start()), IpAddr::V4(*ipv4.end())] { + if openshell_core::net::is_always_blocked_ip(address) { + return Err(StoreConfigError::InvalidPool); + } + } + for address in [IpAddr::V6(*ipv6.start()), IpAddr::V6(*ipv6.end())] { + if openshell_core::net::is_always_blocked_ip(address) { + return Err(StoreConfigError::InvalidPool); + } + } + Ok(Self { ipv4, ipv6 }) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct StoreConfig { + pub(crate) pools: SyntheticPools, + pub(crate) max_mappings: usize, +} + +impl StoreConfig { + pub(crate) fn new( + pools: SyntheticPools, + max_mappings: usize, + ) -> Result { + if max_mappings == 0 { + return Err(StoreConfigError::ZeroCapacity); + } + Ok(Self { + pools, + max_mappings, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum StoreConfigError { + #[error("synthetic address pool is empty or contains an always-blocked boundary")] + InvalidPool, + #[error("resolved endpoint store capacity must be non-zero")] + ZeroCapacity, +} + +#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PublishError { + #[error("policy generation changed before mapping publication")] + StalePolicy, + #[error("resolved endpoint publication was empty or invalid")] + InvalidMapping, + #[error("synthetic address pool is exhausted")] + PoolExhausted, + #[error("resolved endpoint store lock was poisoned")] + LockPoisoned, +} + +#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MappingLookupError { + #[error("transparent TCP mapping is missing")] + Missing, + #[error("transparent TCP mapping is expired")] + Expired, + #[error("transparent TCP mapping belongs to a stale policy generation")] + StalePolicy, + #[error("transparent TCP mapping does not authorize the requested port")] + PortMismatch, + #[error("transparent TCP mapping does not contain the authorized endpoint")] + EndpointMismatch, + #[error("transparent TCP mapping is internally invalid")] + InvalidMapping, + #[error("resolved endpoint store lock was poisoned")] + LockPoisoned, +} + +#[derive(Default)] +struct PolicyDnsMetrics { + queries: AtomicU64, + refused: AtomicU64, + upstream_queries: AtomicU64, + no_valid_address: AtomicU64, + mappings_published: AtomicU64, + mappings_expired: AtomicU64, + pool_exhausted: AtomicU64, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct PolicyDnsMetricsSnapshot { + pub(crate) queries: u64, + pub(crate) refused: u64, + pub(crate) upstream_queries: u64, + pub(crate) no_valid_address: u64, + pub(crate) mappings_published: u64, + pub(crate) mappings_expired: u64, + pub(crate) pool_exhausted: u64, + pub(crate) active_mappings: usize, + pub(crate) allocated_identities: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct AllocationKey { + normalized_name: NormalizedName, + family: AddressFamily, + allocation_identity: [u8; 32], +} + +struct StoreState { + records: BTreeMap, + allocations: BTreeMap, + expired_allocations: BTreeSet, + next_ipv4: u32, + end_ipv4: u32, + next_ipv6: u128, + end_ipv6: u128, + next_mapping_generation: u64, +} + +pub(crate) struct ResolvedEndpointStore { + state: RwLock, + config: StoreConfig, + metrics: Arc, +} + +impl ResolvedEndpointStore { + pub(crate) fn new(config: StoreConfig) -> Self { + let next_ipv4 = u32::from(*config.pools.ipv4.start()); + let end_ipv4 = u32::from(*config.pools.ipv4.end()); + let next_ipv6 = u128::from(*config.pools.ipv6.start()); + let end_ipv6 = u128::from(*config.pools.ipv6.end()); + Self { + state: RwLock::new(StoreState { + records: BTreeMap::new(), + allocations: BTreeMap::new(), + expired_allocations: BTreeSet::new(), + next_ipv4, + end_ipv4, + next_ipv6, + end_ipv6, + next_mapping_generation: 0, + }), + config, + metrics: Arc::new(PolicyDnsMetrics::default()), + } + } + + pub(crate) fn note_query(&self) { + self.metrics.queries.fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn note_refused(&self) { + self.metrics.refused.fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn note_upstream_query(&self) { + self.metrics + .upstream_queries + .fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn note_no_valid_address(&self) { + self.metrics + .no_valid_address + .fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn publish( + &self, + request: PublishRequest, + current_policy_generation: u64, + now: Instant, + ) -> Result { + if request.policy_generation != current_policy_generation { + return Err(PublishError::StalePolicy); + } + if request.ttl.is_zero() + || request.contracts.is_empty() + || request.contracts.iter().any(|contract| { + contract.port == 0 + || contract.pinned_addresses.is_empty() + || contract + .pinned_addresses + .iter() + .any(|address| !request.family.accepts(*address)) + }) + { + return Err(PublishError::InvalidMapping); + } + + let key = AllocationKey { + normalized_name: request.normalized_name.clone(), + family: request.family, + allocation_identity: request.allocation_identity, + }; + let mut state = self.state.write().map_err(|_| PublishError::LockPoisoned)?; + let synthetic_address = if let Some(address) = state.allocations.get(&key) { + *address + } else { + if state.allocations.len() >= self.config.max_mappings { + self.metrics.pool_exhausted.fetch_add(1, Ordering::Relaxed); + return Err(PublishError::PoolExhausted); + } + let address = allocate_address(&mut state, request.family).ok_or_else(|| { + self.metrics.pool_exhausted.fetch_add(1, Ordering::Relaxed); + PublishError::PoolExhausted + })?; + state.allocations.insert(key, address); + address + }; + + state.next_mapping_generation = state.next_mapping_generation.saturating_add(1); + let record = ResolvedEndpointRecord { + synthetic_address, + normalized_name: request.normalized_name, + family: request.family, + policy_generation: request.policy_generation, + mapping_generation: state.next_mapping_generation, + mapping_id: Uuid::new_v4(), + created_at: now, + expires_at: now + request.ttl, + contracts: request.contracts, + }; + state.expired_allocations.remove(&synthetic_address); + state.records.insert(synthetic_address, record.clone()); + self.metrics + .mappings_published + .fetch_add(1, Ordering::Relaxed); + Ok(record) + } + + pub(crate) fn lookup( + &self, + synthetic_address: IpAddr, + port: u16, + current_policy_generation: u64, + now: Instant, + ) -> Result { + let state = self + .state + .read() + .map_err(|_| MappingLookupError::LockPoisoned)?; + let Some(record) = state.records.get(&synthetic_address) else { + return if state.expired_allocations.contains(&synthetic_address) { + Err(MappingLookupError::Expired) + } else { + Err(MappingLookupError::Missing) + }; + }; + if now >= record.expires_at { + return Err(MappingLookupError::Expired); + } + if record.policy_generation != current_policy_generation { + return Err(MappingLookupError::StalePolicy); + } + if !record + .contracts + .iter() + .any(|contract| contract.port == port) + { + return Err(MappingLookupError::PortMismatch); + } + Ok(MappingLookup { + record: record.clone(), + port, + }) + } + + /// Remove expired active records without freeing their synthetic identity. + pub(crate) fn expire(&self, now: Instant) -> Result { + let mut state = self + .state + .write() + .map_err(|_| MappingLookupError::LockPoisoned)?; + let expired = state + .records + .iter() + .filter_map(|(address, record)| (now >= record.expires_at).then_some(*address)) + .collect::>(); + for address in &expired { + state.records.remove(address); + state.expired_allocations.insert(*address); + } + self.metrics + .mappings_expired + .fetch_add(expired.len() as u64, Ordering::Relaxed); + Ok(expired.len()) + } + + pub(crate) fn metrics(&self, now: Instant) -> PolicyDnsMetricsSnapshot { + let state = self + .state + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + PolicyDnsMetricsSnapshot { + queries: self.metrics.queries.load(Ordering::Relaxed), + refused: self.metrics.refused.load(Ordering::Relaxed), + upstream_queries: self.metrics.upstream_queries.load(Ordering::Relaxed), + no_valid_address: self.metrics.no_valid_address.load(Ordering::Relaxed), + mappings_published: self.metrics.mappings_published.load(Ordering::Relaxed), + mappings_expired: self.metrics.mappings_expired.load(Ordering::Relaxed), + pool_exhausted: self.metrics.pool_exhausted.load(Ordering::Relaxed), + active_mappings: state + .records + .values() + .filter(|record| now < record.expires_at) + .count(), + allocated_identities: state.allocations.len(), + } + } +} + +fn allocate_address(state: &mut StoreState, family: AddressFamily) -> Option { + match family { + AddressFamily::Ipv4 if state.next_ipv4 <= state.end_ipv4 => { + let address = IpAddr::V4(Ipv4Addr::from(state.next_ipv4)); + state.next_ipv4 = state.next_ipv4.saturating_add(1); + Some(address) + } + AddressFamily::Ipv6 if state.next_ipv6 <= state.end_ipv6 => { + let address = IpAddr::V6(Ipv6Addr::from(state.next_ipv6)); + state.next_ipv6 = state.next_ipv6.saturating_add(1); + Some(address) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proxy::destination::{AddressAuthorization, DestinationValidationPlan}; + use std::sync::Barrier; + + fn store(max_mappings: usize) -> ResolvedEndpointStore { + let pools = SyntheticPools::new( + Ipv4Addr::new(198, 18, 0, 1)..=Ipv4Addr::new(198, 18, 0, 2), + "fd00:1::1".parse().unwrap()..="fd00:1::2".parse().unwrap(), + ) + .unwrap(); + ResolvedEndpointStore::new(StoreConfig::new(pools, max_mappings).unwrap()) + } + + fn request(name: &str, generation: u64, ttl: Duration) -> PublishRequest { + PublishRequest { + normalized_name: NormalizedName::parse(name).unwrap(), + family: AddressFamily::Ipv4, + allocation_identity: [1; 32], + policy_generation: generation, + ttl, + contracts: vec![ResolvedPortContract { + endpoint_id: PolicyEndpointId { + policy_name: "database".to_string(), + endpoint_index: 0, + }, + port: 5432, + destination_plan: DestinationValidationPlan { + address_authorization: AddressAuthorization::ExactDeclaredHost, + }, + pinned_addresses: vec!["203.0.113.8".parse().unwrap()], + }], + } + } + + #[test] + fn refresh_retains_synthetic_identity_and_changes_mapping_generation() { + let store = store(2); + let now = Instant::now(); + let first = store + .publish(request("db.example", 7, Duration::from_secs(10)), 7, now) + .unwrap(); + let second = store + .publish( + request("DB.EXAMPLE.", 7, Duration::from_secs(20)), + 7, + now + Duration::from_secs(1), + ) + .unwrap(); + assert_eq!(first.synthetic_address, second.synthetic_address); + assert_ne!(first.mapping_id, second.mapping_id); + assert!(second.mapping_generation > first.mapping_generation); + assert_eq!(first.policy_generation, second.policy_generation); + } + + #[test] + fn distinct_names_sharing_real_ip_get_distinct_correlations() { + let store = store(2); + let now = Instant::now(); + let left = store + .publish(request("left.example", 1, Duration::from_secs(10)), 1, now) + .unwrap(); + let right = store + .publish(request("right.example", 1, Duration::from_secs(10)), 1, now) + .unwrap(); + assert_ne!(left.synthetic_address, right.synthetic_address); + assert_eq!( + left.contracts[0].pinned_addresses, + right.contracts[0].pinned_addresses + ); + } + + #[test] + fn wrong_port_stale_generation_and_expiry_fail_closed() { + let store = store(2); + let now = Instant::now(); + let record = store + .publish(request("db.example", 4, Duration::from_secs(2)), 4, now) + .unwrap(); + assert!(matches!( + store.lookup(record.synthetic_address, 3306, 4, now), + Err(MappingLookupError::PortMismatch) + )); + assert!(matches!( + store.lookup(record.synthetic_address, 5432, 5, now), + Err(MappingLookupError::StalePolicy) + )); + assert!(matches!( + store.lookup( + record.synthetic_address, + 5432, + 4, + now + Duration::from_secs(2) + ), + Err(MappingLookupError::Expired) + )); + } + + #[test] + fn expiry_never_reassigns_synthetic_address_to_another_name() { + let store = store(2); + let now = Instant::now(); + let first = store + .publish(request("first.example", 1, Duration::from_secs(1)), 1, now) + .unwrap(); + assert_eq!(store.expire(now + Duration::from_secs(1)).unwrap(), 1); + let second = store + .publish( + request("second.example", 1, Duration::from_secs(10)), + 1, + now + Duration::from_secs(1), + ) + .unwrap(); + assert_ne!(first.synthetic_address, second.synthetic_address); + assert!(matches!( + store.lookup( + first.synthetic_address, + 5432, + 1, + now + Duration::from_secs(1) + ), + Err(MappingLookupError::Expired) + )); + } + + #[test] + fn changed_endpoint_contract_never_reuses_synthetic_identity() { + let store = store(2); + let now = Instant::now(); + let first = store + .publish(request("db.example", 1, Duration::from_secs(5)), 1, now) + .unwrap(); + let mut changed = request("db.example", 2, Duration::from_secs(5)); + changed.allocation_identity = [2; 32]; + let second = store.publish(changed, 2, now).unwrap(); + assert_ne!(first.synthetic_address, second.synthetic_address); + assert!(matches!( + store.lookup(first.synthetic_address, 5432, 2, now), + Err(MappingLookupError::StalePolicy) + )); + } + + #[test] + fn pool_exhaustion_and_stale_publication_publish_nothing() { + let store = store(1); + let now = Instant::now(); + assert!(matches!( + store.publish(request("stale.example", 1, Duration::from_secs(5)), 2, now), + Err(PublishError::StalePolicy) + )); + store + .publish(request("first.example", 2, Duration::from_secs(5)), 2, now) + .unwrap(); + assert!(matches!( + store.publish(request("second.example", 2, Duration::from_secs(5)), 2, now), + Err(PublishError::PoolExhausted) + )); + let metrics = store.metrics(now); + assert_eq!(metrics.allocated_identities, 1); + assert_eq!(metrics.active_mappings, 1); + assert_eq!(metrics.pool_exhausted, 1); + } + + #[test] + fn real_address_never_inherits_synthetic_mapping() { + let store = store(1); + let now = Instant::now(); + store + .publish(request("db.example", 1, Duration::from_secs(5)), 1, now) + .unwrap(); + assert!(matches!( + store.lookup("203.0.113.8".parse().unwrap(), 5432, 1, now), + Err(MappingLookupError::Missing) + )); + } + + #[test] + fn ipv6_pool_allocates_only_ipv6_synthetic_addresses() { + let store = store(1); + let now = Instant::now(); + let mut request = request("db.example", 1, Duration::from_secs(5)); + request.family = AddressFamily::Ipv6; + request.contracts[0].pinned_addresses = vec!["2001:db8::8".parse().unwrap()]; + let record = store.publish(request, 1, now).unwrap(); + assert!(record.synthetic_address.is_ipv6()); + assert_eq!(record.family, AddressFamily::Ipv6); + } + + #[test] + fn concurrent_refreshes_never_publish_partial_records() { + let store = Arc::new(store(1)); + let barrier = Arc::new(Barrier::new(9)); + let now = Instant::now(); + let mut workers = Vec::new(); + for _ in 0..8 { + let store = store.clone(); + let barrier = barrier.clone(); + workers.push(std::thread::spawn(move || { + barrier.wait(); + store + .publish(request("db.example", 3, Duration::from_secs(5)), 3, now) + .unwrap() + })); + } + barrier.wait(); + let records = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect::>(); + assert!( + records + .iter() + .all(|record| record.synthetic_address == records[0].synthetic_address) + ); + let lookup = store + .lookup(records[0].synthetic_address, 5432, 3, now) + .unwrap(); + assert!(!lookup.record.contracts.is_empty()); + assert!(!lookup.record.contracts[0].pinned_addresses.is_empty()); + assert_eq!(store.metrics(now).allocated_identities, 1); + } + + #[tokio::test] + async fn connector_uses_only_pinned_addresses_and_endpoint_identity() { + let store = store(1); + let now = Instant::now(); + let record = store + .publish( + request("must-not-resolve.invalid", 1, Duration::from_secs(5)), + 1, + now, + ) + .unwrap(); + let lookup = store + .lookup(record.synthetic_address, 5432, 1, now) + .unwrap(); + let endpoint = lookup.endpoint_ids().next().unwrap().clone(); + let connector = lookup.connector_for(&endpoint).await.unwrap(); + assert_eq!(connector.addrs(), &["203.0.113.8:5432".parse().unwrap()]); + } +} diff --git a/crates/openshell-supervisor-network/src/policy_dns/wire.rs b/crates/openshell-supervisor-network/src/policy_dns/wire.rs new file mode 100644 index 0000000000..27b0698ddc --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_dns/wire.rs @@ -0,0 +1,280 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! DNS request and response wire handling. + +use super::resolver::{AddressFamily, MAX_DNS_MESSAGE_BYTES, ResolveError, TrustedResolver}; +use super::{PolicyDnsError, PolicyDnsService}; +use hickory_proto::op::{Message, MessageType, OpCode, ResponseCode}; +use hickory_proto::rr::rdata::{A, AAAA}; +use hickory_proto::rr::{DNSClass, RData, Record, RecordType}; +use std::net::IpAddr; +use std::time::Instant; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum WireError { + #[error("DNS response encoding failed")] + Encode, + #[error("DNS-over-TCP frame is invalid")] + InvalidTcpFrame, +} + +/// Handle one DNS datagram without binding a runtime listener. +pub(crate) async fn handle_udp_query( + service: &PolicyDnsService, + wire: &[u8], +) -> Result, WireError> { + let fallback_id = wire + .get(..2) + .map(|bytes| u16::from_be_bytes([bytes[0], bytes[1]])) + .unwrap_or_default(); + if wire.len() > MAX_DNS_MESSAGE_BYTES { + return encode_message(Message::error_msg( + fallback_id, + OpCode::Query, + ResponseCode::FormErr, + )); + } + let Ok(request) = Message::from_vec(wire) else { + return encode_message(Message::error_msg( + fallback_id, + OpCode::Query, + ResponseCode::FormErr, + )); + }; + let Some((query, family)) = validate_request(&request) else { + let code = if request.metadata.message_type != MessageType::Query + || request.metadata.op_code != OpCode::Query + || request.queries.len() != 1 + { + ResponseCode::FormErr + } else { + ResponseCode::NotImp + }; + return encode_message(response_with_code(&request, code)); + }; + + let raw_name = query.name.to_ascii(); + match service + .answer_query(&raw_name, family, Instant::now()) + .await + { + Ok(answer) => { + let rdata = match answer.address { + IpAddr::V4(address) if family == AddressFamily::Ipv4 => RData::A(A(address)), + IpAddr::V6(address) if family == AddressFamily::Ipv6 => RData::AAAA(AAAA(address)), + _ => return encode_message(response_with_code(&request, ResponseCode::ServFail)), + }; + let mut response = response_with_code(&request, ResponseCode::NoError); + response.answers.push(Record::from_rdata( + query.name.clone(), + u32::try_from(answer.ttl.as_secs()).unwrap_or(u32::MAX), + rdata, + )); + encode_message(response) + } + Err(PolicyDnsError::Ineligible) => { + encode_message(response_with_code(&request, ResponseCode::Refused)) + } + Err(PolicyDnsError::Resolver(ResolveError::NxDomain)) => { + encode_message(response_with_code(&request, ResponseCode::NXDomain)) + } + Err(PolicyDnsError::InvalidName) => { + encode_message(response_with_code(&request, ResponseCode::FormErr)) + } + Err( + PolicyDnsError::Resolver(_) + | PolicyDnsError::NoValidAddress + | PolicyDnsError::StalePolicy + | PolicyDnsError::Publish(_) + | PolicyDnsError::Policy(_), + ) => encode_message(response_with_code(&request, ResponseCode::ServFail)), + } +} + +/// Handle exactly one length-prefixed DNS-over-TCP message. +pub(crate) async fn handle_tcp_query( + service: &PolicyDnsService, + frame: &[u8], +) -> Result, WireError> { + let declared = frame + .get(..2) + .map(|bytes| usize::from(u16::from_be_bytes([bytes[0], bytes[1]]))) + .ok_or(WireError::InvalidTcpFrame)?; + if declared > MAX_DNS_MESSAGE_BYTES || frame.len() != declared + 2 { + return Err(WireError::InvalidTcpFrame); + } + let response = handle_udp_query(service, &frame[2..]).await?; + let length = u16::try_from(response.len()).map_err(|_| WireError::Encode)?; + let mut framed = Vec::with_capacity(response.len() + 2); + framed.extend_from_slice(&length.to_be_bytes()); + framed.extend_from_slice(&response); + Ok(framed) +} + +fn validate_request(request: &Message) -> Option<(&hickory_proto::op::Query, AddressFamily)> { + if request.metadata.message_type != MessageType::Query + || request.metadata.op_code != OpCode::Query + || request.queries.len() != 1 + { + return None; + } + let query = request.queries.first()?; + if query.query_class != DNSClass::IN { + return None; + } + let family = match query.query_type { + RecordType::A => AddressFamily::Ipv4, + RecordType::AAAA => AddressFamily::Ipv6, + _ => return None, + }; + Some((query, family)) +} + +fn response_with_code(request: &Message, code: ResponseCode) -> Message { + let mut response = Message::response(request.metadata.id, request.metadata.op_code); + response.metadata.recursion_desired = request.metadata.recursion_desired; + response.metadata.recursion_available = true; + response.metadata.response_code = code; + response.queries.clone_from(&request.queries); + response +} + +fn encode_message(message: Message) -> Result, WireError> { + message.to_vec().map_err(|_| WireError::Encode) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::opa::OpaEngine; + use crate::policy_dns::name::NormalizedName; + use crate::policy_dns::resolver::TrustedAnswer; + use crate::policy_dns::store::{ResolvedEndpointStore, StoreConfig, SyntheticPools}; + use hickory_proto::op::Query; + use hickory_proto::rr::Name; + use std::net::{Ipv4Addr, Ipv6Addr}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + struct FakeResolver { + calls: AtomicUsize, + } + + impl TrustedResolver for FakeResolver { + async fn resolve( + &self, + _name: &NormalizedName, + family: AddressFamily, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(TrustedAnswer { + addresses: match family { + AddressFamily::Ipv4 => vec!["8.8.8.8".parse().unwrap()], + AddressFamily::Ipv6 => vec!["2001:4860:4860::8888".parse().unwrap()], + }, + ttl: Duration::from_secs(10), + }) + } + } + + fn service() -> PolicyDnsService { + let yaml = r" +network_policies: + database: + name: database + endpoints: [{ host: db.example, port: 5432, protocol: tcp }] + binaries: [{ path: /usr/bin/psql }] +filesystem_policy: { include_workdir: true, read_only: [], read_write: [] } +landlock: { compatibility: best_effort } +process: { run_as_user: sandbox, run_as_group: sandbox } +"; + let policy = Arc::new( + OpaEngine::from_strings(include_str!("../../data/sandbox-policy.rego"), yaml).unwrap(), + ); + let pools = SyntheticPools::new( + Ipv4Addr::new(198, 18, 0, 1)..=Ipv4Addr::new(198, 18, 0, 4), + "fd00:1::1".parse::().unwrap()..="fd00:1::4".parse::().unwrap(), + ) + .unwrap(); + PolicyDnsService::new( + policy, + FakeResolver { + calls: AtomicUsize::new(0), + }, + Arc::new(ResolvedEndpointStore::new( + StoreConfig::new(pools, 8).unwrap(), + )), + ) + } + + fn request(name: &str, record_type: RecordType) -> Vec { + let mut message = Message::new(42, MessageType::Query, OpCode::Query); + message.metadata.recursion_desired = true; + message + .queries + .push(Query::query(Name::from_ascii(name).unwrap(), record_type)); + message.to_vec().unwrap() + } + + #[tokio::test] + async fn udp_and_tcp_queries_return_synthetic_answers() { + let service = service(); + let udp = handle_udp_query(&service, &request("DB.EXAMPLE.", RecordType::A)) + .await + .unwrap(); + let udp_message = Message::from_vec(&udp).unwrap(); + assert_eq!(udp_message.metadata.response_code, ResponseCode::NoError); + assert!(matches!(udp_message.answers[0].data, RData::A(_))); + + let query = request("db.example.", RecordType::A); + let mut frame = Vec::with_capacity(query.len() + 2); + frame.extend_from_slice(&u16::try_from(query.len()).unwrap().to_be_bytes()); + frame.extend_from_slice(&query); + let tcp = handle_tcp_query(&service, &frame).await.unwrap(); + let declared = usize::from(u16::from_be_bytes([tcp[0], tcp[1]])); + assert_eq!(declared, tcp.len() - 2); + assert_eq!( + Message::from_vec(&tcp[2..]).unwrap().metadata.response_code, + ResponseCode::NoError + ); + + let ipv6 = handle_udp_query(&service, &request("db.example.", RecordType::AAAA)) + .await + .unwrap(); + assert!(matches!( + Message::from_vec(&ipv6).unwrap().answers[0].data, + RData::AAAA(_) + )); + } + + #[tokio::test] + async fn ineligible_query_is_refused_without_upstream_call() { + let service = service(); + let wire = handle_udp_query(&service, &request("other.example.", RecordType::A)) + .await + .unwrap(); + assert_eq!( + Message::from_vec(&wire).unwrap().metadata.response_code, + ResponseCode::Refused + ); + assert_eq!(service.resolver.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn unsupported_type_is_not_implemented_and_malformed_tcp_is_rejected() { + let service = service(); + let wire = handle_udp_query(&service, &request("db.example.", RecordType::TXT)) + .await + .unwrap(); + assert_eq!( + Message::from_vec(&wire).unwrap().metadata.response_code, + ResponseCode::NotImp + ); + assert!(matches!( + handle_tcp_query(&service, &[0, 10, 1, 2]).await, + Err(WireError::InvalidTcpFrame) + )); + } +} diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 1bd6a6e001..0bf88a29ed 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -3,7 +3,7 @@ //! HTTP CONNECT proxy with OPA policy evaluation and process-identity binding. -mod destination; +pub(crate) mod destination; mod egress; mod relay; diff --git a/crates/openshell-supervisor-network/src/proxy/destination.rs b/crates/openshell-supervisor-network/src/proxy/destination.rs index 4f94a08743..1ce514133a 100644 --- a/crates/openshell-supervisor-network/src/proxy/destination.rs +++ b/crates/openshell-supervisor-network/src/proxy/destination.rs @@ -1,21 +1,27 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +#![allow( + clippy::redundant_pub_crate, + reason = "the destination primitives intentionally remain internal to the proxy crate" +)] + //! Shared external destination validation and upstream dial boundary. use super::{ - implicit_allowed_ips_for_ip_host, is_host_gateway_alias, parse_allowed_ips, - resolve_and_check_allowed_ips, resolve_and_check_declared_endpoint, - resolve_and_check_trusted_gateway, resolve_and_reject_internal, + BLOCKED_CONTROL_PLANE_PORTS, implicit_allowed_ips_for_ip_host, is_cloud_metadata_ip, + is_host_gateway_alias, is_link_local_ip, parse_allowed_ips, resolve_and_check_allowed_ips, + resolve_and_check_declared_endpoint, resolve_and_check_trusted_gateway, + resolve_and_reject_internal, }; use ipnet::IpNet; -use openshell_core::net::connect_tcp_nodelay_best_effort; +use openshell_core::net::{connect_tcp_nodelay_best_effort, is_always_blocked_ip, is_internal_ip}; use std::net::{IpAddr, SocketAddr}; use tokio::net::TcpStream; /// Address-validation mode selected from the current endpoint configuration. #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum AddressAuthorization { +pub(crate) enum AddressAuthorization { DefaultPublicOnly, ExplicitAllowedIps(Vec), ExactDeclaredHost, @@ -32,16 +38,16 @@ pub(super) enum AddressAuthorization { /// Fully materialized input to shared destination validation. #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct DestinationValidationPlan { - pub(super) address_authorization: AddressAuthorization, +pub(crate) struct DestinationValidationPlan { + pub(crate) address_authorization: AddressAuthorization, } /// Inputs needed to apply the current SSRF and endpoint destination policy. -pub(super) struct DestinationRequest<'a> { - pub(super) host: &'a str, - pub(super) port: u16, - pub(super) sandbox_entrypoint_pid: u32, - pub(super) plan: &'a DestinationValidationPlan, +pub(crate) struct DestinationRequest<'a> { + pub(crate) host: &'a str, + pub(crate) port: u16, + pub(crate) sandbox_entrypoint_pid: u32, + pub(crate) plan: &'a DestinationValidationPlan, } /// Destination-validation branch that rejected an egress request. @@ -49,7 +55,7 @@ pub(super) struct DestinationRequest<'a> { /// Adapters use this classification to preserve their existing HTTP response /// and OCSF message shapes while sharing the underlying validation logic. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum DestinationDenialKind { +pub(crate) enum DestinationDenialKind { TrustedGateway, InvalidAllowedIps, AllowedIps, @@ -58,9 +64,9 @@ pub(super) enum DestinationDenialKind { } #[derive(Debug)] -pub(super) struct DestinationDenial { - pub(super) kind: DestinationDenialKind, - pub(super) reason: String, +pub(crate) struct DestinationDenial { + pub(crate) kind: DestinationDenialKind, + pub(crate) reason: String, } impl DestinationDenial { @@ -70,7 +76,7 @@ impl DestinationDenial { } /// Select one current destination-validation mode without changing precedence. -pub(super) fn build_validation_plan( +pub(crate) fn build_validation_plan( host: &str, normalized_host: &str, trusted_host_gateway: Option, @@ -103,8 +109,8 @@ pub(super) fn build_validation_plan( /// Build the destination mode used by policy DNS after it has validated and /// pinned a non-empty answer set for an endpoint. -#[allow(dead_code, reason = "used when the policy DNS adapter lands")] -pub(super) fn build_pinned_validation_plan( +#[allow(dead_code, reason = "used by the policy DNS adapter")] +pub(crate) fn build_pinned_validation_plan( addresses: Vec, ) -> Result { if addresses.is_empty() { @@ -119,25 +125,140 @@ pub(super) fn build_pinned_validation_plan( }) } +/// Filter resolver-provided addresses through a materialized destination plan. +/// +/// This is the address-only policy-DNS boundary: it never reads a hosts file, +/// invokes a system lookup, or otherwise resolves `host`. Unlike CONNECT's +/// all-or-nothing validation, prohibited answers are removed so a trusted DNS +/// response containing both usable and unusable addresses can retain only the +/// usable subset. +#[allow(dead_code, reason = "used by the policy DNS adapter")] +pub(crate) fn filter_resolved_addresses( + plan: &DestinationValidationPlan, + host: &str, + port: u16, + resolved_ips: &[IpAddr], +) -> Result, DestinationDenial> { + let (kind, control_plane_blocked) = match &plan.address_authorization { + AddressAuthorization::TrustedGatewayAlias { .. } => { + (DestinationDenialKind::TrustedGateway, true) + } + AddressAuthorization::ExplicitAllowedIps(_) + | AddressAuthorization::ImplicitIpLiteral(_) => (DestinationDenialKind::AllowedIps, true), + AddressAuthorization::ExactDeclaredHost => (DestinationDenialKind::DeclaredEndpoint, true), + AddressAuthorization::DefaultPublicOnly => (DestinationDenialKind::InternalAddress, false), + AddressAuthorization::PinnedResolved(_) => (DestinationDenialKind::AllowedIps, false), + }; + + if control_plane_blocked && BLOCKED_CONTROL_PLANE_PORTS.contains(&port) { + return Err(DestinationDenial::new( + kind, + format!("port {port} is a blocked control-plane port, connection rejected"), + )); + } + + let mut allowed = Vec::new(); + let mut first_rejection = None; + for &ip in resolved_ips { + let rejection = match &plan.address_authorization { + AddressAuthorization::DefaultPublicOnly if is_internal_ip(ip) => Some(format!( + "{host} resolves to internal address {ip}, connection rejected" + )), + AddressAuthorization::ExplicitAllowedIps(networks) => { + if is_always_blocked_ip(ip) { + Some(format!( + "{host} resolves to always-blocked address {ip}, connection rejected" + )) + } else if !networks.iter().any(|network| network.contains(&ip)) { + Some(format!( + "{host} resolves to {ip} which is not in allowed_ips, connection rejected" + )) + } else { + None + } + } + AddressAuthorization::ImplicitIpLiteral(expected_ip) => { + if is_always_blocked_ip(ip) { + Some(format!( + "{host} resolves to always-blocked address {ip}, connection rejected" + )) + } else if ip != *expected_ip { + Some(format!( + "{host} resolves to {ip} which is not in allowed_ips, connection rejected" + )) + } else { + None + } + } + AddressAuthorization::ExactDeclaredHost if is_always_blocked_ip(ip) => Some(format!( + "{host} resolves to always-blocked address {ip}, connection rejected" + )), + AddressAuthorization::TrustedGatewayAlias { expected_ip } => { + if is_cloud_metadata_ip(ip) { + Some(format!( + "{host} resolves to cloud metadata address {ip}, connection rejected" + )) + } else if ip != *expected_ip { + Some(format!( + "{host} resolves to {ip} which does not match trusted host gateway \ + {expected_ip}, connection rejected" + )) + } else if !is_link_local_ip(ip) { + Some(format!( + "{host} resolves to non-link-local address {ip}, connection rejected" + )) + } else { + None + } + } + AddressAuthorization::PinnedResolved(pinned) if !pinned.contains(&ip) => Some(format!( + "{host} resolves to unpinned address {ip}, connection rejected" + )), + AddressAuthorization::DefaultPublicOnly + | AddressAuthorization::ExactDeclaredHost + | AddressAuthorization::PinnedResolved(_) => None, + }; + if let Some(reason) = rejection { + first_rejection.get_or_insert(reason); + } else if !allowed.contains(&ip) { + allowed.push(ip); + } + } + + if allowed.is_empty() { + return Err(DestinationDenial::new( + kind, + first_rejection.unwrap_or_else(|| { + format!( + "DNS resolution returned no addresses for {}", + super::normalize_host_lookup_key(host) + ) + }), + )); + } + + Ok(allowed) +} + /// Validated, but not yet opened, upstream destination. /// /// The explicit proxy adapter controls when `connect` is called so CONNECT and /// forward HTTP retain their current upstream-dial timing during the refactor. -pub(super) struct UpstreamConnector { +pub(crate) struct UpstreamConnector { host: String, port: u16, addrs: Vec, } impl UpstreamConnector { - pub(super) fn addrs(&self) -> &[SocketAddr] { + pub(crate) fn addrs(&self) -> &[SocketAddr] { &self.addrs } /// Opens the connection with `TCP_NODELAY` set: this is the upstream dial /// boundary for latency-sensitive proxied request/response traffic, where /// Nagle would stall sub-MSS writes on delayed ACKs. - pub(super) async fn connect(&self) -> std::io::Result { + pub(crate) async fn connect(&self) -> std::io::Result { tracing::debug!( host = %self.host, port = self.port, @@ -147,7 +268,7 @@ impl UpstreamConnector { connect_tcp_nodelay_best_effort(self.addrs.as_slice()).await } - fn new(host: &str, port: u16, addrs: Vec) -> Self { + pub(crate) fn new(host: &str, port: u16, addrs: Vec) -> Self { Self { host: host.to_string(), port, @@ -157,7 +278,7 @@ impl UpstreamConnector { } /// Resolve and validate a destination using the existing proxy security rules. -pub(super) async fn validate_destination( +pub(crate) async fn validate_destination( request: DestinationRequest<'_>, ) -> Result { let DestinationRequest { @@ -317,6 +438,82 @@ mod tests { assert!(denial.reason.contains("empty pinned address set")); } + #[test] + fn address_filter_retains_public_answer_from_mixed_set() { + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::DefaultPublicOnly, + }; + let public: IpAddr = "8.8.8.8".parse().unwrap(); + let private: IpAddr = "10.1.2.3".parse().unwrap(); + + let allowed = + filter_resolved_addresses(&plan, "mixed.example", 443, &[private, public]).unwrap(); + + assert_eq!(allowed, vec![public]); + } + + #[test] + fn address_filter_exact_host_allows_private_but_not_always_blocked() { + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::ExactDeclaredHost, + }; + let private: IpAddr = "10.1.2.3".parse().unwrap(); + let loopback: IpAddr = "127.0.0.1".parse().unwrap(); + + let allowed = + filter_resolved_addresses(&plan, "private.example", 443, &[loopback, private]).unwrap(); + + assert_eq!(allowed, vec![private]); + } + + #[test] + fn address_filter_enforces_allowed_ips() { + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::ExplicitAllowedIps(vec![ + "10.2.0.0/16".parse().unwrap(), + ]), + }; + let included: IpAddr = "10.2.3.4".parse().unwrap(); + let excluded: IpAddr = "10.3.4.5".parse().unwrap(); + + let allowed = + filter_resolved_addresses(&plan, "allowlisted.example", 443, &[excluded, included]) + .unwrap(); + + assert_eq!(allowed, vec![included]); + } + + #[test] + fn address_filter_rejects_always_blocked_only_answer() { + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::ExactDeclaredHost, + }; + + let denial = filter_resolved_addresses( + &plan, + "loopback.example", + 443, + &["127.0.0.1".parse().unwrap()], + ) + .expect_err("loopback must not survive filtering"); + + assert_eq!(denial.kind, DestinationDenialKind::DeclaredEndpoint); + } + + #[test] + fn address_filter_rejects_control_plane_port() { + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::ExactDeclaredHost, + }; + + let denial = + filter_resolved_addresses(&plan, "api.example", 6443, &["8.8.8.8".parse().unwrap()]) + .expect_err("control-plane port must remain blocked"); + + assert_eq!(denial.kind, DestinationDenialKind::DeclaredEndpoint); + assert!(denial.reason.contains("blocked control-plane port")); + } + #[test] fn validation_mode_precedence_is_explicit_and_stable() { let trusted_ip = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)); From c3534b1a40e6c4b5903c79812652fe008588a735 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:18:19 -0700 Subject: [PATCH 14/17] docs(network): describe dormant policy DNS boundary Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- architecture/sandbox.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 9842d74748..db55a7fa52 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -77,9 +77,16 @@ Adapter-specific response and OCSF event shapes remain at the protocol boundary. Policy authors may use `protocol: tcp` as an explicit spelling of the existing L4 passthrough behavior. Explicit TCP endpoints require a valid DNS hostname; hostless `allowed_ips` and literal-IP selectors remain available only to the -legacy forward-proxy path when `protocol` is omitted. The egress intent reserves -a transparent TCP adapter and a policy-DNS-pinned address, but DNS serving and -transparent TCP capture are not active yet. +legacy forward-proxy path when `protocol` is omitted. The network supervisor +contains a dormant policy-DNS boundary for explicit TCP endpoints: +it snapshots eligible endpoint identities from one policy generation, resolves +eligible names only through an explicitly supplied trusted resolver, filters +answers through the shared destination controls, and publishes expiring +synthetic-address mappings with separate mapping generations. Refreshes retain +their synthetic identity, and policy reload, expiry, wrong ports, missing +mappings, or pool exhaustion fail closed. The pinned connector never resolves +the name again. No DNS listener is exposed to workloads, resolver configuration +is not injected, and transparent TCP capture is not active in this increment. Provider credential placeholders are resolved through the live provider state for each HTTP request, after destination and L7 policy admission. A static From 7a8803a21c97e7df5b9a05fdda04568075f8985b Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:25:05 -0700 Subject: [PATCH 15/17] fix(network): harden policy DNS publication Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../openshell-supervisor-network/src/opa.rs | 28 +++ .../src/policy_dns/mod.rs | 189 ++++++++++++++++-- .../src/policy_dns/store.rs | 33 +++ .../src/policy_dns/wire.rs | 3 +- .../openshell-supervisor-network/src/proxy.rs | 2 +- 5 files changed, 231 insertions(+), 24 deletions(-) diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index f5a7d77de1..a6d6b777ed 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -804,9 +804,37 @@ impl OpaEngine { self.generation.load(Ordering::Acquire) } + /// Run a short operation only while `expected_generation` is current. + /// + /// The engine mutex is also the policy reload mutex. Holding it across the + /// generation comparison and callback linearizes state derived from an OPA + /// snapshot with every policy reload and fail-closed transition. Callers + /// must not perform I/O or other long-running work in `operation`. + pub(crate) fn with_current_generation( + &self, + expected_generation: u64, + operation: impl FnOnce(u64) -> T, + ) -> Result> { + let _engine = self + .engine + .lock() + .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; + let current_generation = self.current_generation(); + if current_generation != expected_generation { + return Ok(None); + } + Ok(Some(operation(current_generation))) + } + /// Replace the complete middleware service registry and invalidate /// existing tunnels so subsequent requests use the new service set. pub fn replace_middleware_registry(&self, registry: MiddlewareRegistry) -> Result<()> { + // Generation changes serialize through the engine lock so guarded + // publication cannot overlap any runtime generation transition. + let _engine = self + .engine + .lock() + .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; let mut runner = self .middleware_runner .write() diff --git a/crates/openshell-supervisor-network/src/policy_dns/mod.rs b/crates/openshell-supervisor-network/src/policy_dns/mod.rs index f9ef3cc557..1c5bdffcdc 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/mod.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/mod.rs @@ -33,6 +33,7 @@ pub(crate) use store::{ use crate::opa::OpaEngine; use crate::proxy::destination::{build_validation_plan, filter_resolved_addresses}; +use crate::proxy::is_host_gateway_alias; use openshell_core::host_pattern::HostSelector; use openshell_ocsf::{ ActionId, ActivityId, ConfigStateChangeBuilder, DispositionId, Endpoint, @@ -60,6 +61,8 @@ pub(crate) enum PolicyDnsError { InvalidName, #[error("DNS name is not eligible for policy DNS")] Ineligible, + #[error("trusted host gateway is unavailable for the reserved alias")] + TrustedGatewayUnavailable, #[error("trusted resolver failed: {0}")] Resolver(#[from] resolver::ResolveError), #[error("no trusted resolver address passed endpoint destination policy")] @@ -80,6 +83,7 @@ pub(crate) struct PolicyDnsService { policy: Arc, resolver: R, store: Arc, + trusted_host_gateway: Option, } impl PolicyDnsService { @@ -87,11 +91,13 @@ impl PolicyDnsService { policy: Arc, resolver: R, store: Arc, + trusted_host_gateway: Option, ) -> Self { Self { policy, resolver, store, + trusted_host_gateway, } } @@ -104,11 +110,24 @@ impl PolicyDnsService { self.store.note_query(); let normalized_name = NormalizedName::parse(raw_name).map_err(|_| PolicyDnsError::InvalidName)?; + if is_host_gateway_alias(normalized_name.as_str()) && self.trusted_host_gateway.is_none() { + self.store.note_refused(); + emit_dns_denial( + &normalized_name, + "policy_dns_trusted_gateway_unavailable", + "Policy DNS refused a reserved host-gateway alias because no trusted gateway is configured", + ); + return Err(PolicyDnsError::TrustedGatewayUnavailable); + } let snapshot = self .policy .policy_dns_eligibility_snapshot() .map_err(|error| PolicyDnsError::Policy(error.to_string()))?; - let eligible = eligible_endpoints(&snapshot.endpoints, &normalized_name)?; + let eligible = eligible_endpoints( + &snapshot.endpoints, + &normalized_name, + self.trusted_host_gateway, + )?; if eligible.is_empty() { self.store.note_refused(); emit_dns_denial( @@ -157,22 +176,22 @@ impl PolicyDnsService { return Err(PolicyDnsError::NoValidAddress); } - let current_generation = self.policy.current_generation(); - if current_generation != snapshot.generation { - return Err(PolicyDnsError::StalePolicy); - } - let record = self.store.publish( - PublishRequest { - normalized_name: normalized_name.clone(), - family, - allocation_identity, - policy_generation: snapshot.generation, - ttl, - contracts, - }, - current_generation, - now, - )?; + let request = PublishRequest { + normalized_name: normalized_name.clone(), + family, + allocation_identity, + policy_generation: snapshot.generation, + ttl, + contracts, + }; + let publication = self + .policy + .with_current_generation(snapshot.generation, |current_generation| { + self.store.publish(request, current_generation, now) + }) + .map_err(|error| PolicyDnsError::Policy(error.to_string()))? + .ok_or(PolicyDnsError::StalePolicy)?; + let record = publication?; emit_mapping_publication(&record); Ok(SyntheticAnswer { address: record.synthetic_address, @@ -198,6 +217,7 @@ struct EligibleEndpoint { fn eligible_endpoints( endpoints: &[crate::opa::MatchedEndpoint], name: &NormalizedName, + trusted_host_gateway: Option, ) -> Result, PolicyDnsError> { let mut eligible = Vec::new(); for endpoint in endpoints { @@ -219,7 +239,7 @@ fn eligible_endpoints( let destination_plan = build_validation_plan( name.as_str(), name.as_str(), - None, + trusted_host_gateway, &raw_allowed_ips, exact_declared_host, ) @@ -364,6 +384,14 @@ mod tests { } fn service(policy_yaml: &str, addresses: Vec) -> PolicyDnsService { + service_with_gateway(policy_yaml, addresses, None) + } + + fn service_with_gateway( + policy_yaml: &str, + addresses: Vec, + trusted_host_gateway: Option, + ) -> PolicyDnsService { let policy = Arc::new( OpaEngine::from_strings(include_str!("../../data/sandbox-policy.rego"), policy_yaml) .unwrap(), @@ -385,6 +413,7 @@ mod tests { Arc::new(ResolvedEndpointStore::new( StoreConfig::new(pools, 16).unwrap(), )), + trusted_host_gateway, ) } @@ -467,6 +496,93 @@ process: { run_as_user: sandbox, run_as_group: sandbox } ); } + const HOST_GATEWAY_POLICY: &str = r" +network_policies: + gateway: + name: gateway + endpoints: + - { host: host.openshell.internal, port: 8080, protocol: tcp } + binaries: [{ path: /usr/bin/client }] +filesystem_policy: { include_workdir: true, read_only: [], read_write: [] } +landlock: { compatibility: best_effort } +process: { run_as_user: sandbox, run_as_group: sandbox } +"; + + fn gateway_service( + addresses: Vec, + trusted_host_gateway: Option, + ) -> PolicyDnsService { + service_with_gateway(HOST_GATEWAY_POLICY, addresses, trusted_host_gateway) + } + + #[tokio::test] + async fn reserved_gateway_alias_without_trusted_address_never_queries_resolver() { + for alias in [ + "host.openshell.internal", + "host.containers.internal", + "host.docker.internal", + ] { + let yaml = HOST_GATEWAY_POLICY.replace("host.openshell.internal", alias); + let service = service_with_gateway(&yaml, vec!["169.254.1.2".parse().unwrap()], None); + + let result = service + .answer_query(alias, AddressFamily::Ipv4, Instant::now()) + .await; + + assert!(matches!( + result, + Err(PolicyDnsError::TrustedGatewayUnavailable) + )); + assert_eq!(service.resolver.calls.load(Ordering::SeqCst), 0); + } + } + + #[tokio::test] + async fn reserved_gateway_alias_pins_only_the_exact_trusted_address() { + let trusted: IpAddr = "169.254.1.2".parse().unwrap(); + let service = gateway_service( + vec![ + "169.254.169.254".parse().unwrap(), + "169.254.1.3".parse().unwrap(), + "10.2.3.4".parse().unwrap(), + trusted, + ], + Some(trusted), + ); + let now = Instant::now(); + + let answer = service + .answer_query("host.openshell.internal", AddressFamily::Ipv4, now) + .await + .unwrap(); + let mapping = service + .store + .lookup(answer.address, 8080, answer.policy_generation, now) + .unwrap(); + + assert_eq!(mapping.record.contracts[0].pinned_addresses, [trusted]); + } + + #[tokio::test] + async fn reserved_gateway_alias_rejects_mismatch_metadata_private_and_wrong_family_answers() { + let trusted: IpAddr = "169.254.1.2".parse().unwrap(); + for (family, address) in [ + (AddressFamily::Ipv4, "169.254.1.3"), + (AddressFamily::Ipv4, "169.254.169.254"), + (AddressFamily::Ipv4, "10.2.3.4"), + (AddressFamily::Ipv6, "fe80::2"), + ] { + let service = gateway_service(vec![address.parse().unwrap()], Some(trusted)); + let result = service + .answer_query("host.openshell.internal", family, Instant::now()) + .await; + assert!( + matches!(result, Err(PolicyDnsError::NoValidAddress)), + "{address} must not satisfy the trusted gateway contract" + ); + } + } + struct BlockingResolver { started: Arc, release: Arc, @@ -488,7 +604,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } } #[tokio::test] - async fn policy_reload_during_resolution_publishes_nothing() { + async fn delayed_stale_resolution_cannot_replace_newer_generation_mapping() { let policy = Arc::new( OpaEngine::from_strings(include_str!("../../data/sandbox-policy.rego"), BASE_POLICY) .unwrap(), @@ -510,6 +626,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } release: release.clone(), }, store.clone(), + None, )); let query = tokio::spawn(async move { service @@ -520,14 +637,42 @@ process: { run_as_user: sandbox, run_as_group: sandbox } policy .reload(include_str!("../../data/sandbox-policy.rego"), BASE_POLICY) .unwrap(); + let current_service = PolicyDnsService::new( + policy.clone(), + FakeResolver { + calls: AtomicUsize::new(0), + answer: TrustedAnswer { + addresses: vec!["8.8.4.4".parse().unwrap()], + ttl: Duration::from_secs(10), + }, + }, + store.clone(), + None, + ); + let now = Instant::now(); + let current = current_service + .answer_query("db.example", AddressFamily::Ipv4, now) + .await + .unwrap(); release.notify_one(); assert!(matches!( query.await.unwrap(), Err(PolicyDnsError::StalePolicy) )); - let metrics = store.metrics(Instant::now()); - assert_eq!(metrics.active_mappings, 0); - assert_eq!(metrics.allocated_identities, 0); + let mapping = store + .lookup(current.address, 5432, current.policy_generation, now) + .unwrap(); + assert_eq!( + mapping.record.policy_generation, + policy.current_generation() + ); + assert_eq!( + mapping.record.contracts[0].pinned_addresses, + ["8.8.4.4".parse::().unwrap()] + ); + let metrics = store.metrics(now); + assert_eq!(metrics.active_mappings, 1); + assert_eq!(metrics.allocated_identities, 1); } #[test] diff --git a/crates/openshell-supervisor-network/src/policy_dns/store.rs b/crates/openshell-supervisor-network/src/policy_dns/store.rs index 07190af9a8..cc9d9f9680 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/store.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/store.rs @@ -338,6 +338,17 @@ impl ResolvedEndpointStore { address }; + // Defense in depth for callers outside the OPA generation guard: a + // delayed publication from an older generation must never replace a + // newer live correlation for the same stable allocation identity. + if state + .records + .get(&synthetic_address) + .is_some_and(|record| record.policy_generation > request.policy_generation) + { + return Err(PublishError::StalePolicy); + } + state.next_mapping_generation = state.next_mapping_generation.saturating_add(1); let record = ResolvedEndpointRecord { synthetic_address, @@ -619,6 +630,28 @@ mod tests { assert_eq!(metrics.pool_exhausted, 1); } + #[test] + fn older_generation_cannot_replace_newer_live_mapping() { + let store = store(1); + let now = Instant::now(); + let newer = store + .publish(request("db.example", 2, Duration::from_secs(10)), 2, now) + .unwrap(); + + assert!(matches!( + store.publish( + request("db.example", 1, Duration::from_secs(10)), + 1, + now + Duration::from_secs(1), + ), + Err(PublishError::StalePolicy) + )); + + let mapping = store.lookup(newer.synthetic_address, 5432, 2, now).unwrap(); + assert_eq!(mapping.record.mapping_id, newer.mapping_id); + assert_eq!(mapping.record.policy_generation, 2); + } + #[test] fn real_address_never_inherits_synthetic_mapping() { let store = store(1); diff --git a/crates/openshell-supervisor-network/src/policy_dns/wire.rs b/crates/openshell-supervisor-network/src/policy_dns/wire.rs index 27b0698ddc..ecc520b428 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/wire.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/wire.rs @@ -73,7 +73,7 @@ pub(crate) async fn handle_udp_query( )); encode_message(response) } - Err(PolicyDnsError::Ineligible) => { + Err(PolicyDnsError::Ineligible | PolicyDnsError::TrustedGatewayUnavailable) => { encode_message(response_with_code(&request, ResponseCode::Refused)) } Err(PolicyDnsError::Resolver(ResolveError::NxDomain)) => { @@ -206,6 +206,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } Arc::new(ResolvedEndpointStore::new( StoreConfig::new(pools, 8).unwrap(), )), + None, ) } diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 0bf88a29ed..b44ffa603e 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -3101,7 +3101,7 @@ fn normalize_host_lookup_key(host: &str) -> &str { /// Returns `true` if `host` is one of the well-known driver-injected aliases /// for the host machine (e.g. `host.openshell.internal`). -fn is_host_gateway_alias(host: &str) -> bool { +pub(crate) fn is_host_gateway_alias(host: &str) -> bool { let h = normalize_host_lookup_key(host); HOST_GATEWAY_ALIASES .iter() From f52f12258a7d568034379a740d22d4c6ba68a6ab Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:30:19 -0700 Subject: [PATCH 16/17] fix(network): audit policy DNS failures Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/policy_dns/mod.rs | 271 ++++++++++++++++-- .../src/policy_dns/name.rs | 3 + .../src/policy_dns/resolver.rs | 7 + .../src/policy_dns/store.rs | 102 +------ 4 files changed, 277 insertions(+), 106 deletions(-) diff --git a/crates/openshell-supervisor-network/src/policy_dns/mod.rs b/crates/openshell-supervisor-network/src/policy_dns/mod.rs index 1c5bdffcdc..e4bd5499b2 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/mod.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/mod.rs @@ -26,9 +26,9 @@ mod wire; pub(crate) use name::NormalizedName; pub(crate) use resolver::{AddressFamily, SocketTrustedResolver, TrustedAnswer, TrustedResolver}; pub(crate) use store::{ - MappingLookup, MappingLookupError, PolicyDnsMetricsSnapshot, PolicyEndpointId, PublishError, - PublishRequest, ResolvedEndpointRecord, ResolvedEndpointStore, ResolvedPortContract, - StoreConfig, SyntheticPools, + MappingLookup, MappingLookupError, PolicyEndpointId, PublishError, PublishRequest, + ResolvedEndpointRecord, ResolvedEndpointStore, ResolvedPortContract, StoreConfig, + SyntheticPools, }; use crate::opa::OpaEngine; @@ -107,11 +107,9 @@ impl PolicyDnsService { family: AddressFamily, now: Instant, ) -> Result { - self.store.note_query(); let normalized_name = NormalizedName::parse(raw_name).map_err(|_| PolicyDnsError::InvalidName)?; if is_host_gateway_alias(normalized_name.as_str()) && self.trusted_host_gateway.is_none() { - self.store.note_refused(); emit_dns_denial( &normalized_name, "policy_dns_trusted_gateway_unavailable", @@ -129,7 +127,6 @@ impl PolicyDnsService { self.trusted_host_gateway, )?; if eligible.is_empty() { - self.store.note_refused(); emit_dns_denial( &normalized_name, "policy_dns_ineligible", @@ -140,8 +137,21 @@ impl PolicyDnsService { // The trusted resolver is invoked only after the immutable snapshot // proved policy eligibility. It never consults sandbox resolver state. - self.store.note_upstream_query(); - let trusted_answer = self.resolver.resolve(&normalized_name, family).await?; + let endpoint_context = eligible_endpoint_context(&eligible); + let trusted_answer = match self.resolver.resolve(&normalized_name, family).await { + Ok(answer) => answer, + Err(error) => { + emit_dns_failure( + &normalized_name, + family, + &endpoint_context, + snapshot.generation, + resolver_failure_detail(&error), + "Policy DNS trusted resolver query failed", + ); + return Err(PolicyDnsError::Resolver(error)); + } + }; let ttl = clamp_mapping_ttl(trusted_answer.ttl); let allocation_identity = allocation_identity(&eligible); let mut contracts = Vec::new(); @@ -167,7 +177,6 @@ impl PolicyDnsService { (&left.endpoint_id, left.port).cmp(&(&right.endpoint_id, right.port)) }); if contracts.is_empty() { - self.store.note_no_valid_address(); emit_dns_denial( &normalized_name, "policy_dns_no_valid_address", @@ -184,14 +193,50 @@ impl PolicyDnsService { ttl, contracts, }; - let publication = self + let record = match self .policy .with_current_generation(snapshot.generation, |current_generation| { self.store.publish(request, current_generation, now) - }) - .map_err(|error| PolicyDnsError::Policy(error.to_string()))? - .ok_or(PolicyDnsError::StalePolicy)?; - let record = publication?; + }) { + Ok(Some(Ok(record))) => record, + Ok(Some(Err(error))) => { + // InvalidMapping is unreachable for the well-formed request + // assembled above, and LockPoisoned requires a prior panic + // while holding the store lock. Keep both defensive outcomes + // observable because the store API intentionally rejects them. + emit_dns_failure( + &normalized_name, + family, + &endpoint_context, + snapshot.generation, + publication_failure_detail(error), + "Policy DNS resolved-endpoint mapping publication failed", + ); + return Err(PolicyDnsError::Publish(error)); + } + Ok(None) => { + emit_dns_failure( + &normalized_name, + family, + &endpoint_context, + snapshot.generation, + "policy_dns_publication_stale_generation", + "Policy DNS discarded a stale resolved-endpoint mapping", + ); + return Err(PolicyDnsError::StalePolicy); + } + Err(error) => { + emit_dns_failure( + &normalized_name, + family, + &endpoint_context, + snapshot.generation, + "policy_dns_publication_generation_check_failed", + "Policy DNS could not validate the active policy generation before publication", + ); + return Err(PolicyDnsError::Policy(error.to_string())); + } + }; emit_mapping_publication(&record); Ok(SyntheticAnswer { address: record.synthetic_address, @@ -278,6 +323,16 @@ fn allocation_identity(endpoints: &[EligibleEndpoint]) -> [u8; 32] { hasher.finalize().into() } +fn eligible_endpoint_context(endpoints: &[EligibleEndpoint]) -> Vec { + let mut endpoint_ids = endpoints + .iter() + .map(|endpoint| endpoint.endpoint_id.clone()) + .collect::>(); + endpoint_ids.sort(); + endpoint_ids.dedup(); + endpoint_ids +} + fn value_field<'a>(value: &'a regorus::Value, key: &str) -> Option<&'a regorus::Value> { let regorus::Value::Object(fields) = value else { return None; @@ -343,6 +398,82 @@ fn emit_dns_denial(name: &NormalizedName, detail: &str, message: &str) { ); } +fn resolver_failure_detail(error: &resolver::ResolveError) -> &'static str { + match error { + resolver::ResolveError::Timeout => "policy_dns_upstream_timeout", + resolver::ResolveError::Io(_) => "policy_dns_upstream_io_failed", + resolver::ResolveError::Oversized => "policy_dns_upstream_oversized_response", + resolver::ResolveError::Malformed => "policy_dns_upstream_malformed_response", + resolver::ResolveError::NxDomain => "policy_dns_upstream_nxdomain", + resolver::ResolveError::Response(_) => "policy_dns_upstream_error_response", + resolver::ResolveError::NoData => "policy_dns_upstream_no_data", + resolver::ResolveError::CnameLimit => "policy_dns_upstream_cname_limit", + } +} + +fn publication_failure_detail(error: PublishError) -> &'static str { + match error { + PublishError::StalePolicy => "policy_dns_publication_stale_generation", + PublishError::InvalidMapping => "policy_dns_publication_invalid_mapping", + PublishError::PoolExhausted => "policy_dns_publication_pool_exhausted", + PublishError::LockPoisoned => "policy_dns_publication_store_unavailable", + } +} + +fn build_dns_failure_event( + name: &NormalizedName, + family: AddressFamily, + eligible_endpoints: &[PolicyEndpointId], + policy_generation: u64, + detail: &str, + message: &str, +) -> openshell_ocsf::OcsfEvent { + let endpoint_context = eligible_endpoints + .iter() + .map(|endpoint| { + serde_json::json!({ + "policy_name": endpoint.policy_name.as_str(), + "endpoint_index": endpoint.endpoint_index, + }) + }) + .collect::>(); + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Refuse) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(name.as_str(), 53)) + .status_detail(detail) + .unmapped("normalized_name", name.as_str()) + .unmapped("address_family", family.as_str()) + .unmapped( + "eligible_endpoints", + serde_json::Value::Array(endpoint_context), + ) + .unmapped("policy_generation", policy_generation) + .message(message) + .build() +} + +fn emit_dns_failure( + name: &NormalizedName, + family: AddressFamily, + eligible_endpoints: &[PolicyEndpointId], + policy_generation: u64, + detail: &str, + message: &str, +) { + ocsf_emit!(build_dns_failure_event( + name, + family, + eligible_endpoints, + policy_generation, + detail, + message, + )); +} + fn emit_mapping_publication(record: &ResolvedEndpointRecord) { ocsf_emit!( ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) @@ -372,6 +503,18 @@ mod tests { answer: TrustedAnswer, } + struct NxDomainResolver; + + impl TrustedResolver for NxDomainResolver { + async fn resolve( + &self, + _name: &NormalizedName, + _family: AddressFamily, + ) -> Result { + Err(resolver::ResolveError::NxDomain) + } + } + impl TrustedResolver for FakeResolver { async fn resolve( &self, @@ -437,7 +580,41 @@ process: { run_as_user: sandbox, run_as_group: sandbox } .await; assert!(matches!(result, Err(PolicyDnsError::Ineligible))); assert_eq!(service.resolver.calls.load(Ordering::SeqCst), 0); - assert_eq!(service.store.metrics(Instant::now()).refused, 1); + } + + #[tokio::test] + async fn eligible_nxdomain_fails_without_publishing_a_mapping() { + let policy = Arc::new( + OpaEngine::from_strings(include_str!("../../data/sandbox-policy.rego"), BASE_POLICY) + .unwrap(), + ); + let pools = SyntheticPools::new( + Ipv4Addr::new(198, 18, 0, 1)..=Ipv4Addr::new(198, 18, 0, 1), + "fd00:1::1".parse::().unwrap()..="fd00:1::1".parse::().unwrap(), + ) + .unwrap(); + let store = Arc::new(ResolvedEndpointStore::new( + StoreConfig::new(pools, 1).unwrap(), + )); + let service = PolicyDnsService::new(policy, NxDomainResolver, store.clone(), None); + + let result = service + .answer_query("DB.EXAMPLE.", AddressFamily::Ipv4, Instant::now()) + .await; + + assert!(matches!( + result, + Err(PolicyDnsError::Resolver(resolver::ResolveError::NxDomain)) + )); + assert!(matches!( + store.lookup( + "198.18.0.1".parse().unwrap(), + 5432, + service.policy.current_generation(), + Instant::now() + ), + Err(MappingLookupError::Missing) + )); } #[tokio::test] @@ -670,9 +847,67 @@ process: { run_as_user: sandbox, run_as_group: sandbox } mapping.record.contracts[0].pinned_addresses, ["8.8.4.4".parse::().unwrap()] ); - let metrics = store.metrics(now); - assert_eq!(metrics.active_mappings, 1); - assert_eq!(metrics.allocated_identities, 1); + } + + #[test] + fn policy_dns_failure_events_have_stable_actionable_context() { + let name = NormalizedName::parse("DB.EXAMPLE.").unwrap(); + let endpoints = vec![PolicyEndpointId { + policy_name: "database".to_string(), + endpoint_index: 2, + }]; + let event = build_dns_failure_event( + &name, + AddressFamily::Ipv4, + &endpoints, + 7, + "policy_dns_upstream_nxdomain", + "Policy DNS trusted resolver query failed", + ); + let json = serde_json::to_value(event).unwrap(); + + assert_eq!(json["activity_name"], "Refuse"); + assert_eq!(json["action"], "Denied"); + assert_eq!(json["severity"], "Low"); + assert_eq!(json["status"], "Failure"); + assert_eq!(json["status_detail"], "policy_dns_upstream_nxdomain"); + assert_eq!(json["dst_endpoint"]["domain"], "db.example"); + assert_eq!(json["dst_endpoint"]["port"], 53); + assert_eq!(json["unmapped"]["normalized_name"], "db.example"); + assert_eq!(json["unmapped"]["address_family"], "ipv4"); + assert_eq!(json["unmapped"]["policy_generation"], 7); + assert_eq!( + json["unmapped"]["eligible_endpoints"], + serde_json::json!([{"policy_name": "database", "endpoint_index": 2}]) + ); + } + + #[test] + fn resolver_and_publication_failures_have_stable_reason_codes() { + assert_eq!( + resolver_failure_detail(&resolver::ResolveError::NxDomain), + "policy_dns_upstream_nxdomain" + ); + assert_eq!( + resolver_failure_detail(&resolver::ResolveError::Timeout), + "policy_dns_upstream_timeout" + ); + assert_eq!( + publication_failure_detail(PublishError::StalePolicy), + "policy_dns_publication_stale_generation" + ); + assert_eq!( + publication_failure_detail(PublishError::InvalidMapping), + "policy_dns_publication_invalid_mapping" + ); + assert_eq!( + publication_failure_detail(PublishError::PoolExhausted), + "policy_dns_publication_pool_exhausted" + ); + assert_eq!( + publication_failure_detail(PublishError::LockPoisoned), + "policy_dns_publication_store_unavailable" + ); } #[test] diff --git a/crates/openshell-supervisor-network/src/policy_dns/name.rs b/crates/openshell-supervisor-network/src/policy_dns/name.rs index a2c9acb66d..0f2317e096 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/name.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/name.rs @@ -27,6 +27,9 @@ impl NormalizedName { return Err(NameError); } + // DNS names are case-insensitive. Canonicalizing to lowercase also + // prevents DNS 0x20 case variation from becoming an unobserved data + // channel in policy matching and synthetic-allocation identities. let normalized = parsed.to_ascii().trim_end_matches('.').to_ascii_lowercase(); if normalized.is_empty() { return Err(NameError); diff --git a/crates/openshell-supervisor-network/src/policy_dns/resolver.rs b/crates/openshell-supervisor-network/src/policy_dns/resolver.rs index 8a31f77856..286ad0bfb1 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/resolver.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/resolver.rs @@ -30,6 +30,13 @@ pub(crate) enum AddressFamily { } impl AddressFamily { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Ipv4 => "ipv4", + Self::Ipv6 => "ipv6", + } + } + pub(crate) fn record_type(self) -> RecordType { match self { Self::Ipv4 => RecordType::A, diff --git a/crates/openshell-supervisor-network/src/policy_dns/store.rs b/crates/openshell-supervisor-network/src/policy_dns/store.rs index cc9d9f9680..b48b0b7b1d 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/store.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/store.rs @@ -12,8 +12,7 @@ use crate::proxy::destination::{ use std::collections::{BTreeMap, BTreeSet}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::ops::RangeInclusive; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, RwLock}; +use std::sync::RwLock; use std::time::{Duration, Instant}; use uuid::Uuid; @@ -204,30 +203,6 @@ pub(crate) enum MappingLookupError { LockPoisoned, } -#[derive(Default)] -struct PolicyDnsMetrics { - queries: AtomicU64, - refused: AtomicU64, - upstream_queries: AtomicU64, - no_valid_address: AtomicU64, - mappings_published: AtomicU64, - mappings_expired: AtomicU64, - pool_exhausted: AtomicU64, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub(crate) struct PolicyDnsMetricsSnapshot { - pub(crate) queries: u64, - pub(crate) refused: u64, - pub(crate) upstream_queries: u64, - pub(crate) no_valid_address: u64, - pub(crate) mappings_published: u64, - pub(crate) mappings_expired: u64, - pub(crate) pool_exhausted: u64, - pub(crate) active_mappings: usize, - pub(crate) allocated_identities: usize, -} - #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] struct AllocationKey { normalized_name: NormalizedName, @@ -249,7 +224,6 @@ struct StoreState { pub(crate) struct ResolvedEndpointStore { state: RwLock, config: StoreConfig, - metrics: Arc, } impl ResolvedEndpointStore { @@ -270,30 +244,9 @@ impl ResolvedEndpointStore { next_mapping_generation: 0, }), config, - metrics: Arc::new(PolicyDnsMetrics::default()), } } - pub(crate) fn note_query(&self) { - self.metrics.queries.fetch_add(1, Ordering::Relaxed); - } - - pub(crate) fn note_refused(&self) { - self.metrics.refused.fetch_add(1, Ordering::Relaxed); - } - - pub(crate) fn note_upstream_query(&self) { - self.metrics - .upstream_queries - .fetch_add(1, Ordering::Relaxed); - } - - pub(crate) fn note_no_valid_address(&self) { - self.metrics - .no_valid_address - .fetch_add(1, Ordering::Relaxed); - } - pub(crate) fn publish( &self, request: PublishRequest, @@ -327,13 +280,10 @@ impl ResolvedEndpointStore { *address } else { if state.allocations.len() >= self.config.max_mappings { - self.metrics.pool_exhausted.fetch_add(1, Ordering::Relaxed); return Err(PublishError::PoolExhausted); } - let address = allocate_address(&mut state, request.family).ok_or_else(|| { - self.metrics.pool_exhausted.fetch_add(1, Ordering::Relaxed); - PublishError::PoolExhausted - })?; + let address = + allocate_address(&mut state, request.family).ok_or(PublishError::PoolExhausted)?; state.allocations.insert(key, address); address }; @@ -363,9 +313,6 @@ impl ResolvedEndpointStore { }; state.expired_allocations.remove(&synthetic_address); state.records.insert(synthetic_address, record.clone()); - self.metrics - .mappings_published - .fetch_add(1, Ordering::Relaxed); Ok(record) } @@ -421,33 +368,8 @@ impl ResolvedEndpointStore { state.records.remove(address); state.expired_allocations.insert(*address); } - self.metrics - .mappings_expired - .fetch_add(expired.len() as u64, Ordering::Relaxed); Ok(expired.len()) } - - pub(crate) fn metrics(&self, now: Instant) -> PolicyDnsMetricsSnapshot { - let state = self - .state - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - PolicyDnsMetricsSnapshot { - queries: self.metrics.queries.load(Ordering::Relaxed), - refused: self.metrics.refused.load(Ordering::Relaxed), - upstream_queries: self.metrics.upstream_queries.load(Ordering::Relaxed), - no_valid_address: self.metrics.no_valid_address.load(Ordering::Relaxed), - mappings_published: self.metrics.mappings_published.load(Ordering::Relaxed), - mappings_expired: self.metrics.mappings_expired.load(Ordering::Relaxed), - pool_exhausted: self.metrics.pool_exhausted.load(Ordering::Relaxed), - active_mappings: state - .records - .values() - .filter(|record| now < record.expires_at) - .count(), - allocated_identities: state.allocations.len(), - } - } } fn allocate_address(state: &mut StoreState, family: AddressFamily) -> Option { @@ -470,7 +392,7 @@ fn allocate_address(state: &mut StoreState, family: AddressFamily) -> Option ResolvedEndpointStore { let pools = SyntheticPools::new( @@ -617,17 +539,21 @@ mod tests { store.publish(request("stale.example", 1, Duration::from_secs(5)), 2, now), Err(PublishError::StalePolicy) )); - store + let first = store .publish(request("first.example", 2, Duration::from_secs(5)), 2, now) .unwrap(); assert!(matches!( store.publish(request("second.example", 2, Duration::from_secs(5)), 2, now), Err(PublishError::PoolExhausted) )); - let metrics = store.metrics(now); - assert_eq!(metrics.allocated_identities, 1); - assert_eq!(metrics.active_mappings, 1); - assert_eq!(metrics.pool_exhausted, 1); + let preserved = store + .lookup(first.synthetic_address, 5432, 2, now) + .expect("pool exhaustion must preserve the existing mapping"); + assert_eq!(preserved.record.mapping_id, first.mapping_id); + assert!(matches!( + store.lookup("198.18.0.2".parse().unwrap(), 5432, 2, now), + Err(MappingLookupError::Missing) + )); } #[test] @@ -708,7 +634,7 @@ mod tests { .unwrap(); assert!(!lookup.record.contracts.is_empty()); assert!(!lookup.record.contracts[0].pinned_addresses.is_empty()); - assert_eq!(store.metrics(now).allocated_identities, 1); + assert!(records.iter().all(|record| record.mapping_generation > 0)); } #[tokio::test] From f9798be876a1f9f18043ca0243c75b34b81922f5 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:21:03 -0700 Subject: [PATCH 17/17] fix(network): preserve DNS answer order Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/policy_dns/resolver.rs | 29 ++++++++++++++-- .../src/policy_dns/store.rs | 33 +++++++++++++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/openshell-supervisor-network/src/policy_dns/resolver.rs b/crates/openshell-supervisor-network/src/policy_dns/resolver.rs index 286ad0bfb1..3209efd14c 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/resolver.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/resolver.rs @@ -11,7 +11,7 @@ use super::name::NormalizedName; use hickory_proto::op::{Message, MessageType, OpCode, Query, ResponseCode}; use hickory_proto::rr::{Name, RData, RecordType}; use openshell_core::net::connect_tcp_nodelay_best_effort; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::net::{IpAddr, SocketAddr}; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -204,8 +204,7 @@ impl TrustedResolver for SocketTrustedResolver { .iter() .map(|(address, _)| *address) .collect::>(); - addresses.sort_unstable(); - addresses.dedup(); + retain_first_addresses(&mut addresses); addresses.truncate(MAX_RETAINED_ADDRESSES); let address_ttl = records.iter().map(|(_, ttl)| *ttl).min().unwrap_or(1); return Ok(TrustedAnswer { @@ -239,6 +238,11 @@ impl TrustedResolver for SocketTrustedResolver { } } +fn retain_first_addresses(addresses: &mut Vec) { + let mut seen = HashSet::new(); + addresses.retain(|address| seen.insert(*address)); +} + fn parse_response(wire: &[u8], id: u16, query: &Query) -> Result { if wire.len() > MAX_DNS_MESSAGE_BYTES { return Err(ResolveError::Oversized); @@ -311,6 +315,25 @@ mod tests { use openshell_core::net::set_tcp_nodelay_best_effort; use tokio::net::TcpListener; + #[test] + fn resolver_address_deduplication_preserves_answer_order() { + let mut addresses = vec![ + "203.0.113.20".parse().unwrap(), + "203.0.113.10".parse().unwrap(), + "203.0.113.20".parse().unwrap(), + ]; + + retain_first_addresses(&mut addresses); + + assert_eq!( + addresses, + vec![ + "203.0.113.20".parse::().unwrap(), + "203.0.113.10".parse::().unwrap(), + ] + ); + } + #[test] fn answer_parser_keeps_only_requested_family_and_bounds_are_constants() { let owner = Name::from_ascii("db.example.").unwrap(); diff --git a/crates/openshell-supervisor-network/src/policy_dns/store.rs b/crates/openshell-supervisor-network/src/policy_dns/store.rs index b48b0b7b1d..6198de744c 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/store.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/store.rs @@ -9,7 +9,7 @@ use crate::proxy::destination::{ DestinationRequest, DestinationValidationPlan, UpstreamConnector, build_pinned_validation_plan, validate_destination, }; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::ops::RangeInclusive; use std::sync::RwLock; @@ -89,14 +89,14 @@ impl MappingLookup { &self, endpoint_id: &PolicyEndpointId, ) -> Result { + let mut seen = HashSet::new(); let addresses = self .record .contracts .iter() .filter(|contract| contract.port == self.port && &contract.endpoint_id == endpoint_id) .flat_map(|contract| contract.pinned_addresses.iter().copied()) - .collect::>() - .into_iter() + .filter(|address| seen.insert(*address)) .collect::>(); if addresses.is_empty() { return Err(MappingLookupError::EndpointMismatch); @@ -655,4 +655,31 @@ mod tests { let connector = lookup.connector_for(&endpoint).await.unwrap(); assert_eq!(connector.addrs(), &["203.0.113.8:5432".parse().unwrap()]); } + + #[tokio::test] + async fn connector_preserves_resolver_address_order_while_deduplicating() { + let store = store(1); + let now = Instant::now(); + let mut request = request("must-not-resolve.invalid", 1, Duration::from_secs(5)); + request.contracts[0].pinned_addresses = vec![ + "203.0.113.20".parse().unwrap(), + "203.0.113.10".parse().unwrap(), + "203.0.113.20".parse().unwrap(), + ]; + let record = store.publish(request, 1, now).unwrap(); + let lookup = store + .lookup(record.synthetic_address, 5432, 1, now) + .unwrap(); + let endpoint = lookup.endpoint_ids().next().unwrap().clone(); + + let connector = lookup.connector_for(&endpoint).await.unwrap(); + + assert_eq!( + connector.addrs(), + &[ + "203.0.113.20:5432".parse().unwrap(), + "203.0.113.10:5432".parse().unwrap(), + ] + ); + } }