Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

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

13 changes: 10 additions & 3 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-supervisor-network/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
25 changes: 25 additions & 0 deletions crates/openshell-supervisor-network/data/sandbox-policy.rego
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-supervisor-network/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
145 changes: 145 additions & 0 deletions crates/openshell-supervisor-network/src/opa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MatchedEndpoint>,
pub generation: u64,
}

/// Atomic policy result used to authorize and materialize one egress request.
#[derive(Debug, Clone)]
pub struct EgressAuthorization {
Expand Down Expand Up @@ -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<PolicyDnsEligibilitySnapshot> {
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.
Expand Down Expand Up @@ -752,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<T>(
&self,
expected_generation: u64,
operation: impl FnOnce(u64) -> T,
) -> Result<Option<T>> {
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()
Expand Down Expand Up @@ -2074,6 +2154,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();
Expand Down
Loading
Loading