Skip to content
Draft
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
20 changes: 14 additions & 6 deletions docs/backends/wslc/wsl-container-getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,13 +558,13 @@ variables that well-behaved clients honor.

**How it works**

1. When `runtimeConfig.networkProxy` is set, the runner translates it into the
1. When `runtimeConfig.networkProxy` is set, MXC translates it into the
`HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment
variables inside the container (via `WslcSetProcessSettingsEnvVariables`).
Any caller-supplied values for these keys β€” including `NO_PROXY` /
`no_proxy` β€” are **stripped** from the *initial* process environment first.
Because WSLC merges the process environment onto the image's baked-in
`ENV`, the runner also sets `NO_PROXY` / `no_proxy` to the **empty string**,
`ENV`, MXC also sets `NO_PROXY` / `no_proxy` to the **empty string**,
so an image-baked exemption (e.g. `ENV NO_PROXY=*`) cannot silently disable
the proxy. This sanitizes the process's *starting* environment only; see the
cooperative-model caveat below.
Expand Down Expand Up @@ -593,11 +593,19 @@ proxy must have an address routable from the container:
}
```

**Where to set it**

- **Create-and-run execution:** on the request, with the unrestricted bridged
posture shown above. A proxy cannot make isolated networking reach an
external listener.

- **Container lifecycle operations:** on a `1.1.0-alpha` `provision` request,
the proxy applies to every `exec` in the container. An `exec` request can
also set `runtimeConfig.networkProxy`, but it must name the same URL. An `exec` always
inherits the provisioned network posture.

The removed `network.proxy` object and its `localhost`/`builtinTestServer`
forms are rejected by schema `0.9.0-alpha`. Create-and-run proxy use requires the unrestricted
bridged posture shown above; a proxy cannot make isolated networking reach an
external listener. Execution in an existing container (`exec`) instead supplies only `runtimeConfig` and
inherits its provisioned network posture.
forms are rejected by schema `0.9.0-alpha`.

### Per-host filtering is not supported

Expand Down
15 changes: 9 additions & 6 deletions docs/backends/wslc/wslc-state-aware.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,7 @@ containers reach Windows `127.0.0.1` services through `host.wslc.internal`; see
| Directional `network` posture | deny/deny/deny β†’ isolated; allow/allow/allow β†’ bridged | rejected | rejected; inherit provision posture |
| `network.egress.allow` / `deny` rules | rejected | rejected | rejected |
| Legacy `network` fields | structurally rejected in v0.9 | structurally rejected | structurally rejected |
| `runtimeConfig.networkProxy` | structurally rejected in published contracts; rejected by WSLc in `1.1.0-alpha` | structurally rejected | honored as a routable URL, injected as `HTTP_PROXY` / `HTTPS_PROXY` env vars |
| `runtimeConfig.networkProxy` | structurally rejected in published contracts; in `1.1.0-alpha`, honored as a routable URL and injected into every exec | structurally rejected | honored as a routable URL, injected as `HTTP_PROXY` / `HTTPS_PROXY` env vars; must match the provision URL, if one was set |
| `ui` | rejected | rejected | rejected |
| `process.env` / `process.inheritDefaultEnv` | n/a | n/a | honored β†’ see [Environment](wsl-container-getting-started.md#environment) |
| `process.timeout` | n/a | n/a | honored β†’ `ExecConfig.timeout_ms` |
Expand All @@ -675,9 +675,11 @@ Filesystem policy is fixed at `provision` and immutable afterwards.

The directional network posture is fixed at `provision`. Post-provision
phases reject supplied posture by presence, even when values equal defaults.
Only exec can supply a cooperative proxy through top-level `runtimeConfig`;
its request omits `network` so proxy-only execution inherits the existing
posture. Proxy URLs must address a listener reachable from the guest; a host
Provision (`1.1.0-alpha`) and exec can supply a cooperative proxy through
top-level `runtimeConfig`. A provision proxy applies to every exec in that
container; an exec may omit it or repeat the same URL, and a different URL is
rejected. An exec request omits `network`, so proxy-only execution inherits the
existing posture. Proxy URLs must address a listener reachable from the guest; a host
loopback listener is not made guest-reachable by spelling its host address
`localhost`.

Expand Down Expand Up @@ -730,8 +732,9 @@ success for a request it only partly honoured β€” a sandbox that is quietly not
what was asked for. A refusal the caller can see is better.

Upgrading an install that may have a live daemon therefore means stopping the
old one first. `PROTOCOL_VERSION` is **9** as of the addition of exec stdin
(the `stdin` flag on `exec` and the `exec_stdin` request).
old one first. `PROTOCOL_VERSION` is **10** as of the addition of
`network_proxy` to `provision` and `exec`, which moved applying the cooperative
proxy from the client to the daemon.

## Daemon idle-timeout env overrides

Expand Down
18 changes: 16 additions & 2 deletions src/mxc-sdk/src/backends/wslc/common/daemon_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ pub const MAX_EXEC_ID_BYTES: usize = 128;
///
/// Changing this strands whatever a running daemon is holding, since the SDK
/// offers no way to re-attach to a live session from another process.
pub const PROTOCOL_VERSION: u32 = 9;
pub const PROTOCOL_VERSION: u32 = 10;

/// Largest payload one [`StreamFrame::Stdin`] may carry.
pub const MAX_STDIN_CHUNK_BYTES: usize = 64 * 1024;
Expand Down Expand Up @@ -171,6 +171,10 @@ pub struct ProvisionConfig {
skip_serializing_if = "Option::is_none"
)]
pub session_ref: Option<String>,

/// Cooperative proxy URL applied to every exec in this container.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network_proxy: Option<String>,
}

/// Inputs to `start`: the container was created at provision; start boots it.
Expand Down Expand Up @@ -198,7 +202,8 @@ pub struct ExecConfig {
/// Working directory inside the container (empty = container default).
#[serde(default)]
pub working_directory: String,
/// Environment variables applied to the process, as `(name, value)` pairs.
/// Environment variables applied to the process, as `(name, value)` pairs,
/// before the daemon applies the cooperative proxy.
#[serde(default)]
pub env: Vec<(String, String)>,

Expand All @@ -214,6 +219,11 @@ pub struct ExecConfig {
/// immediately.
#[serde(default)]
pub stdin: bool,

/// This exec's cooperative proxy URL. It must match the one set at
/// provision, if any; absent uses that one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network_proxy: Option<String>,
}

/// Inputs to `stop`: stop the running container, keeping it created for a later
Expand Down Expand Up @@ -513,6 +523,7 @@ mod tests {
container_port: 80,
}],
session_ref: Some("wslcsess:abc".to_string()),
network_proxy: None,
}));
}

Expand All @@ -531,6 +542,7 @@ mod tests {
env_scope: EnvScope::Replace,
timeout_ms: 30_000,
stdin: true,
network_proxy: None,
}));
roundtrip(DaemonRequest::CancelExec(CancelExecConfig {
exec_id: "exec-1".to_string(),
Expand Down Expand Up @@ -578,6 +590,7 @@ mod tests {
env_scope: EnvScope::Replace,
timeout_ms: 0,
stdin: false,
network_proxy: None,
};

let frame = encode_frame(&DaemonRequest::Exec(config.clone())).unwrap();
Expand Down Expand Up @@ -643,6 +656,7 @@ mod tests {
network: NetworkMode::None,
port_mappings: Vec::new(),
session_ref: None,
network_proxy: None,
})
.expect("serialize");

Expand Down
116 changes: 92 additions & 24 deletions src/mxc-sdk/src/backends/wslc/common/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
//! | `ui` | rejected (no UI primitive) | rejected | rejected |
//! | `network.egress.default` | honoured (None / Bridged) | rejected (immutable) | rejected (immutable) |
//! | `network.ingress.*` | must match egress (deny all / allow all) | rejected | rejected |
//! | `runtimeConfig.networkProxy` | rejected (applies at exec) | rejected | honoured (cooperative env) |
//! | `runtimeConfig.networkProxy` | honoured (applied to every exec) | rejected | honoured (must match provision's) |
//!
//! [^1]: a standalone `denied_path` is honoured by container isolation (unlisted
//! host paths are simply never mounted); only a denied path nested under a
Expand All @@ -41,10 +41,11 @@ const ERR_ENUMERATE_PATHS: &str =
const ERR_NETWORK_IMMUTABLE: &str =
"network mode is bound to the provision phase and cannot be changed by the WSLc backend after \
provisioning";
const ERR_PROXY_AT_PROVISION: &str =
"runtimeConfig.networkProxy is applied per-exec by the WSLc backend; set it on the exec phase, not provision";
const ERR_PROXY_AT_PHASE: &str =
"runtimeConfig.networkProxy is only honoured on the exec phase by the WSLc backend";
"runtimeConfig.networkProxy is only honoured on the provision and exec phases by the WSLc backend";
const ERR_PROXY_MISMATCH: &str =
"WSLc: runtimeConfig.networkProxy on exec must match the proxy set at provision; omit it to \
use the provisioned proxy";
const ERR_PROXY_URL_FORM: &str =
"WSLc: runtimeConfig.networkProxy requires a proxy url reachable from inside the container; \
a host-loopback proxy is not reachable from the container's own network namespace";
Expand Down Expand Up @@ -137,9 +138,9 @@ pub(crate) fn validate_directional_network(request: &ExecutionRequest) -> Result
}

/// Validate the request for the provision phase. `rw` / `ro` paths become
/// volume mounts and `network.egress.default` selects the container network
/// mode; both are honoured here. Everything else in the module table is
/// rejected.
/// volume mounts, `network.egress.default` selects the container network
/// mode and a `url`-form proxy is stored for every exec; all are honoured
/// here. Everything else in the module table is rejected.
pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<(), MxcError> {
if !request.policy.enumerate_paths.is_empty() {
return Err(MxcError::policy_validation(ERR_ENUMERATE_PATHS));
Expand All @@ -151,16 +152,14 @@ pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<()
)
.map_err(MxcError::policy_validation)?;
reject_ui_policy(request)?;
if request.policy.network_proxy.is_enabled() {
return Err(MxcError::policy_validation(ERR_PROXY_AT_PROVISION));
}
reject_non_url_proxy(request)?;
validate_directional_network(request)?;
Ok(())
}

/// Validate the request for start / stop / deprovision. These phases carry no
/// applicable policy: filesystem and network mode are fixed at provision and
/// the proxy is an exec-time concern. A UI policy is never supported.
/// applicable policy: filesystem, network mode and the proxy are fixed at
/// provision. A UI policy is never supported.
pub(crate) fn validate_post_provision_policy(request: &ExecutionRequest) -> Result<(), MxcError> {
reject_filesystem_policy(request)?;
reject_ui_policy(request)?;
Expand All @@ -178,10 +177,16 @@ pub(crate) fn validate_exec_policy(request: &ExecutionRequest) -> Result<(), Mxc
reject_filesystem_policy(request)?;
reject_ui_policy(request)?;
reject_post_provision_network_mode(request)?;
reject_non_url_proxy(request)?;
reject_proxy_credentials_in_argv(request)?;
Ok(())
}

/// The proxy must be a URL so a routable value reaches the container.
fn reject_non_url_proxy(request: &ExecutionRequest) -> Result<(), MxcError> {
if request.policy.network_proxy.is_enabled() && exec_proxy_url(request).is_none() {
return Err(MxcError::policy_validation(ERR_PROXY_URL_FORM));
}
reject_proxy_credentials_in_argv(request)?;
Ok(())
}

Expand All @@ -194,18 +199,36 @@ pub(crate) fn validate_exec_policy(request: &ExecutionRequest) -> Result<(), Mxc
/// runs. The other scopes leave the entries with the SDK's environment setter,
/// which argv never sees.
pub(crate) fn reject_proxy_credentials_in_argv(request: &ExecutionRequest) -> Result<(), MxcError> {
if EnvScope::of(request) != EnvScope::Replace {
return Ok(());
}

match exec_proxy_url(request) {
Some(url) if crate::mxc_common::proxy_env::proxy_url_has_credentials(url) => {
Some(url) if exposes_proxy_credentials(EnvScope::of(request), url) => {
Err(MxcError::policy_validation(ERR_PROXY_CREDENTIALS_IN_ARGV))
}
_ => Ok(()),
}
}

fn exposes_proxy_credentials(scope: EnvScope, url: &str) -> bool {
scope == EnvScope::Replace && crate::mxc_common::proxy_env::proxy_url_has_credentials(url)
}

/// The proxy URL an exec runs under: its own, or the one set at provision when
/// it names none. The daemon calls this because only it holds the provisioned
/// URL; the error is a rejection message that never repeats either URL.
pub fn resolve_exec_proxy<'a>(
provisioned: Option<&'a str>,
requested: Option<&'a str>,
scope: EnvScope,
) -> Result<Option<&'a str>, &'static str> {
if provisioned.is_some() && requested.is_some() && provisioned != requested {
return Err(ERR_PROXY_MISMATCH);
}
let url = requested.or(provisioned);
if url.is_some_and(|url| exposes_proxy_credentials(scope, url)) {
return Err(ERR_PROXY_CREDENTIALS_IN_ARGV);
}
Ok(url)
}

/// The routable proxy URL to inject at exec, or `None` when the proxy is
/// disabled or specified without an original URL.
/// Borrows from the request so presence validation does not allocate.
Expand Down Expand Up @@ -473,17 +496,62 @@ mod tests {
}

#[test]
fn provision_rejects_proxy() {
fn provision_accepts_only_a_url_proxy() {
let req = parsed(
r#"{"version":"1.1.0-alpha","phase":"provision","containment":"wslc","network":{"egress":{"default":"allow"},"ingress":{"default":"allow","hostLoopback":"allow"}},"runtimeConfig":{"networkProxy":"http://proxy.example:8080"}}"#,
);
validate_provision_policy(&req).unwrap();
assert_eq!(exec_proxy_url(&req), Some("http://proxy.example:8080"));

let req = request_with_policy(ContainerPolicy {
network_proxy: url_proxy(),
network_egress: Some(NetworkEgressPolicy {
default: NetworkAction::Allow,
..Default::default()
}),
network_ingress: Some(NetworkIngressPolicy {
default: NetworkAction::Allow,
host_loopback: NetworkAction::Allow,
}),
network_proxy: non_url_proxy(),
..Default::default()
});
assert_policy_validation(validate_provision_policy(&req).unwrap_err(), "exec phase");
assert_policy_validation(validate_provision_policy(&req).unwrap_err(), "url");
}

let req = parsed(
r#"{"version":"1.1.0-alpha","phase":"provision","containment":"wslc","runtimeConfig":{"networkProxy":"http://proxy.example:8080"}}"#,
#[test]
fn an_exec_runs_under_its_own_or_the_provisioned_proxy_but_not_a_different_one() {
let provisioned = Some("http://proxy.example:8080");
for requested in [None, provisioned] {
assert_eq!(
resolve_exec_proxy(provisioned, requested, EnvScope::Merge),
Ok(provisioned)
);
}
assert_eq!(
resolve_exec_proxy(None, provisioned, EnvScope::Merge),
Ok(provisioned)
);
assert_eq!(
resolve_exec_proxy(
provisioned,
Some("http://other.example:8080"),
EnvScope::Merge
),
Err(ERR_PROXY_MISMATCH)
);
}

#[test]
fn a_replaced_environment_refuses_a_credential_bearing_provisioned_proxy() {
let url = credential_proxy().address.unwrap().original_url.unwrap();
assert_eq!(
resolve_exec_proxy(Some(&url), None, EnvScope::Replace),
Err(ERR_PROXY_CREDENTIALS_IN_ARGV)
);
assert_eq!(
resolve_exec_proxy(Some(&url), None, EnvScope::Merge),
Ok(Some(url.as_str()))
);
assert_policy_validation(validate_provision_policy(&req).unwrap_err(), "exec phase");
}

// ---- post-provision (start / stop / deprovision) ----
Expand Down
Loading
Loading