diff --git a/.azure-pipelines/templates/SDK.Integration.Test.Job.yml b/.azure-pipelines/templates/SDK.Integration.Test.Job.yml index b2b32dbc6..d3242d2a2 100644 --- a/.azure-pipelines/templates/SDK.Integration.Test.Job.yml +++ b/.azure-pipelines/templates/SDK.Integration.Test.Job.yml @@ -102,6 +102,20 @@ jobs: sudo apt-get install -y -qq lxc lxc-utils dnsmasq-base iptables bubblewrap slirp4netns displayName: Install LXC, Bubblewrap, and slirp4netns + # LXC enforces network policy from the policy itself, and the default is + # deny-all, so every LXC run installs an egress chain even when the + # request carries no network section. A bridged veth only reaches + # FORWARD while br_netfilter delivers bridged packets to iptables, and + # without it the backend refuses to report success for a policy it + # cannot enforce. Tolerated rather than required, because a hosted pool + # may forbid loading modules; when it does, the LXC tests fail exactly + # as they already would. + - script: | + sudo modprobe br_netfilter || echo "br_netfilter unavailable; LXC policy enforcement is unreachable on this pool" + sudo sysctl -w net.bridge.bridge-nf-call-iptables=1 || true + sudo sysctl -w net.bridge.bridge-nf-call-ip6tables=1 || true + displayName: Enable bridge netfilter + - script: sudo MXC_SKIP_LXC_NETWORK_TESTS=1 MXC_DEBUG=${{ parameters.debug }} npm test workingDirectory: $(integrationDirectory) displayName: npm test (sudo, with LXC) diff --git a/.gitattributes b/.gitattributes index 2260cbb80..0fa5766f3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,6 @@ *.sh text eol=lf .github/copilot-instructions.md whitespace=cr-at-eol .github/workflows/*.lock.yml linguist-generated=true +# Rust sources under the LXC backend are stored LF; editors on Windows write CRLF +# and silently re-commit the whole file as a rewrite. +src/backends/lxc/**/*.rs text eol=lf diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 69154ad32..5fb5113d6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -69,10 +69,14 @@ three `Build.*.Job.yml` workflows before calling the matrix job. catalog and expands a plan, so a backend may only be triggered where its platform declares it. - `scripts/ci/prepare-{windows,linux,macos}-host.{ps1,sh}` — per-backend host - prep, plus an inventory of the workload interpreters. Backend prerequisites - are installed per job; most workload interpreters come from image provisioning - scripts outside this repository, while Windows prep installs packaged `winapp` - and OpenSSL per job. + prep, plus an inventory of the workload interpreters. Backend prerequisites + + are installed per job; most workload interpreters come from image provisioning + + scripts outside this repository, while Windows prep installs packaged `winapp` + + and OpenSSL per job. + - `scripts/ci/run_backend_validation_tests.{ps1,sh}` — map a matrix `backend` id to the repository's existing backend suite. An unwired id fails loudly rather than reporting a false success. @@ -164,7 +168,7 @@ The Rust workspace (`src/`) implements multiple sandboxing backends behind the ` | Hyperlight | `wxc-exec.exe` | Windows | `backends/hyperlight/common/src/lib.rs` — Hyperlight + Unikraft micro-VM backend | | IsolationSession | `wxc-exec.exe` | Windows | `backends/isolation_session/common/src/` — feature-gated behind `isolation_session`, experimental, uses the in-proc `Windows.AI.IsolationSession.Preview` `IsoSessionOps` API. Supports both one-shot (single-invocation lifecycle, via `ScriptRunner`) and state-aware (multi-invocation provision/start/exec/stop/deprovision, via `StatefulSandboxBackend`) modes. Rejects all filesystem policy (`readwritePaths`/`readonlyPaths`/`deniedPaths`) at every phase with `policy_validation` — the backend has no host-folder-sharing primitive. Likewise rejects any supplied `ui` policy at every phase on both surfaces (as `policy_validation` on the state-aware surface; one-shot discards the typed variant and surfaces `backend_error` with the reason in the message): the isolation session isolates the *host's* UI from contained code but does not deny it UI capabilities (window creation, GDI and the session's own clipboard all work inside it), so no `ui` posture is truthful here — there is no value combination that could be accepted instead, which is why there is no acknowledgment-style gate as there is for `network`. The check is presence-based via `ContainerPolicy::ui_specified` (twin of `network_specified`) because `UiPolicy`'s defaults are full lockdown, making an explicit lockdown `ui` indistinguishable by value from an absent one. An omitted `ui` is accepted and applies no restriction — the schema's default-deny reading does not hold on this backend. One-shot additionally rejects `lifecycle.destroyOnExit=false` and `lifecycle.preservePolicy=true` — the in-proc API has no session-lifetime knob, and the default `destroyOnExit=true` matches actual behavior so it is accepted; the state-aware parser already rejects the whole `lifecycle` section. The full per-phase honor matrix for both surfaces is in `docs/isolation-session/state-aware-rust.md`. The container's network is unrestricted (outbound open; a process inside can listen on a localhost-reachable port) and MXC has no primitive to filter or deny it, so provision (and one-shot) accept ONLY the canonical unrestricted-network acknowledgment — `network.defaultPolicy=allow` + `network.allowLocalNetwork=true`, no host rules, no proxy, default enforcement — and refuse anything else (including an absent policy, which defaults to the unenforceable deny) with `policy_validation`; post-provision phases reject any supplied network policy (fixed at provision, tracked via `ExecutionRequest.network_specified`) and inherit an absent one. State-aware provision accepts an optional `appId` (a packaged app must pass its Package Family Name in the `PFN:` format, e.g. `PFN:Contoso.App_8wekyb3d8bbwe`; an unpackaged app may pass any string), carried verbatim inside the returned `sandboxId`; the one-shot surface takes no backend configuration at all (a stray `experimental.isolation_session` payload is accepted and ignored). Streams stdout/stderr, forwards stdin, and switches to ConPTY mode when wxc-exec's stdout is a TTY for `spawnSandbox` parity. | | WSLc | `wxc-exec.exe` | Windows | `backends/wslc/common/src/` — feature-gated behind `wslc`, experimental, uses the WSLc SDK (`wslcsdk.dll`, loaded at runtime) to run Linux containers in a WSL2 VM. Supports both one-shot (`WSLContainerRunner`, via `ScriptRunner` + streaming `SandboxBackend`) and state-aware (`state_aware.rs` `WslcStateAwareRunner`, via `StatefulSandboxBackend`) modes. Because the WSLc SDK has **no cross-process re-attach**, state-aware keeps the session (VM) + container warm across separate `wxc-exec` phase processes behind a persistent per-user daemon (`wxc-wslc-daemon.exe`, `backends/wslc/daemon/`) that owns the live `WslcSession`/`WslcContainer` handles; phase processes are thin named-pipe clients (`daemon_client.rs`). The daemon runs all SDK calls on one apartment-affine worker thread (so exec is currently serialized across sandboxes — see `docs/wsl/wslc-state-aware.md`). Honors `readwritePaths`/`readonlyPaths` at provision (→ container volumes) + `network.defaultPolicy` (`Block`→`None`, `Allow`→`Bridged`; networking is all-or-nothing — no per-host filtering, since the container lacks `CAP_NET_ADMIN`); rejects `deniedPaths` nested under a mount and rejects proxy/host-filtering at provision. exec honors `network.proxy` **url-form only** (injected as `HTTP_PROXY`/`HTTPS_PROXY`); start/stop/deprovision reject all policy. Rejects, on **both** surfaces and every phase, the fields it cannot honor: any supplied `ui` (presence-based via `ContainerPolicy::ui_specified`, since the backend has no mechanism to enforce UI restrictions on a container — an omitted `ui` is accepted and applies no restriction, so the schema's default-deny reading does not hold here) and `network.enforcementMode` other than `capabilities` (no `CAP_NET_ADMIN` for in-container rules). Provision/one-shot additionally reject `network.allowLocalNetwork=true` (all-or-nothing networking; the one-shot message points at `experimental.wslc.portMappings`, which the state-aware surface does not have), and one-shot rejects `lifecycle.preservePolicy=true` and `lifecycle.destroyOnExit=false` (the container is session-scoped and the session dies with the one-shot process, so `false` cannot be honored; only the default `true` matches actual behavior). Every rejection aborts before any container is created. ID prefix `wslc` (`wslc:<32-hex>`). Idle-timeout is env-overridable via `MXC_WSLC_DAEMON_IDLE_TIMEOUT_SECS`/`MXC_WSLC_DAEMON_IDLE_POLL_SECS`. See `docs/wsl/wslc-state-aware.md`. | -| LXC | `lxc-exec` | Linux | `core/lxc/src/main.rs` + `backends/lxc/common/` | +| LXC | `lxc-exec` | Linux | `core/lxc/src/main.rs` + `backends/lxc/common/` — supports both **one-shot** (single-invocation lifecycle, stable) and **state-aware** (multi-invocation provision/start/exec/stop/deprovision, via `StatefulSandboxBackend`) modes. State-aware is experimental — requires `--experimental`. State-aware lives in `backends/lxc/common/src/state_aware.rs` (`LxcStateAwareRunner`) and needs **no daemon**: the container outlives each phase process in the LXC runtime, so `sandboxId` is just `lxc:` and every phase re-acquires the handle with `LxcContainer::new`. In a firewall mode the host-side veth name is derived from the container name and pinned by a container-global `lxc.hook.start-host` installed **before** start, so the iptables FORWARD chain is scoped before the interface exists; the hook resolves the container's peer interface from `$LXC_PID` and renames it to that name, and exits nonzero — aborting the start — if it cannot find one. The interface set is read from liblxc (`lxc-info -c`) rather than by parsing the container's config, so an `lxc.include` that declares interfaces elsewhere is resolved rather than refused, and no interface *index* is read at all, so a container numbering its interface `lxc.net.3` is enforced exactly as one using `lxc.net.0`. Start is refused when the container declares anything other than exactly one interface, or does not declare that interface's type as `veth` (an undeclared type is refused too, since absence is not evidence of a veth). Teardown derives that same name rather than asking `lxc-info`, which keeps reporting the name liblxc recorded before the hook renamed it, and is ownership-scoped, so a concurrent start's chain is left alone; mount cleanup clears only MXC-marked `lxc.mount.entry` lines. Inbound default-deny (`IngressManager`) is installed on both paths whenever the policy installs a firewall; on the state-aware path it goes on after `container.start()` because the chain lives in the container's network namespace and needs its init PID, and a failure there rolls the start back. Start, stop, and deprovision each take a per-sandbox `flock` in the LXC root, so a teardown cannot strip a start's firewall in the window before the container runs. See `docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md`. | | Seatbelt | `mxc-exec-mac` | macOS | `core/mxc_darwin/src/main.rs` + `backends/seatbelt/common/` — uses macOS App Sandbox (Seatbelt) profiles for process containment. Requires schema `0.7.0-alpha`+. Supports `network.proxy` via the same cooperative env-var model as Bubblewrap (injects `HTTP_PROXY`/`HTTPS_PROXY` into the sandbox, reusing `wxc_common::unix_proxy_coordinator`; `builtinTestServer` spawns the shared `unix-test-proxy`). Also declares the schema-0.8 directional `NetworkPolicySupport` capability flags (`EGRESS_DEFAULT \| INGRESS_DEFAULT \| HOST_LOOPBACK \| RUNTIME_PROXY`, no `EGRESS_RULES`/`PROXY_PEER_IDENTITY`): `network.egress.default`/`network.ingress.default`/`runtimeConfig.networkProxy` map onto the same profile rules as the legacy `defaultPolicy`/`allowLocalNetwork`/`network.proxy` fields (`profile_builder.rs` consults `network_egress`/`network_ingress` when populated, falling back to the legacy fields otherwise — see `docs/sandbox-policy/0.8.0/networking/networking.md`). `network.ingress.hostLoopback` is enforced on the container-to-host direction: `deny` emits `(deny network-outbound (remote ip "localhost:*"))` after the broad outbound rule (Seatbelt takes the *last* matching rule, and SBPL's `localhost` covers the host's non-loopback addresses too, closing the LAN-IP path), while `allow` under a deny egress default emits the matching allow, with the same breadth in reverse — it opens every address bound to this host, not just `127.0.0.1`. Neither case reaches another machine: SBPL's `localhost` means *this machine*, not *this network*, and there is no narrower primitive (a literal `127.0.0.1` is a profile syntax error); the port-scoped proxy allow is always emitted last so it survives. The inbound half is not expressible, so `validate()` still rejects a `hostLoopback` that diverges from `network.ingress.default`. An omitted `hostLoopback` is `deny`, not an inherit of `default` ([#1032](https://github.com/microsoft/mxc/issues/1032)). Every Seatbelt-specific network invariant (proxy vs. `enforcementMode`, remote proxy under deny, proxy combined with an allow default, `allowedHosts` under deny, `blockedHosts`, and the `hostLoopback`/`ingress.default` match) lives in `backends/seatbelt/common/src/seatbelt_policy.rs` and is enforced from the backend's own `validate`, which both the run-to-completion and streaming paths call — `wxc_common::config_parser` holds no Seatbelt rules. See `docs/seatbelt/seatbelt-backend.md`. | | Bubblewrap | `lxc-exec` | Linux | `backends/bubblewrap/common/src/bwrap_runner.rs` — unprivileged sandboxing via Linux user namespaces and `bwrap`. Experimental — requires `--experimental`. Uses shared filesystem/network policy fields; per-host network filtering via `NetworkIptablesManager` from `backends/lxc/common`. For schema 0.8+, proxy mode uses a private network namespace with rootless `slirp4netns` routing and a default-DROP egress chain that permits only loopback and the translated proxy endpoint; `network.enforcementMode: "firewall"` with host lists takes the same private-namespace path and filters by IP/CIDR instead. Both also install a default-deny `MXC_INGRESS` chain on `INPUT` (accepting `-i lo` and `ESTABLISHED,RELATED`), whose posture comes from the directional `network.ingress` section at 0.8+ (`ingress.default`) or from `network.allowLocalNetwork` on the legacy shape; `ingress.default: "allow"` and `ingress.hostLoopback: "allow"` are both refused, as slirp offers no route in. `ingress.hostLoopback` is bidirectional, so its deny also drops egress to slirp's gateway `10.0.2.2` (the host's own loopback), lowered ahead of every caller rule so a broad allow cannot reopen it; proxy mode needs no such rule since it opens only the proxy endpoint. Rules are installed from a supervisor that holds the namespaces, via `nsenter` + `iptables-restore`/`ip6tables-restore` split into byte-budgeted numbered payloads (one restore is one bounded netlink transaction, so a large host list would otherwise exceed it and install nothing); both built-in hooks ride in the final transaction, so a hook is never live over a half-built chain. Both modes require `slirp4netns`, util-linux `unshare`/`nsenter`, `iptables`/`ip6tables`, `iptables-restore`/`ip6tables-restore` on PATH, and fail validation if any is unavailable. The `nf_conntrack` module must also be loaded for the ingress chain's connection-state match, but it is *not* probed at validation time: unprivileged bwrap cannot `modprobe`, and a missing module instead fails the `iptables-restore` transaction at launch, which rolls back and aborts the supervisor before the workload runs (fail-closed, not silently unenforced). `iptables`/`ip6tables` must also resolve to the `nf_tables` backend, unless `/run/xtables.lock` is writable by the calling user: the legacy backend opens that lock unconditionally, and the rules are installed by an unprivileged same-uid supervisor that cannot open a root-owned one — `validate` refuses such a host rather than letting the supervisor die at the first rule. The host proxy endpoint is rewritten to slirp's gateway `10.0.2.2`, so `127.0.0.1`/`0.0.0.0`/`::` are translated while `::1` is rejected (an IPv6-loopback listener cannot accept the gateway's IPv4 connection). Schema 0.6/0.7 and absent-version requests retain the legacy shared-network proxy behavior. See `docs/bwrap-support/bubblewrap-backend.md`. | @@ -257,7 +261,7 @@ The workspace is organized into six top-level directories under `src/`: - `learning_mode_windows` (`backends/learning_mode/windows`) is a Windows-only backend support crate for the AppInfo-brokered Learning Mode APIs in `processmodel.dll`. It runtime-resolves the Learning Mode trace and process security-environment exports, owns their typed handle/lifecycle wrappers, decodes sealed ETL traces through `learning_mode_core`, and process-scopes guarded WPR retention with Windows Trace Relogger using exact job-attested PID/creation/exit `FILETIME` ranges. It depends on `wxc_common` plus `learning_mode_core`; runner integration consumes it from the AppContainer backend layer. The trace contract is `HRESULT Start` + retryable `HRESULT Stop` + infallible `Close`: `Stop` never consumes the trace handle, and every started trace must be closed exactly once (closing without stopping is the early-exit discard path). The process security-environment contract is `HRESULT Create` + infallible by-value `Close` and consumes a PSEC 1.0 FlatBuffer, not the legacy SBOX buffer; generated PSEC bindings live in `core/generated/process_security_environment_specification`. - `plm` (`host/plm`) is the Windows-only legacy WPR Learning Mode helper. Public `plm.exe` is `asInvoker`: ETL analysis and every caller-selected file path stay under the caller token. It self-elevates only hidden fixed WPR operations; the retained elevated guardian accepts authenticated attach and stop/analyze/discard controls over unique local PID-checked named pipes and uses the compiled-in profile from protected fixed-volume ProgramData scratch. WPR's host-wide source ETL never crosses the privilege boundary: after terminal job tracking, the guardian relogs a separate ETL containing only supported Learning Mode events from exact handle-attested process generations, analyzes that filtered ETL, and returns its bytes only when explicitly retained. Relogging failure transfers no trace. Successful authenticated stop/discard disarms the child before releasing the PLM singleton. Owner death, pipe break, or another uncertain control failure preserves the recovery marker and deliberately leaves WPR untouched for administrator recovery. - `wxc`, `lxc`, and `mxc_darwin` are thin binary crates (`wxc-exec` / `lxc-exec` / `mxc-exec-mac`) that wire up CLI args (`clap`), load/validate config, handle maintenance modes (`--probe`, `--delete`, `--setup-*`, `--audit`), and **delegate all backend dispatch to `mxc_engine`**. They contain no `match request.containment` of their own. `wxc-exec` additionally owns the Windows Ctrl-C / DACL-cleanup / telemetry orchestration around the engine call. Its `--audit` compatibility workflow synthesizes allow-mode `captureDenials` with ETL retention, then generates policy-authoring artifacts from the actionable denials document returned by the selected engine backend. -- `mxc_engine` is the **single execution engine** — the one home for "given an `ExecutionRequest`, run it". It owns: run-to-completion backend selection (`run` / `resolve_runner`, covering **all** backends, incl. the Windows ProcessContainer BaseContainer/AppContainer BFS/DACL fallback tiers via `appcontainer_common::dispatcher::dispatch_with_fallback`, and every experimental backend, feature-gated); streaming (`spawn` → `Box`); state-aware lifecycle dispatch (`run_state_aware`, including Windows Sandbox and IsolationSession); host probing (`platform_support` / `PlatformSupport`); and config building (`build_request` / `build_request_with_containment`, `SandboxPolicy` + sections, `available_tools_policy`/`user_profile_policy`/`temporary_files_policy`). It depends on the backend crates (cfg-split: appcontainer/windows_sandbox lifecycle/isolation_session/wslc/nanvix on Windows, bubblewrap/lxc/nanvix on Linux, seatbelt on macOS) so it can't live in `wxc_common`. Both the executor binaries and `mxc-sdk` call into it. `ResolvedRunner` carries the boxed runner plus (Windows only) the optional `DaclManager` guard, so `wxc-exec` can park the guard for its signal handler. +- `mxc_engine` is the **single execution engine** — the one home for "given an `ExecutionRequest`, run it". It owns: run-to-completion backend selection (`run` / `resolve_runner`, covering **all** backends, incl. the Windows ProcessContainer BaseContainer/AppContainer BFS/DACL fallback tiers via `appcontainer_common::dispatcher::dispatch_with_fallback`, and every experimental backend, feature-gated); streaming (`spawn` → `Box`); state-aware lifecycle dispatch (`run_state_aware`, covering Windows Sandbox, IsolationSession, LXC, and WSLc); host probing (`platform_support` / `PlatformSupport`); and config building (`build_request` / `build_request_with_containment`, `SandboxPolicy` + sections, `available_tools_policy`/`user_profile_policy`/`temporary_files_policy`). It depends on the backend crates (cfg-split: appcontainer/windows_sandbox lifecycle/isolation_session/wslc/nanvix on Windows, bubblewrap/lxc/nanvix on Linux, seatbelt on macOS) so it can't live in `wxc_common`. Both the executor binaries and `mxc-sdk` call into it. `ResolvedRunner` carries the boxed runner plus (Windows only) the optional `DaclManager` guard, so `wxc-exec` can park the guard for its signal handler. - `mxc-sdk` is the **public Rust SDK** — a thin facade over `mxc_engine`. Build a `SandboxRequest` with `build_request`, then either `run(request)` (run-to-completion; returns an `Output` with the `WaitOutcome`, captured @@ -331,7 +335,7 @@ When changing behavior covered by existing documentation, update the relevant do ### Policy versioning -The `SandboxPolicy.version` in the SDK must match a JSON schema version in the supported range (`0.6.0-alpha` minimum, `0.9.0-alpha` maximum). The SDK validates this in `sandbox.ts` — if the policy version is older than `MIN_VERSION` or newer than `SUPPORTED_VERSION` it throws. State-aware lifecycle requests use `0.6.0-alpha`. These bounds are mirrored from the canonical `schemas/schema-version.json` and enforced by `scripts/versioning/check-schema-versions.js`. See `docs/versioning.md` for the full design. +The `SandboxPolicy.version` in the SDK must match a JSON schema version in the supported range (`0.6.0-alpha` minimum, `0.9.0-alpha` maximum). The SDK validates this in `sandbox.ts` — if the policy version is older than `MIN_VERSION` or newer than `SUPPORTED_VERSION` it throws. State-aware lifecycle requests default per backend: `0.6.0-alpha` for `isolation_session` and `windows_sandbox`, `0.8.0-alpha` for `lxc` and `wslc`, whose network surfaces need directional enforcement (`DEFAULT_STATE_AWARE_VERSION` in `sdk/node/src/state-aware-helper.ts`). These bounds are mirrored from the canonical `schemas/schema-version.json` and enforced by `scripts/versioning/check-schema-versions.js`. See `docs/versioning.md` for the full design. ## Creating Issues diff --git a/docs/lxc-support/lxc-backend.md b/docs/lxc-support/lxc-backend.md index 8e1c598a4..5c9220d16 100644 --- a/docs/lxc-support/lxc-backend.md +++ b/docs/lxc-support/lxc-backend.md @@ -82,6 +82,56 @@ The `distribution` and `release` fields control which LXC template is used to cr | `debian` | `bookworm`, `trixie` | Stable, well-tested | | `fedora` | `39`, `40` | Modern packages | +### State-aware lifecycle configuration + +The LXC state-aware lifecycle is experimental. Every call to a lifecycle phase must pass `--experimental` to `lxc-exec`, or `{ experimental: true }` when using the TypeScript SDK. Without it every phase is refused with `backend_unavailable` before any container is touched. The one-shot surface is unaffected and does not require this flag. + +The table above describes the **one-shot** surface, where the two fields sit in +a top-level `lxc` section. The state-aware lifecycle carries the same two +fields in the backend's own sub-object instead, under +`experimental.lxc.provision`, alongside the `provision` phase that consumes +them: + +```json +{ + "version": "0.8.0-alpha", + "phase": "provision", + "containment": "lxc", + "experimental": { + "lxc": { + "provision": { + "distribution": "alpine", + "release": "3.23" + } + } + } +} +``` + +The LXC lifecycle requires `version` 0.8.0 or later. A lifecycle request +declaring an older schema, or none at all, is refused with +`malformed_request`. The one-shot surface is unaffected and still accepts 0.7. + +`provision` is the only phase that takes LXC-specific configuration. `start`, +`exec`, `stop`, and `deprovision` carry no `experimental.lxc` payload — they +route by the `sandboxId` returned from `provision`, and their cross-cutting +`filesystem` and `network` policy comes from the top-level sections. + +| Rule | Enforced by | Diagnostic when violated | +|------|-------------|--------------------------| +| Both `distribution` and `release` are required | runtime | `LXC distribution and release are required` | +| `experimental.lxc.provision` must be present on the provision phase | runtime | `experimental.lxc.provision with distribution and release is required` | +| Each field must be a string | schema and parser | ``Invalid configuration at `experimental.lxc.provision.distribution`: invalid type: ... expected a string`` | +| Only one backend section may appear, and it must match `containment` | runtime | `Multiple containment backends configured: ... Only one backend section is allowed; remove the unused section(s)` | + +Both fields are optional in the wire model, exactly as they are in the one-shot +`lxc` section, so the schema accepts a provision section that omits them and the +backend is what refuses it. That split is deliberate: the `experimental` block +is intentionally permissive and the schema "is an editor/CI convenience, never +the gate" (`docs/schema-codegen.md`), so requirements that a caller must satisfy +live in the parser and the backend. See `docs/versioning.md` for the +single-backend-section rule and its graduation path. + ### Process Environment and Working Directory The `process.cwd` and `process.env` fields from the standard schema are honored inside the container: @@ -89,9 +139,9 @@ The `process.cwd` and `process.env` fields from the standard schema are honored | Field | LXC Implementation | Notes | |-------|-------------------|-------| | `process.cwd` | `cd -- "$1" && exec /bin/sh -c "$2"` wrapper prelude, with the cwd passed as a positional argument | Empty string preserves the container default cwd. A nonexistent or non-permitted path surfaces as a generic non-zero exit (typically `1`, from `cd`'s own status); callers needing strong cwd validation should pre-check the path. The positional-arg trick means cwd values with spaces, quotes, `$vars`, or backticks pass through verbatim with no shell escaping. | -| `process.env` | Each `KEY=VAL` entry becomes a repeated `--set-var=KEY=VAL` flag to `lxc-attach` | Malformed entries — those without `=` (e.g. `"BADENTRY"`) or with an empty key (e.g. `"=foo"`) — are silently skipped. Embedded `=` in the value (e.g. `"X=a=b=c"`) is preserved. | +| `process.env` | Each `KEY=VAL` entry becomes a repeated `--set-var=KEY=VAL` flag to `lxc-attach` | Malformed entries — those without `=` (e.g. `"BADENTRY"`) or with an empty key (e.g. `"=foo"`) — fail the run and name the offending entry, rather than being dropped and running the workload in an environment the caller did not describe. Embedded `=` in the value (e.g. `"X=a=b=c"`) is preserved. | -**Replace semantics.** When `process.env` is non-empty, `lxc-exec` also passes `--clear-env` to `lxc-attach` so the host environment does **not** leak into the sandbox, regardless of how many entries survive the malformed-skip. This is the posture `lxc-attach(1)` recommends for sandbox-spawn callers. If a variable is set in both the host and `process.env`, the `process.env` value wins. +**Replace semantics.** When `process.env` is non-empty, `lxc-exec` also passes `--clear-env` to `lxc-attach` so the host environment does **not** leak into the sandbox, and every entry the caller wrote is present, since a malformed one would have failed the run. This is the posture `lxc-attach(1)` recommends for sandbox-spawn callers. If a variable is set in both the host and `process.env`, the `process.env` value wins. When `process.env` is empty (or absent), the legacy keep-env behavior is preserved and the host environment is inherited. @@ -154,13 +204,18 @@ wins" is not true without them: `allowedHosts` and `blockedHosts` entries may be bare IPv4/IPv6 literals, IPv4/IPv6 CIDR blocks, or hostnames. Hostnames are resolved to both A and AAAA records; IPv4 destinations are applied to the `iptables` chain and IPv6 destinations are applied to the `ip6tables` chain. Host-list rules match all ports and protocols. Port- and protocol-specific egress rules are a schema 0.8 feature, described below; the legacy host lists cannot express them. -An entry that resolves to nothing — an unknown hostname, or a CIDR prefix out -of range for its family — cannot be turned into a rule. What that costs -depends on the entry and on `defaultPolicy`: +A destination that could never match anything — a CIDR prefix out of range for +its family, or text that is neither an address nor a hostname — is refused +before any rule is programmed and before any chain is created. It names nothing +that can exist on any host at any moment, so there is no run in which the +caller gets what they wrote. + +A well-formed entry that resolves to no address right now cannot be turned into +a rule either. What that costs depends on the entry and on `defaultPolicy`: | Entry | `defaultPolicy` | Behavior | |-------|-----------------|----------| -| `allowedHosts` | either | Reported as unresolved and skipped. Failing to write an ACCEPT rule can only make the policy more restrictive | +| `allowedHosts` | either | **Fails firewall setup.** The caller named a destination the container is meant to reach; skipping the ACCEPT silently leaves it unreachable, which is not the policy that was written | | `blockedHosts` | `block` | Reported as unresolved and skipped. The closing DROP already denies the destination, so the unwritten rule was redundant | | `blockedHosts` | `allow` | **Fails firewall setup.** The chain ends in ACCEPT, so the unwritten DROP was the only thing that would have denied that destination, and skipping it silently converts a deny into an allow | @@ -207,10 +262,16 @@ Before programming the IPv6 chain, MXC probes `ip6tables` with a read-only `ip6t | Classification | Condition | Behavior | |----------------|-----------|----------| | `Available` | The `ip6tables` probe succeeds | Programs the parallel `ip6tables` chain alongside the IPv4 chain | -| `KernelIpv6Disabled` | The probe fails **and** the host has no active IPv6 | Skips the IPv6 chain and logs that there is no IPv6 egress to filter — safe, because there is nothing to filter | -| `UnusableButIpv6Active` | The probe fails **and** the host has active IPv6 | **Fails firewall setup** rather than applying an IPv4-only policy that would silently leave IPv6 egress unfiltered | +| `KernelIpv6Disabled` | The probe fails **and** the host has no IPv6 stack at all | Skips the IPv6 chain and logs that there is no IPv6 egress to filter — safe, because there is nothing to filter | +| `UnusableButIpv6Active` | The probe fails **and** the host's IPv6 state is active or unknown | **Fails firewall setup** rather than applying an IPv4-only policy that would silently leave IPv6 egress unfiltered | -Host IPv6 activity is read from `/proc/net/if_inet6`: a non-loopback interface with an IPv6 address counts as active, while loopback-only `::1` on `lo` (present even on IPv4-only hosts) does not. If that file cannot be read at all — as opposed to being absent, which means IPv6 is disabled — the state is treated as *unknown* rather than as a confirmed "IPv6 is off", so an unreadable IPv6 state fails closed instead of leaving IPv6 unfiltered. +Host IPv6 activity is read from `/proc/net/if_inet6`. A non-loopback interface +carrying an IPv6 address counts as active. Everything short of that — loopback-only +`::1` on `lo`, an empty file, or a file that cannot be read — is *unknown* rather +than a confirmed "IPv6 is off", because the kernel creates that file only when the +IPv6 stack is loaded, and an address can be configured at any point during the run. +Only an absent file, meaning IPv6 was disabled at boot, is a confirmed negative. +Unknown fails closed. The chains are hooked into `FORWARD` for container egress with **up to two rules per family**, because the input interface `FORWARD` sees depends on how @@ -251,7 +312,7 @@ Egress firewall state is torn down automatically with best-effort removal of the ### Inbound (ingress) policy -Inbound filtering is a separate chain from the egress chains above, and it lives **inside the container's own network namespace** rather than on the host. Every command is issued through `nsenter -t -n`, so the container's init PID is mandatory. When a firewall enforcement mode is requested and MXC cannot discover that PID, the run is aborted rather than started with inbound enforcement silently disabled. This is LXC-specific, and the Bubblewrap comparison is policy-dependent rather than absolute: Bubblewrap gives the sandbox its own network namespace via `--unshare-net` when the default policy is `block` with no `allowedHosts`, no `blockedHosts`, and no proxy, and shares the host's namespace otherwise. It installs no inbound chain in either case — under `--unshare-net` because nothing outside the sandbox can reach in, and when the namespace is shared because an inbound chain there would be host-wide. +Inbound filtering is a separate chain from the egress chains above, and it lives **inside the container's own network namespace** rather than on the host. Every command is issued through `nsenter -t -n`, so the container's init PID is mandatory for every run that installs a firewall. If MXC cannot discover that PID for such a run, the run is aborted rather than started with inbound enforcement silently disabled; a run whose policy installs no firewall needs no PID and is unaffected. This is LXC-specific, and the Bubblewrap comparison is policy-dependent rather than absolute: Bubblewrap gives the sandbox its own network namespace via `--unshare-net` when the default policy is `block` with no `allowedHosts`, no `blockedHosts`, and no proxy, and shares the host's namespace otherwise. It installs no inbound chain in either case — under `--unshare-net` because nothing outside the sandbox can reach in, and when the namespace is shared because an inbound chain there would be host-wide. Every `iptables`/`ip6tables` subprocess is spawned with `LC_ALL=C` and `LANG=C`. Teardown decides whether a non-zero exit means "already absent" by matching iptables' own diagnostic text, and that text is localized, so an unpinned locale would turn a benign already-absent result on a non-English host into a fatal error and abort every fresh install. @@ -265,13 +326,13 @@ requests a firewall enforcement mode. Under 0.8.0 it is always installed. The chain uses an `MXCI-` prefix so it can never collide with, or be torn down for, the `MXC-` egress chain of the same container. Loopback, established and related traffic, and — for IPv6 — the ICMPv6 types required for Neighbor Discovery and Path MTU Discovery are permitted ahead of the terminal drop, so a default-deny container can still complete connections it initiated itself. -IPv6 is classified with the same three-way probe as egress, but against the *container* namespace rather than the host, and from a different signal. A host that reports IPv6 disabled says nothing about the namespace actually being filtered. `UnusableButIpv6Active` inside that namespace fails the run closed rather than enforcing an IPv4-only inbound policy that would leave inbound IPv6 open. +IPv6 is classified with the same three-way probe as egress, but against the *container* namespace rather than the host. A host that reports IPv6 disabled says nothing about the namespace actually being filtered. `UnusableButIpv6Active` inside that namespace fails the run closed rather than enforcing an IPv4-only inbound policy that would leave inbound IPv6 open. -The signal differs because a container is not a long-running host. Egress reads the *contents* of `/proc/net/if_inet6` and treats loopback-only as inactive, which is a fair reading for a host that has been up for a while. A container has not been: `wait_for_network` returns on the first address of *any* family, so a container whose IPv6 address has not arrived yet presents exactly the same address-less file as one with IPv6 switched off. Inbound therefore keys on that file's *existence*, which is stable — the kernel never creates `/proc//net/if_inet6` when IPv6 is disabled at boot. A present but address-less file counts as active, so an unusable `ip6tables` fails the run closed instead of installing IPv4-only enforcement that an IPv6 address arriving moments later would slip past. The trade is deliberate and one-directional: this can abort a run that the egress rule would have let proceed, never the reverse. +Both directions key on that file's *existence* rather than on finding an address written in it. The kernel creates `if_inet6` only when the IPv6 stack is loaded, so its presence is stable while its contents are not. `wait_for_network` returns on the first address of *any* family, so a container whose IPv6 address has not arrived yet presents exactly the same address-less file as one with IPv6 switched off; a host mid-configuration presents the same file for the same reason. Reading the contents would classify both as "no IPv6" and install IPv4-only enforcement that an address arriving moments later would slip past. Keying on existence can abort a run that would have completed harmlessly, and that is the direction this backend accepts. Inbound rules are installed after the container starts and after egress setup completes, so inbound is unfiltered for a short interval at container startup. The workload script is executed only after installation finishes, so no sandboxed code runs during that interval and the exposure is to external traffic only. Narrowing this interval is tracked separately. -Inbound default-deny is not a containment boundary against the sandboxed workload. Because the chain lives in the container's own network namespace, the workload can reach it: MXC creates containers from the stock `lxc-create -t download` template and never sets `lxc.cap.drop` or `lxc.cap.keep`, so LXC's defaults apply — the shared default drops only `mac_admin`, `mac_override`, `sys_time`, `sys_module`, and `sys_rawio`, and an unprivileged user-namespace container starts with a full capability set. `lxc-attach` is invoked without `-u` or `-g`, so the workload runs as container root and holds `CAP_NET_ADMIN` in the namespace the chain lives in, where it can flush or delete it. The egress chains are not exposed this way: they sit on the host and hook into `FORWARD` by the container's host-side veth, out of the workload's reach. The asymmetry follows from where each chain is installed. Inbound default-deny therefore closes off external reachability — including for services the workload itself starts — for any workload that does not deliberately tear it down, and does not survive one that does. Making inbound enforcement tamper-proof is tracked in issue #854. +Inbound default-deny is not a containment boundary against the sandboxed workload. Because the chain lives in the container's own network namespace, the workload can reach it: MXC creates containers from the stock `lxc-create -t download` template and never sets `lxc.cap.drop` or `lxc.cap.keep`, so LXC's defaults apply — the shared default drops only `mac_admin`, `mac_override`, `sys_time`, `sys_module`, and `sys_rawio`, and an unprivileged user-namespace container starts with a full capability set. `lxc-attach` is invoked without `-u` or `-g`, so the workload runs as container root and holds `CAP_NET_ADMIN` in the namespace the chain lives in, where it can flush or delete it. The egress chains are not exposed this way: they sit on the host and hook into `FORWARD` by the container's host-side veth, out of the workload's reach. The asymmetry follows from where each chain is installed. Inbound default-deny therefore closes off external reachability — including for services the workload itself starts — for any workload that does not deliberately tear it down, and does not survive one that does. Making inbound enforcement tamper-proof is tracked in issue #897. Unlike egress, the inbound chain honors the lifecycle's `preservePolicy`: when it is set *and* installation succeeded, the chain is deliberately left in place after the run for inspection. A partially installed chain from a failed run is always torn down regardless of the setting. @@ -305,7 +366,11 @@ silent corrections: would be injected while direct egress stayed open — a config that reads as deny-all-except-proxy and enforces neither half. MXC refuses it rather than auto-promoting the mode, so a stated enforcement level is never silently - rewritten. + rewritten. The refusal is not specific to the proxy: any configuration that + states a network posture under `capabilities` is refused, because this backend + filters with iptables and has no capability mechanism for the network. A + configuration that states no posture is not asking for enforcement and runs + unchanged. - **The `url` must not carry credentials.** LXC passes the proxy URL to `lxc-attach` as a `--set-var` argument, and process arguments are world-readable through `/proc//cmdline`, so inline `user:pass@` would be diff --git a/docs/lxc-support/state-aware-plan.md b/docs/lxc-support/state-aware-plan.md new file mode 100644 index 000000000..817a86ddb --- /dev/null +++ b/docs/lxc-support/state-aware-plan.md @@ -0,0 +1,266 @@ +# MXC LXC Backend — State-Aware + +The per-backend document required by §11.6 of the state-aware sandbox API +specification. It declares what the LXC backend does at each lifecycle phase: +the config and metadata shapes, the cross-cutting policy honor matrix, +mode-specific fields, idempotence behavior, the concurrency story, and the +error mapping. + +`docs/lxc-support/lxc-backend.md` describes the shipped implementation. This +document declares the contract that implementation meets. + +## Scope + +### In scope + +The five state-aware phases — provision, start, exec, stop, and deprovision — +as reached through `lxc-exec` with a state-aware request. + +### Out of scope + +One-shot execution, covered by `docs/lxc-support/lxc-backend.md`. The +cross-backend policy semantics, covered by `docs/sandbox-policy/0.8.0/`. + +## Schema version floor + +State-aware requires a `schema_version` of at least `0.8.0`. A request below +that floor is refused before any phase runs, and the sandbox is never created. +One-shot execution has no such floor and continues to accept `0.7.0`. + +The floor exists because LXC's state-aware support missed the 0.7.0 cutoff. + +## Experimental opt-in + +The LXC state-aware lifecycle is experimental. Every call to a lifecycle phase must pass `--experimental` to `lxc-exec`. Without it every phase is refused with `backend_unavailable` before any container is touched. One-shot execution has no such requirement. + +## Per-phase config and metadata shapes + +### Provision + +**Config — `LxcConfig`.** Both fields are required. + +| Field | Type | Meaning | +|---|---|---| +| `distribution` | string | Linux distribution for the container rootfs, for example `alpine` or `ubuntu`. | +| `release` | string | Distribution release version, for example `3.20` or `24.04`. | + +**Metadata — `LxcProvisionMetadata`.** + +| Field | Type | Meaning | +|---|---|---| +| `containerName` | string | The LXC container name backing this sandbox. | +| `created` | bool | True when this call created the container, false when it adopted an existing one. | + +#### The `sandboxId` format + +Provision returns `lxc:`. Every later phase parses that form +and rejects anything else as `malformed_id` — a missing or foreign prefix, an +empty name, or a name longer than the LXC limit. + +### Start + +**Config — none.** Start is the only phase that accepts cross-cutting policy; +see the honor matrix below. + +**Metadata — none.** + +### Exec + +**Config — the process fields of the request**, matching one-shot execution. + +**Metadata — none.** LXC relays the sandbox's output to this process's own +stdout and stderr rather than handing back pipes. The returned handle carries +null stream handles and a waiter holding the exit code. + +An in-process caller that needs the streams back is refused up front, before +the workload runs, because a later refusal would describe output that has +already gone somewhere the caller never asked for. + +### Stop + +**Config — none. Metadata — none.** + +### Deprovision + +**Config — none. Metadata — none.** + +## Cross-cutting policy honor matrix + +Per §10.3. LXC applies filesystem and network policy at start and refuses it +everywhere else. + +| Field | provision | start | exec | stop | deprovision | +|---|---|---|---|---|---| +| `filesystem` | `policy_validation` | applied | `policy_validation` | `policy_validation` | `policy_validation` | +| `network` | `policy_validation` | applied | `policy_validation` | `policy_validation` | `policy_validation` | +| `ui` | ignored | ignored | ignored | ignored | ignored | + +The two fields differ on an empty block. A `network` block that is present +but sets nothing is refused — the check reads the "was a network section +supplied" bit, not the fields inside it. A `filesystem` block that is present +but lists no paths is **not** refused; the check reads only whether a path +list is non-empty, and an empty block is indistinguishable from an absent one. + +A request that carries no policy at all passes every phase. + +The rejection is deliberate rather than incidental. A container's filesystem +mounts and firewall rules are installed when it starts, and a caller that +passed them to stop or exec would receive a silent no-op instead of the +isolation they asked for. + +## Mode-specific fields + +### `containerId`, state-aware provision only + +LXC accepts an optional `containerId` at provision, naming the container to +adopt instead of minting a fresh name. This is the exception to the rule in +`mxc-state-aware-sandbox-api-overview.md` that neither request shape carries +a container identifier, and it is called out in the specification at §6.1. + +When `containerId` names an existing container, provision adopts it and +returns `created: false`. When it names one that does not exist, provision +creates it under that name and returns `created: true`. + +## Idempotence per phase + +| Phase | Repeated call | Notes | +|---|---|---| +| provision | non-idempotent without `containerId` | Each call mints a fresh container name. With `containerId`, a repeated call adopts the container the first one created and reports `created: false`. | +| start | `already_started` | A second start against a running container is refused. | +| exec | per-call | Each exec runs the command again. There is no deduplication. | +| stop | `already_stopped` | A stop against a container that is not running is refused, whether it was never started or has already been stopped. | +| deprovision | idempotent success | A second deprovision against a destroyed sandbox exits 0. | + +### Why stop reports one code for two histories + +LXC answers "is this container running" with a single probe, and that probe +cannot distinguish a container that was never started from one that ran and +has since stopped. Reporting one code for one observable state keeps the +answer honest. `not_started` is reserved for exec, the only other phase that +needs a running container. Each code is emitted by exactly one phase. + +### Why deprovision is the one idempotent phase + +Deprovision runs the authoritative network teardown whether or not the +container still exists. A caller that retries after a failure partway through +teardown has to be able to reach that cleanup a second time. Every other +phase reports `stale_id` against a destroyed sandbox, provided the request +carries nothing that fails earlier — see Precedence. + +### Stopping a container also tears down its network + +A container that exits on its own leaves the current start's iptables rules on +the host. Stop removes them on both paths — before returning success for a +container it stopped, and before returning `already_stopped` for one that was +already down. A refusal never leaves host state behind. + +## Concurrency + +### Multiple sandboxes + +Distinct `sandboxId`s name distinct LXC containers, and their lifecycle locks +are independent. Their firewall chains are not guaranteed independent; see +Known issues. + +### The same sandbox + +Provision, start, stop, and deprovision take a lifecycle lock keyed on the +container name. Concurrent calls against one sandbox serialize rather than +interleave, and the second caller observes the state the first one left. + +### Concurrent exec calls + +Exec does not take the lifecycle lock. Two execs against one sandbox run +concurrently inside the same container, and MXC does not order them. A caller +that needs them ordered has to sequence them itself. + +## Error mapping + +LXC emits nine of the twelve codes in the specification's closed union. + +| Observable condition | Wire `error.code` | Trigger | +|---|---|---| +| Caller did not pass `--experimental` | `backend_unavailable` | The state-aware lifecycle is experimental. Reported by all five phases before any container is touched. | +| Provision config missing or incomplete | `malformed_request` | No `experimental.lxc.provision` block, or an empty `distribution` or `release`. | +| The `sandboxId` does not parse | `malformed_id` | Missing or foreign prefix, empty name, a name over 20 characters, or a character outside ASCII alphanumerics, `-`, and `_`. Detected before any container is touched. | +| The id parses, the container does not exist | `stale_id` | The sandbox was deprovisioned, or the container was destroyed out of band. Reported by start, exec, and stop. | +| The container exists but is not running, at exec | `not_started` | Exec needs a running container. | +| The container is already running, at start | `already_started` | | +| The container is not running, at stop | `already_stopped` | See the idempotence table. | +| A phase received policy it does not accept | `policy_validation` | See the honor matrix. Also covers a network policy LXC cannot enforce on this host. | +| A container state probe could not be read | `backend_error` | LXC could not answer whether the container exists or is running. An unreadable probe is never treated as "gone" or "stopped". Also returned when an in-process caller asks exec for the sandbox's streams, which LXC cannot hand back. | +| The `lxc-*` tools are absent from the host | `backend_unavailable` | LXC drives every phase by spawning those tools. Reported by all five phases before any container is touched, which separates a machine without LXC installed from a container operation that failed. | + +### Precedence + +Every phase runs in two stages, and the first stage never touches the +container. Validation checks the provision config shape, parses the +`sandboxId`, and rejects policy the phase does not accept. Only then does the +phase itself acquire the lifecycle lock and probe the container. + +What follows from that split is the part callers can rely on: + +- `malformed_request`, `malformed_id`, and a *phase-acceptance* + `policy_validation` are reported before any state code. An exec, stop, or + deprovision against a destroyed sandbox that also carries rejected policy + reports `policy_validation`, not `stale_id`. +- Within the second stage there is no single ordering. The lifecycle lock is + taken before the existence probe, and failing to take it reports + `backend_error`. Start applies filesystem policy after its existence and + running probes, and a failure there also reports `policy_validation`. + +`policy_validation` and `backend_error` each have two arrival points, one in +each stage. Neither code identifies which stage produced it; the message +does. + +### The remaining three codes + +None of these is emitted by LXC backend code. Two of the three still reach a +caller of `lxc-exec`, because shared routing decides them before any backend is +chosen. + +**`not_provisioned` is unreachable.** The specification reserves it for a +sandbox whose id is recognized but whose resources have not been created. LXC +has no such state — `lxc-create` runs during provision, and the container is +defined from the moment an id exists. Every condition that would reach +`not_provisioned` on another backend reaches `stale_id` here. + +**`unsupported_containment` answers a sandbox id naming no known backend.** +Shared routing returns it for a prefix with no entry in the lookup table, which +is the outcome the specification assigns to a direct wire call. An id carrying +the `lxc` prefix routes to this backend instead. + +**`unsupported_phase` comes from shared dispatch**, above the backend, never +from LXC code. A caller reaches it by naming a backend that has no state-aware +lifecycle on this host. + +## Known issues + +### Firewall chain names are not guaranteed unique + +A container's iptables chain name is derived from its container name through a +non-cryptographic hash. That derivation is not injective, and a caller that +chooses `containerId` can construct two names that land on one chain. When +that happens, one sandbox's stop or deprovision tears down the other's rules, +leaving a running container with no firewall. + +The name length limit and character set narrow the input set; they do not make +the derivation injective. The durable fix is persisted chain ownership +verified before any flush or delete. Tracked in AB#62953349. + +## References + +- `docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md` — the + specification this document declares against. +- `docs/lxc-support/lxc-backend.md` — the shipped LXC implementation. +- `docs/sandbox-policy/0.8.0/` — the cross-backend policy contract. +- `tests/scripts/run_lxc_state_aware_phase_ordering_test.sh` — the phase + ordering and error codes above. +- `tests/scripts/run_lxc_state_aware_network_test.sh` — the honor matrix. +- `tests/scripts/run_lxc_state_aware_version_floor_test.sh` — the schema + version floor. +- `tests/scripts/run_lxc_state_aware_routing_test.sh` — the refusals decided + from the sandbox id alone, the missing runtime dependency, and the envelope + discipline stdout has to keep. +- `tests/scripts/run_lxc_state_aware_adopt_test.sh` — the identifier form and + adopt-or-create. diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md index 8e4a9ec31..44e8075e3 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md @@ -236,7 +236,7 @@ wire format and have different roles: | Field | Where it appears | Source | Purpose | |---|---|---|---| | `sandboxId` | State-aware wire envelope (§7); SDK return value from `provisionSandbox` | System-generated by the backend | Opaque routing identifier; must be passed to subsequent state-aware calls | -| `containerId` | One-shot wire envelope (per `docs/schema.md`) | Caller-supplied (or auto-generated random hex) | Human-readable label, used as e.g. AppContainer profile name | +| `containerId` | One-shot wire envelope (per `docs/schema.md`); **LXC** state-aware `provision` | Caller-supplied (or auto-generated random hex) | Human-readable label, used as e.g. AppContainer profile name or LXC container name | State-aware non-provision calls carry `sandboxId` on the request; provision returns it on the response. A state-aware request **may** also carry `containerId` — the parser @@ -244,6 +244,19 @@ preserves it into the request the backend receives — but it is inert for backe do not use it as a label, and it is never a routing key on the state-aware path. One-shot calls carry `containerId` (when present); they do not carry `sandboxId`. +**LXC exception.** LXC is the one backend whose state-aware `provision` also accepts +`containerId`, because its durable state *is* the named container and `lxc-ls` / +`lxc-attach` address it by name. Consequences callers must understand: + +- The returned `sandboxId` is `lxc:`, so the name is not opaque. +- Provision is **adopt-or-create**: if a container with that name already exists it is + reused rather than created, and `provisionMetadata.created` reports which happened. +- Deprovision **destroys the container unconditionally**, including one it adopted + rather than created. MXC retains no caller-side state between phases, so deprovision + cannot distinguish an adopted container from a created one. Callers that pass a + `containerId` for a pre-existing container therefore hand MXC ownership of it. +- Omit `containerId` to get a generated name and avoid adopting anything. + ## 6. TypeScript SDK The SDK adds five new functions, exported from `@microsoft/mxc-sdk` alongside the existing @@ -1710,6 +1723,47 @@ unconditionally by the in-guest agent). | `network` | rejected | rejected | rejected | rejected | rejected | | `ui` | rejected | rejected | rejected | rejected | rejected | +For LXC, filesystem path lists and `network` are applied at **start** — the container is +created empty at provision and its mounts and iptables chain are installed just before +it runs — and rejected at every other phase. Only a *non-empty* path list counts as a +filesystem policy, so a `filesystem` block whose lists are all empty is accepted at any +phase. `ui` is not consulted anywhere in the LXC backend; `LxcStartConfig` does not +expose it, so SDK callers cannot pass it, but a raw-JSON caller that sends it is not +rejected. + +| Field | provision | start | exec | stop | deprovision | +|---|---|---|---|---|---| +| `filesystem` (non-empty path lists) | rejected | applied | rejected | rejected | rejected | +| `network` | rejected | applied | rejected | rejected | rejected | +| `ui` | ignored | ignored | ignored | ignored | ignored | + +> **LXC `network` constraints.** `network.proxy` and `allowLocalNetwork` are +> rejected at start. LXC enforces network policy through iptables whenever +> `defaultPolicy` is `"block"`, either host list is non-empty, or proxy is +> enabled. `enforcementMode` must name `"firewall"` or `"both"`: a +> configuration that states a network posture under `"capabilities"` is refused +> rather than run unenforced, because this backend filters with iptables and has +> no capability mechanism for the network. A configuration that states no +> posture is not asking for enforcement and runs unchanged. The veth is not +> discovered after start: the name is derived from the container name and +> pinned by a container-global +> `lxc.hook.start-host` installed before start, so the chain is scoped before the +> interface exists. The hook resolves the container's peer interface from +> `$LXC_PID` and renames it to that name; if it cannot find one it exits +> nonzero, which aborts the start rather than leaving the container unfiltered. +> The interface set comes from liblxc rather than from the container's own +> config file, so an `lxc.include` that declares interfaces elsewhere is +> resolved rather than guessed at, and no interface index is read at all — a +> container numbering its only interface `lxc.net.3` is enforced exactly as one +> using `lxc.net.0`, as is one at any index liblxc accepts. Start fails instead +> when that pin cannot be trusted to cover the container's traffic — when the +> container declares anything other than exactly one interface, or does not +> declare that interface's type as `veth`. An undeclared type is refused +> alongside a wrong one, because absence is not evidence of a veth, and a +> `macvlan` or `phys` interface would take a hook pinned to a veth name that +> never appears while its traffic ran unfiltered. `removeRulesOnExit` is not +> part of the LXC surface (see `LxcNetworkConfig`). + - **Compile-time enforcement at the SDK.** Each per-(backend, phase) Config (§6.1) declares only the cross-cutting fields the matrix marks as `applied` for that phase *and* that the runtime currently honors. TypeScript rejects callers passing fields diff --git a/schemas/dev/mxc-config.schema.0.9.0-dev.json b/schemas/dev/mxc-config.schema.0.9.0-dev.json index abd0a78d6..79a773ed9 100644 --- a/schemas/dev/mxc-config.schema.0.9.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.9.0-dev.json @@ -201,6 +201,17 @@ ], "description": "IsolationSession backend config (Windows)." }, + "lxc": { + "anyOf": [ + { + "$ref": "#/definitions/LxcExperimental" + }, + { + "type": "null" + } + ], + "description": "LXC backend config (Linux)." + }, "seatbelt": { "anyOf": [ { @@ -390,6 +401,43 @@ }, "type": "object" }, + "LxcExperimental": { + "description": "State-aware LXC configuration. Only provision takes a config payload.", + "properties": { + "provision": { + "anyOf": [ + { + "$ref": "#/definitions/LxcProvisionPhase" + }, + { + "type": "null" + } + ], + "description": "State-aware provision-phase configuration." + } + }, + "type": "object" + }, + "LxcProvisionPhase": { + "description": "Provision-phase LXC configuration, separate from the stable top-level `lxc` section because this surface is experimental. Filesystem mounts and network policy come from the top-level `filesystem` and `network` sections.", + "properties": { + "distribution": { + "description": "Distribution image (e.g. `alpine`).", + "type": [ + "string", + "null" + ] + }, + "release": { + "description": "Distribution release (e.g. `3.23`).", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "Network": { "additionalProperties": false, "description": "Network access policy.", diff --git a/scripts/check-dotnet-api-parity.js b/scripts/check-dotnet-api-parity.js index ec7d8d096..d33e6b861 100644 --- a/scripts/check-dotnet-api-parity.js +++ b/scripts/check-dotnet-api-parity.js @@ -449,35 +449,6 @@ if (!tierMatch) { compare("isolation-tier wire names", managedTiers, rustTiers); } -const rustStateAware = read( - "src", - "core", - "mxc_engine", - "src", - "state_aware.rs" -).split("#[cfg(test)]")[0]; -const stateAwareMatch = /matches!\(\s*backend,\s*([\s\S]*?)\)\s*&&/.exec( - rustStateAware -); -if (!stateAwareMatch) { - errors.push("state_aware.rs: could not find experimental backend registry"); -} else { - const rustBackends = [ - ...stateAwareMatch[1].matchAll(/ContainmentBackend::(\w+)/g), - ].map((match) => match[1]); - const managedStateAware = read( - "sdk", - "dotnet", - "Microsoft.Mxc.Sdk", - "StateAwareTypes.cs" - ); - compare( - "state-aware containment enum", - enumVariants(managedStateAware, "StateAwareContainment", "csharp"), - rustBackends - ); -} - const rustDispatch = read( "src", "core", @@ -501,6 +472,23 @@ const managedPrefixes = [ ].map((match) => `${match[1]}:${match[2]}`); compare("state-aware sandbox-id prefixes", managedPrefixes, rustPrefixes); +const managedStateAware = read( + "sdk", + "dotnet", + "Microsoft.Mxc.Sdk", + "StateAwareTypes.cs" +); +const managedContainments = enumVariants( + managedStateAware, + "StateAwareContainment", + "csharp" +); +compare( + "state-aware containment enum", + managedContainments, + rustPrefixes.map((prefix) => prefix.split(":")[1]) +); + if (errors.length > 0) { console.error("C# API parity FAILED:"); for (const error of errors) console.error(` - ${error}`); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs index cd01655db..f9f9cc69c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs @@ -29,6 +29,9 @@ static MxcLifecycle() /// Default state-aware schema for WSLC. public const string WslcStateAwareVersion = SchemaVersions.WslcStateAware; + /// Default state-aware schema for LXC. + public const string LxcStateAwareVersion = SchemaVersions.LxcStateAware; + /// IsolationSession containment wire key. public const string IsolationSessionContainment = "isolation_session"; @@ -38,6 +41,9 @@ static MxcLifecycle() /// WSLC containment wire key. public const string WslcContainment = "wslc"; + /// LXC containment wire key. + public const string LxcContainment = "lxc"; + private const int ExperimentalOptIn = 1; private static readonly JsonSerializerOptions JsonOptions = new() @@ -475,15 +481,18 @@ private static void ApplyTelemetry( StateAwareContainment.IsolationSession => IsolationSessionContainment, StateAwareContainment.WindowsSandbox => WindowsSandboxContainment, StateAwareContainment.Wslc => WslcContainment, + StateAwareContainment.Lxc => LxcContainment, _ => throw new MxcException( ErrorCode.UnsupportedContainment, $"unknown state-aware containment '{containment}'"), }; - private static string DefaultVersion(StateAwareContainment containment) => - containment == StateAwareContainment.Wslc - ? WslcStateAwareVersion - : StateAwareVersion; + private static string DefaultVersion(StateAwareContainment containment) => containment switch + { + StateAwareContainment.Wslc => WslcStateAwareVersion, + StateAwareContainment.Lxc => LxcStateAwareVersion, + _ => StateAwareVersion, + }; private static void ValidateProvisionOptions( StateAwareContainment containment, @@ -548,6 +557,7 @@ private static StateAwareContainment ContainmentForId(SandboxId id) "iso" => StateAwareContainment.IsolationSession, "wsb" => StateAwareContainment.WindowsSandbox, "wslc" => StateAwareContainment.Wslc, + "lxc" => StateAwareContainment.Lxc, _ => throw new MxcException( ErrorCode.UnsupportedContainment, $"no state-aware backend is registered for sandbox id '{id.Value}'"), diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxId.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxId.cs index 36cde4585..8a4f46b83 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxId.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxId.cs @@ -6,8 +6,8 @@ namespace Microsoft.Mxc.Sdk; /// /// A state-aware sandbox identifier minted by /// . Carries the backend prefix (e.g. -/// iso:, wsb:, or wslc:) the later phases resolve the -/// backend from. Treat it as opaque. +/// iso:, wsb:, wslc:, or lxc:) the later phases resolve +/// the backend from. Treat it as opaque. /// public readonly struct SandboxId : IEquatable { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SchemaVersions.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SchemaVersions.cs index d5cad536c..386a2ebb6 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SchemaVersions.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SchemaVersions.cs @@ -25,4 +25,7 @@ public static class SchemaVersions /// Default state-aware version for WSLC. public const string WslcStateAware = "0.8.0-alpha"; + + /// Default state-aware version for LXC. + public const string LxcStateAware = "0.8.0-alpha"; } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs index 3a09d1bdd..03c5c1db5 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs @@ -19,6 +19,9 @@ public enum StateAwareContainment /// Windows Subsystem for Linux container. Wslc, + + /// Linux container managed by LXC. + Lxc, } /// The default action for traffic with no matching rule. diff --git a/sdk/node/README.md b/sdk/node/README.md index ddce52994..4f5e62eed 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -299,7 +299,7 @@ capability names are reserved and must not be added directly to For long-lived sandboxes where you provision once, exec many times, and tear down at the end (e.g. agentic loops), use the state-aware lifecycle. -> **Backend support:** the state-aware lifecycle is currently implemented for `isolation_session`, `windows_sandbox`, and `wslc` (all Windows-only; all still experimental, so every call must pass `{ experimental: true }`). The one-shot spawn APIs (`spawnSandbox` / `spawnSandboxFromConfig`) are the supported path for every other backend. +> **Backend support:** the state-aware lifecycle is currently implemented for `isolation_session`, `windows_sandbox`, and `wslc` (Windows-only) and `lxc` (Linux-only). All four are experimental; every call must pass `{ experimental: true }`. The one-shot spawn APIs (`spawnSandbox` / `spawnSandboxFromConfig`) are the supported path for every other backend. ```typescript import { @@ -463,10 +463,10 @@ spawnSandboxFromConfig(config, options?, workingDirectory?, env?) → IPty | Chi spawnSandbox(script, policy, options?, workingDirectory?, containerName?, env?) → IPty spawnSandboxAsync(script, policy, ...) → Promise<{ stdout, stderr, exitCode }> -// State-aware lifecycle (currently `isolation_session`, `windows_sandbox`, and `wslc` — all Windows-only) +// State-aware lifecycle (`isolation_session`, `windows_sandbox`, and `wslc` — Windows-only; `lxc` — Linux-only; all experimental) // `config` on provisionSandbox is required for backends whose provision config -// has a required member (isolation_session: the network acknowledgment) and -// optional otherwise (windows_sandbox, wslc). +// has a required member (isolation_session: the network acknowledgment; lxc: +// distribution and release) and optional otherwise (windows_sandbox, wslc). provisionSandbox(containment, config, options?) → Promise startSandbox(sandboxId, config?, options?) → Promise execInSandbox(sandboxId, config, options?) → IPty // streaming diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 9d7c221cd..b03d222b0 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -79,6 +79,10 @@ export interface Experimental { * IsolationSession backend config (Windows). */ isolation_session?: IsolationSession | null; + /** + * LXC backend config (Linux). + */ + lxc?: LxcExperimental | null; /** * Seatbelt backend config (pre-promotion alias). */ @@ -185,6 +189,32 @@ export interface Lxc { release?: string | null; } +/** + * State-aware LXC configuration. Only provision takes a config payload. + */ +export interface LxcExperimental { + /** + * State-aware provision-phase configuration. + */ + provision?: LxcProvisionPhase | null; + [k: string]: unknown; +} + +/** + * Provision-phase LXC configuration, separate from the stable top-level `lxc` section because this surface is experimental. Filesystem mounts and network policy come from the top-level `filesystem` and `network` sections. + */ +export interface LxcProvisionPhase { + /** + * Distribution image (e.g. `alpine`). + */ + distribution?: string | null; + /** + * Distribution release (e.g. `3.23`). + */ + release?: string | null; + [k: string]: unknown; +} + /** * Network access policy. */ diff --git a/sdk/node/src/index.ts b/sdk/node/src/index.ts index a4bd9cb9f..e61cb9c18 100644 --- a/sdk/node/src/index.ts +++ b/sdk/node/src/index.ts @@ -109,6 +109,13 @@ export { IsolationSessionStopConfig, IsolationSessionDeprovisionConfig, IsolationSessionProvisionMetadata, + LxcProvisionConfig, + LxcNetworkConfig, + LxcStartConfig, + LxcExecConfig, + LxcStopConfig, + LxcDeprovisionConfig, + LxcProvisionMetadata, WindowsSandboxProvisionConfig, WindowsSandboxStartConfig, WindowsSandboxExecConfig, diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts index 007242ea3..ce0d51f56 100644 --- a/sdk/node/src/sandbox.ts +++ b/sdk/node/src/sandbox.ts @@ -153,15 +153,38 @@ function buildBubblewrapConfig( */ function buildLinuxProcessConfig( config: ContainerConfig, + policy: SandboxPolicy, ): ContainerConfig { config.lxc = { distribution: 'alpine', release: '3.23', }; applyLinuxNetworkPolicy(config); + if (!usesDirectionalNetwork(policy)) { + applyLxcEnforcementMode(config); + } return config; } +/** + * Names the enforcement mode LXC acts on, for a legacy network block that does + * not already name one. + * + * LXC enforces network policy only through the host firewall, and refuses a + * posture it cannot enforce rather than reporting success for enforcement that + * did not happen. The legacy mapping supplies `defaultPolicy: 'block'` for a + * caller who requested no network at all, so leaving the mode unset would have + * the backend refuse a request whose caller stated no posture. A directional + * block carries no enforcement mode by design, and a mode the caller stated is + * left alone. + */ +function applyLxcEnforcementMode(config: ContainerConfig): void { + if (!config.network || config.network.enforcementMode !== undefined) { + return; + } + config.network.enforcementMode = 'firewall'; +} + /** * Builds the macOS process container (seatbelt) portion of a ContainerConfig. * @@ -407,7 +430,7 @@ export function createConfigFromPolicy( if (containment === 'lxc') { diagLog(`createConfigFromPolicy: containment=lxc, id=${containerId}`); config.containment = 'lxc'; - return buildLinuxProcessConfig(config); + return buildLinuxProcessConfig(config, policy); } if (containment === 'process') { diff --git a/sdk/node/src/state-aware-helper.ts b/sdk/node/src/state-aware-helper.ts index e39f56fdf..37409bcc6 100644 --- a/sdk/node/src/state-aware-helper.ts +++ b/sdk/node/src/state-aware-helper.ts @@ -19,16 +19,27 @@ export const STATE_AWARE_VERSION = '0.6.0-alpha'; export const WSLC_STATE_AWARE_VERSION = '0.8.0-alpha'; export const TELEMETRY_STATE_AWARE_VERSION = '0.9.0-alpha'; +// LXC refuses any state-aware request below 0.8. +export const LXC_STATE_AWARE_VERSION = '0.8.0-alpha'; + // Wire-format cross-cutting fields that live at the envelope's top level. // Anything else on a per-(backend, phase) Config is backend-specific and is // nested under `experimental..`. -export const CROSS_CUTTING_FIELDS = ['filesystem', 'network', 'ui', 'process', 'telemetry'] as const; +export const CROSS_CUTTING_FIELDS = [ + 'containerId', + 'filesystem', + 'network', + 'ui', + 'process', + 'telemetry', +] as const; // Per-backend wire-format prefix. Each value mirrors the corresponding // Rust `Runner::ID_PREFIX` const and is the leading segment of a // `sandboxId` produced by that backend. Each future state-aware backend // declares its own `_ID_PREFIX` const here. export const ISOLATION_SESSION_ID_PREFIX = 'iso'; +export const LXC_ID_PREFIX = 'lxc'; export const WINDOWS_SANDBOX_ID_PREFIX = 'wsb'; export const WSLC_ID_PREFIX = 'wslc'; @@ -38,6 +49,7 @@ export const WSLC_ID_PREFIX = 'wslc'; // global constant. const DEFAULT_STATE_AWARE_VERSION: Record = { isolation_session: STATE_AWARE_VERSION, + lxc: LXC_STATE_AWARE_VERSION, windows_sandbox: STATE_AWARE_VERSION, wslc: WSLC_STATE_AWARE_VERSION, }; @@ -50,6 +62,7 @@ const DEFAULT_STATE_AWARE_VERSION: Record // `malformed_id`. export const BACKEND_TO_PREFIX: Record = { isolation_session: ISOLATION_SESSION_ID_PREFIX, + lxc: LXC_ID_PREFIX, windows_sandbox: WINDOWS_SANDBOX_ID_PREFIX, wslc: WSLC_ID_PREFIX, }; diff --git a/sdk/node/src/state-aware-types.ts b/sdk/node/src/state-aware-types.ts index 6e8985794..8e38ef159 100644 --- a/sdk/node/src/state-aware-types.ts +++ b/sdk/node/src/state-aware-types.ts @@ -20,7 +20,7 @@ export type Phase = 'provision' | 'start' | 'exec' | 'stop' | 'deprovision'; */ export type StateAwareContainmentBackend = Extract< ContainmentBackend, - 'isolation_session' | 'windows_sandbox' | 'wslc' + 'isolation_session' | 'lxc' | 'windows_sandbox' | 'wslc' >; /** @@ -92,6 +92,54 @@ export type IsolationSessionStopConfig = StateAwareConfig; export type IsolationSessionDeprovisionConfig = StateAwareConfig; +export interface LxcProvisionConfig { + /** Schema version (semver). */ + version?: string; + /** Container name to use instead of a generated one. */ + containerId?: string; + /** Linux distribution for the container rootfs, e.g. "alpine" or "ubuntu". */ + distribution: string; + /** Distribution release version, e.g. "3.20" or "24.04". */ + release: string; +} + +/** The `NetworkConfig` fields LXC honors at start. */ +export interface LxcNetworkConfig { + defaultPolicy?: NetworkConfig['defaultPolicy']; + allowedHosts?: NetworkConfig['allowedHosts']; + blockedHosts?: NetworkConfig['blockedHosts']; +} + +/** The `FilesystemConfig` fields LXC honors at start. */ +export interface LxcFilesystemConfig { + readwritePaths?: string[]; + readonlyPaths?: string[]; + deniedPaths?: string[]; +} + +export interface LxcStartConfig { + /** Schema version (semver). */ + version?: string; + filesystem?: LxcFilesystemConfig; + network?: LxcNetworkConfig; +} + +export interface LxcExecConfig { + /** Schema version (semver). */ + version?: string; + process: ProcessConfig; +} + +export interface LxcStopConfig { + /** Schema version (semver). */ + version?: string; +} + +export interface LxcDeprovisionConfig { + /** Schema version (semver). */ + version?: string; +} + /** * IsolationSession's provision-phase metadata surfaced to the caller: the * per-instance agent user account name minted for this sandbox, the agent @@ -105,6 +153,11 @@ export interface IsolationSessionProvisionMetadata { ephemeralWorkspacePath: string; } +export interface LxcProvisionMetadata { + containerName: string; + created: boolean; +} + // WindowsSandbox per-(backend, phase) Configs. WindowsSandbox holds a single // active sandbox behind a persistent host-side daemon. Filesystem policy // (readwrite/readonly/denied HOST paths) is honored at provision and is @@ -223,6 +276,13 @@ type StateAwareConfigRegistry = DefineStateAwareConfigRegistry<{ stop: IsolationSessionStopConfig; deprovision: IsolationSessionDeprovisionConfig; }; + lxc: { + provision: LxcProvisionConfig; + start: LxcStartConfig; + exec: LxcExecConfig; + stop: LxcStopConfig; + deprovision: LxcDeprovisionConfig; + }; windows_sandbox: { provision: WindowsSandboxProvisionConfig; start: WindowsSandboxStartConfig; @@ -322,6 +382,10 @@ export type StateAwareMetadata = DefineStateAwareMetadataRegistry<{ provision?: IsolationSessionProvisionMetadata; // IsolationSession returns no metadata for start, stop, or deprovision. }; + lxc: { + provision?: LxcProvisionMetadata; + // LXC returns no metadata for start, stop, or deprovision. + }; // WindowsSandbox returns no metadata for any phase (provision yields only the // sandbox id). The key still participates so `StateAwareMetadata[C]` type- // checks for `C = 'windows_sandbox'`. `Record` has `keyof = diff --git a/sdk/node/src/state-aware.ts b/sdk/node/src/state-aware.ts index 4e2548a01..c122b31b0 100644 --- a/sdk/node/src/state-aware.ts +++ b/sdk/node/src/state-aware.ts @@ -99,9 +99,9 @@ export async function startSandbox( /** * Streams a script execution inside a started sandbox. Returns an * `IPty` for live stdout/stderr/exit handling, mirroring `spawnSandbox`. - * On dispatch failure the executor emits a single error envelope on stdout; - * the SDK does not parse it here — callers consuming `IPty.onData` see the - * raw bytes. Use `execInSandboxAsync` when typed-error throwing is needed. + * On dispatch failure the executor emits a single error envelope on stdout + * (§7.3); the SDK does not parse it here — callers consuming `IPty.onData` see + * the raw bytes. Use `execInSandboxAsync` when typed-error throwing is needed. */ export function execInSandbox( sandboxId: SandboxId, @@ -140,8 +140,9 @@ export function execInSandbox( /** * Buffered exec convenience. Resolves with `{stdout, stderr, exitCode}` * on script completion. Throws an `MxcError` (with the wire-format `code` - * field set) when the executor reports a dispatch failure (recognised by - * exit != 0 and stdout being a complete `{error}` envelope). + * field set) when the executor reports a dispatch failure, recognised by + * exit != 0 together with a complete `{error}` envelope on stdout, which §7.3 + * reserves for the response envelope in every backend. */ export async function execInSandboxAsync( sandboxId: SandboxId, @@ -158,6 +159,9 @@ export async function execInSandboxAsync const { stdout, stderr, exitCode } = await spawnAndCollect(envelope, options); if (exitCode !== 0) { + // The cross-backend contract (§7.3) reserves stdout for the response + // envelope in every phase, so a dispatch failure is always found there and + // no backend needs its own channel rule. const errorEnvelope = tryParseErrorEnvelope(stdout); if (errorEnvelope) { throw mxcErrorFromEnvelope(errorEnvelope.error); diff --git a/sdk/node/tests/integration/test-helpers.ts b/sdk/node/tests/integration/test-helpers.ts index 8e3e14b4d..ee78200a6 100644 --- a/sdk/node/tests/integration/test-helpers.ts +++ b/sdk/node/tests/integration/test-helpers.ts @@ -280,6 +280,17 @@ export async function probeStateAwareRuntime { }); it('should force enforcementMode=firewall when host filtering is requested', () => { - // The LXC runner only invokes iptables when network_enforcement_mode is - // Firewall|Both (see lxc_common::network_iptables). Without this stamp, - // the parser would default to Capabilities and allowedHosts/blockedHosts - // would be silently dropped on the floor. + // The stamp stays for parity with the other Linux backends; LXC now + // enforces from the policy, so it is no longer load-bearing here. const config = createConfigFromPolicy({ version: '0.6.0-alpha', network: { allowOutbound: true, allowedHosts: ['example.com'] }, diff --git a/sdk/node/tests/unit/state-aware-types.test.ts b/sdk/node/tests/unit/state-aware-types.test.ts index d16b8332a..39bb6a0cf 100644 --- a/sdk/node/tests/unit/state-aware-types.test.ts +++ b/sdk/node/tests/unit/state-aware-types.test.ts @@ -12,6 +12,7 @@ import { ProvisionMetadataFor, ProvisionResult, SandboxId, + StartConfigFor, StartMetadataFor, StateAwareContainmentBackend, StopConfigFor, @@ -226,6 +227,84 @@ describe('IsolationSessionStopConfig and IsolationSessionDeprovisionConfig', () }); }); +describe('LxcStartConfig', () => { + it('accepts the network fields LXC actually enforces', () => { + const cfg: StartConfigFor<'lxc'> = { + version: '0.8.0-alpha', + filesystem: { readwritePaths: ['/workspace'] }, + network: { + defaultPolicy: 'block', + allowedHosts: ['example.com'], + blockedHosts: ['blocked.example.com'], + }, + }; + assert.strictEqual(cfg.network?.defaultPolicy, 'block'); + }); + + it('rejects network.proxy because the LXC runner rejects it at start', () => { + const cfg: StartConfigFor<'lxc'> = { + network: { + // @ts-expect-error — LXC state-aware start does not support network.proxy. + proxy: { builtinTestServer: true }, + }, + }; + assert.ok(cfg); + }); + + it('rejects removeRulesOnExit, which Rust would reject as an unknown field', () => { + // `wire::Network` is `deny_unknown_fields` and has no `removeRulesOnExit`, + // so emitting it inside the top-level `network` object fails the request. + const cfg: StartConfigFor<'lxc'> = { + network: { + // @ts-expect-error — SDK-only field; not part of the LXC wire surface. + removeRulesOnExit: true, + }, + }; + assert.ok(cfg); + }); + + it('rejects filesystem.clearPolicyOnExit, which Rust would reject as an unknown field', () => { + // `wire::Filesystem` is `deny_unknown_fields` and has no + // `clearPolicyOnExit`, so emitting it inside the top-level `filesystem` + // object fails the request. + const cfg: StartConfigFor<'lxc'> = { + filesystem: { + readwritePaths: ['/tmp/work'], + // @ts-expect-error — SDK-only field; not part of the LXC wire surface. + clearPolicyOnExit: false, + }, + }; + assert.ok(cfg); + }); + + it('rejects allowLocalNetwork, which the LXC backend never enforces', () => { + const cfg: StartConfigFor<'lxc'> = { + network: { + // @ts-expect-error — start rejects it with a policy-validation error. + allowLocalNetwork: true, + }, + }; + assert.ok(cfg); + }); + + it('rejects enforcementMode, which LXC never consults', () => { + const cfg: StartConfigFor<'lxc'> = { + network: { + // @ts-expect-error — enforcement follows the policy, not the mode. + enforcementMode: 'capabilities', + }, + }; + assert.ok(cfg); + }); + + it('enforces a restrictive policy from the policy fields alone', () => { + const cfg: StartConfigFor<'lxc'> = { + network: { defaultPolicy: 'block', allowedHosts: ['example.com'] }, + }; + assert.strictEqual(cfg.network?.defaultPolicy, 'block'); + }); +}); + describe('ConfigsForBackend', () => { it('selects the IsolationSession bundle for the isolation_session backend', () => { const bundle: ConfigsForBackend<'isolation_session'> = { diff --git a/sdk/node/tests/unit/state-aware.test.ts b/sdk/node/tests/unit/state-aware.test.ts index 1c5f24f0f..7cc8b5c7e 100644 --- a/sdk/node/tests/unit/state-aware.test.ts +++ b/sdk/node/tests/unit/state-aware.test.ts @@ -474,6 +474,87 @@ describe('execInSandboxAsync', { skip: platformSkip }, () => { ); }); + it('throws the typed MxcError on an LXC dispatch failure with diagnostics on stderr', async () => { + // Channel separation, per the cross-backend contract (§7.3): the executor + // flushes its buffered diagnostics to stderr and writes exactly one + // envelope to stdout. stderr is informational and is never parsed, so the + // buffer cannot shadow the envelope no matter how many lines it runs to. + const fake = fakeSpawn({ + stdout: '{"error":{"code":"not_started","message":"sandbox is not started"}}\n', + stderr: 'lxc: preparing container\nlxc: policy validated\n', + exitCode: 1, + }); + _setSpawnImpl(fake.spawn); + const id = 'lxc:prov-1' as SandboxId<'lxc'>; + await assert.rejects( + () => execInSandboxAsync(id, { process: { commandLine: 'echo' } }, testOptions()), + (err: unknown) => err instanceof MxcError && err.code === 'not_started', + ); + }); + + it('returns an ExecResult when a script is killed by its timeout after streaming output', async () => { + // Characterization, not an endorsement. §7.3 models exec as "either the + // script's output (success) or exactly one envelope (failure)", but a + // timeout is a dispatch failure raised *after* the script has streamed, so + // stdout holds both and whole-string parsing matches neither. The caller + // still sees a nonzero exit, but not the typed reason for it. + // + // Every state-aware backend shares this: the executors all write the + // envelope to the same stdout the guest streamed to. Pinning it here means + // closing the contract gap has to be a deliberate change, not a silent one. + const fake = fakeSpawn({ + stdout: + 'partial output before the kill\n' + + '{"error":{"code":"backend_error","message":"Execution failed: script timed out after 5000ms"}}\n', + stderr: 'diagnostic: attaching to container\n', + exitCode: 3, + }); + _setSpawnImpl(fake.spawn); + const id = 'lxc:abc' as SandboxId<'lxc'>; + const result = await execInSandboxAsync( + id, + { process: { commandLine: 'slow' } }, + testOptions(), + ); + assert.strictEqual(result.exitCode, 3); + }); + + it('does not read stderr as an envelope for backends that relay the guest there', async () => { + // The stderr fallback rests on LXC merging guest stderr into stdout, and + // that reasoning is backend-specific. Windows Sandbox forwards guest + // FrameKind::Stderr frames straight to this stream + // (backends/windows_sandbox/lifecycle/src/state_aware.rs:408-414), and the + // isolation-session exec relays the guest to wxc-exec's own stdout and + // stderr. On both, the last stderr line can be the script's own -- so a + // script that ends with envelope-shaped stderr and exits nonzero would be + // turned into a thrown MxcError rather than the result it actually is. + const stderrLookingLikeADispatchFailure = + '{"error":{"code":"not_started","message":"the script printed this itself"}}\n'; + + for (const id of [ + 'wsb:abc' as SandboxId<'windows_sandbox'>, + 'iso:abc' as SandboxId<'isolation_session'>, + ]) { + const fake = fakeSpawn({ + stdout: 'script output\n', + stderr: stderrLookingLikeADispatchFailure, + exitCode: 7, + }); + _setSpawnImpl(fake.spawn); + const result = await execInSandboxAsync( + id as SandboxId<'windows_sandbox'>, + { process: { commandLine: 'noisy' } }, + testOptions(), + ); + assert.strictEqual( + result.exitCode, + 7, + `${id} must return the script's result, not throw its stderr as a dispatch failure`, + ); + assert.strictEqual(result.stderr, stderrLookingLikeADispatchFailure); + } + }); + it('closes the child stdin so a stdin-reading command sees EOF instead of hanging', async () => { // Regression for the buffered-exec hang: the Rust state-aware path waits // for stdin EOF before closing guest stdin, so spawnAndCollect must end() @@ -583,6 +664,42 @@ describe('windows_sandbox state-aware lifecycle', () => { }); }); +describe('lxc state-aware lifecycle', () => { + it('defaults the version to 0.8.0-alpha so a network policy is enforced', () => { + const env = buildStateAwareEnvelope({ + phase: 'start', + backendKey: 'lxc', + sandboxId: 'lxc:abc', + config: { network: { defaultPolicy: 'block' } }, + }); + assert.strictEqual(env.version, '0.8.0-alpha'); + assert.deepStrictEqual(env.network, { defaultPolicy: 'block' }); + }); + + it('still honors a caller-supplied version over the lxc default', () => { + const env = buildStateAwareEnvelope({ + phase: 'start', + backendKey: 'lxc', + sandboxId: 'lxc:abc', + config: { version: '0.6.0-alpha' }, + }); + assert.strictEqual(env.version, '0.6.0-alpha'); + }); + + describe('round-trip via the typed API', { skip: platformSkip }, () => { + afterEach(() => { _resetSpawnImpl(); }); + + it('startSandbox sends a blocking policy on a version that enforces it', async () => { + const fake = fakeSpawn({ stdout: '{"result":{}}', exitCode: 0 }); + _setSpawnImpl(fake.spawn); + const id = 'lxc:0123abcd' as SandboxId<'lxc'>; + await startSandbox(id, { network: { defaultPolicy: 'block' } }, testOptions()); + assert.strictEqual(fake.captured.envelope?.version, '0.8.0-alpha'); + assert.deepStrictEqual(fake.captured.envelope?.network, { defaultPolicy: 'block' }); + }); + }); +}); + describe('wslc state-aware lifecycle', () => { it('defaults the version to 0.8.0-alpha (not the isolation_session default)', () => { const env = buildStateAwareEnvelope({ diff --git a/sdk/node/tests/unit/wire-conformance-state-aware.test.ts b/sdk/node/tests/unit/wire-conformance-state-aware.test.ts index ec9277638..723b12f1e 100644 --- a/sdk/node/tests/unit/wire-conformance-state-aware.test.ts +++ b/sdk/node/tests/unit/wire-conformance-state-aware.test.ts @@ -46,12 +46,18 @@ import type { WslcExecConfig, WslcStopConfig, WslcDeprovisionConfig, + LxcProvisionConfig, + LxcStartConfig, + LxcExecConfig, + LxcStopConfig, + LxcDeprovisionConfig, } from '../../src/state-aware-types.js'; import type { Phase as WirePhase, IsolationSessionProvisionPhase as WireProvisionPhase, WslcProvisionPhase as WireWslcProvisionPhase, + LxcProvisionPhase as WireLxcProvisionPhase, } from '../../src/generated/wire.js'; import type { @@ -81,11 +87,20 @@ type _Phase = AssertTrue>; // field legal only on provision cannot satisfy the oracle by appearing // on the start config, or vice versa. // -// `filesystem` and `telemetry` are lifted top-level wire fields (like `network`): -// phase configs surface them publicly but they map to the envelope top level, -// not under `experimental..`. Listing them here keeps the -// backend-key set limited to genuinely per-phase wire fields. -type LiftedPhaseKey = 'version' | 'process' | 'network' | 'filesystem' | 'telemetry'; +// `filesystem`, `containerId`, and `telemetry` are lifted top-level wire fields +// (like `network`): phase configs surface them publicly but they map to the +// envelope top level, not under `experimental..`. Listing them +// here keeps the backend-key set limited to genuinely per-phase wire fields. +// `containerId` is lifted for every backend -- `CROSS_CUTTING_FIELDS` in +// `state-aware-helper.ts` moves it to the envelope top level, so it never +// reaches a per-phase object. +type LiftedPhaseKey = + | 'version' + | 'process' + | 'network' + | 'filesystem' + | 'containerId' + | 'telemetry'; type BackendKeys = Exclude; type WireKeys = keyof StripIndex; @@ -172,6 +187,44 @@ type _WslcProvisionWireKeysNonVacuous = AssertTrue< Equivalent, 'image' | 'imageTarPath'> >; +// --- LXC per-phase wire field-set conformance ------------------------------ + +// LXC is the third state-aware backend, so the oracle must cover it too or a +// wire-model change to the LXC surface would regenerate `wire.ts`, pass the +// codegen gate, and leave the SDK silently lagging with no CI signal. LXC's +// only per-phase wire object is provision (`distribution` / `release`); start, +// exec, stop, and deprovision have wire associated type `()` and must expose no +// backend-specific field. `filesystem`, `network`, and `containerId` are lifted +// top-level wire fields (see `LiftedPhaseKey`). +type _LxcProvisionPublicKeys = AssertTrue< + Equivalent, WireKeys>, never> +>; +type _LxcProvisionWireKeys = AssertTrue< + Equivalent, BackendKeys>, never> +>; +type _LxcProvisionFieldValueTypes = AssertTrue< + Equivalent< + PublicFieldValues, + WireFieldValues + > +>; + +type _LxcStartNoBackendKeys = AssertTrue, never>>; +type _LxcExecNoBackendKeys = AssertTrue, never>>; +type _LxcStopNoBackendKeys = AssertTrue, never>>; +type _LxcDeprovisionNoBackendKeys = AssertTrue< + Equivalent, never> +>; + +// Non-vacuity guards (see the isolation_session pins above): pin the derived key +// sets so a derivation bug fails the oracle rather than silently disabling it. +type _LxcProvisionKeysNonVacuous = AssertTrue< + Equivalent, 'distribution' | 'release'> +>; +type _LxcProvisionWireKeysNonVacuous = AssertTrue< + Equivalent, 'distribution' | 'release'> +>; + // --- delegation to the one-shot oracle (documented, asserted) -------------- // The per-phase configs must REUSE the public one-shot leaf types for their @@ -180,6 +233,7 @@ type _WslcProvisionWireKeysNonVacuous = AssertTrue< // assertions fail if that ever happens. type _ExecProcessReuse = AssertTrue>; type _WslcExecProcessReuse = AssertTrue>; +type _LxcExecProcessReuse = AssertTrue>; // Reference the assertion aliases so they read as intentionally load-bearing. export type StateAwareWireConformanceAssertions = [ @@ -204,6 +258,16 @@ export type StateAwareWireConformanceAssertions = [ _WslcProvisionKeysNonVacuous, _WslcProvisionWireKeysNonVacuous, _WslcExecProcessReuse, + _LxcProvisionPublicKeys, + _LxcProvisionWireKeys, + _LxcProvisionFieldValueTypes, + _LxcStartNoBackendKeys, + _LxcExecNoBackendKeys, + _LxcStopNoBackendKeys, + _LxcDeprovisionNoBackendKeys, + _LxcProvisionKeysNonVacuous, + _LxcProvisionWireKeysNonVacuous, + _LxcExecProcessReuse, ]; test('public state-aware SDK types conform to the generated wire schema (compile-time)', () => { diff --git a/src/Cargo.lock b/src/Cargo.lock index 4618472f9..f8b497340 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1398,6 +1398,7 @@ dependencies = [ "mxc_engine", "nanvix_binaries", "nanvix_build_common", + "serde_json", "wxc_common", ] @@ -1408,6 +1409,7 @@ dependencies = [ "libc", "mxc_pty", "nix", + "semver", "serde", "serde_json", "sha2 0.10.9", diff --git a/src/backends/lxc/common/Cargo.toml b/src/backends/lxc/common/Cargo.toml index f40cae257..ce3b81243 100644 --- a/src/backends/lxc/common/Cargo.toml +++ b/src/backends/lxc/common/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true wxc_common = { workspace = true } libc = { workspace = true } nix = { workspace = true } +semver = "1" serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/src/backends/lxc/common/src/filesystem_mounts.rs b/src/backends/lxc/common/src/filesystem_mounts.rs index 6dbfc1494..e11495673 100644 --- a/src/backends/lxc/common/src/filesystem_mounts.rs +++ b/src/backends/lxc/common/src/filesystem_mounts.rs @@ -206,7 +206,15 @@ pub fn configure_filesystem_mounts( policy: &ContainerPolicy, logger: &mut Logger, ) -> Result<(), String> { + // Derive the whole mount set first, then commit it in one config rewrite. + // liblxc accumulates `lxc.mount.entry` lines across restarts, so the + // previous run's MXC mounts have to go; committing the replacement one + // entry at a time meant a crash or a rejected path partway through left a + // durable config matching no policy anyone wrote. Only MXC's own entries + // are replaced -- baseline mounts the distribution template or the operator + // placed in the config carry no marker and survive. let mounts = resolve_mount_order(policy); + let mut entries: Vec = Vec::with_capacity(mounts.len()); // Container-side paths of every re-bound (rw/ro) mount, used to decide // whether a denied *directory* must be masked with a writable tmpfs so a @@ -236,7 +244,7 @@ pub fn configure_filesystem_mounts( "Adding rw bind mount: {} -> /{}", host_path, container_path )); - container.set_config_item("lxc.mount.entry", &mount_entry)?; + entries.push(mount_entry); } FsIntent::ReadOnly => { let mount_entry = format!( @@ -247,7 +255,7 @@ pub fn configure_filesystem_mounts( "Adding ro bind mount: {} -> /{}", host_path, container_path )); - container.set_config_item("lxc.mount.entry", &mount_entry)?; + entries.push(mount_entry); } FsIntent::Denied => { // Resolve the denied path through symlinks to its real host @@ -301,12 +309,12 @@ pub fn configure_filesystem_mounts( "Masking denied path: /{} ({})", container_path, create_type )); - container.set_config_item("lxc.mount.entry", &mount_entry)?; + entries.push(mount_entry); } } } - Ok(()) + container.replace_mxc_mount_entries(&entries) } /// Remove filesystem mount configuration. @@ -357,6 +365,55 @@ mod tests { assert!(validate_path("/mnt/shared").is_ok()); } + #[test] + fn configure_filesystem_mounts_replaces_not_accumulates() { + use wxc_common::logger::Mode; + + // Real config file so set_config_item/clear_config_item operate on disk. + let base = std::env::temp_dir().join(format!( + "mxc-fs-mounts-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let name = "box"; + std::fs::create_dir_all(base.join(name)).unwrap(); + let container = LxcContainer::new(name, Some(base.to_str().unwrap())); + std::fs::write(container.config_file_path(), "lxc.arch = amd64\n").unwrap(); + + let mut logger = Logger::new(Mode::Buffer); + + // First start: broad policy binds /host/broad. + let policy_a = ContainerPolicy { + readwrite_paths: vec!["/host/broad".to_string()], + ..Default::default() + }; + configure_filesystem_mounts(&container, &policy_a, &mut logger).unwrap(); + + // Second start (simulated restart): narrower policy binds /host/narrow. + let policy_b = ContainerPolicy { + readonly_paths: vec!["/host/narrow".to_string()], + ..Default::default() + }; + configure_filesystem_mounts(&container, &policy_b, &mut logger).unwrap(); + + let cfg = std::fs::read_to_string(container.config_file_path()).unwrap(); + assert!( + !cfg.contains("/host/broad"), + "restart must not inherit the previous run's mount, got:\n{cfg}" + ); + assert!( + cfg.contains("/host/narrow"), + "restart must apply the new policy's mount, got:\n{cfg}" + ); + // Non-mount config lines are preserved across the clear/reapply. + assert!(cfg.contains("lxc.arch = amd64")); + + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn has_rebound_descendant_detects_nested_rebind_only() { let rebound = vec![ diff --git a/src/backends/lxc/common/src/lib.rs b/src/backends/lxc/common/src/lib.rs index 3dea29501..7852a440b 100644 --- a/src/backends/lxc/common/src/lib.rs +++ b/src/backends/lxc/common/src/lib.rs @@ -8,3 +8,4 @@ pub mod lxc_runner; pub mod network_ingress; pub mod network_iptables; pub mod signal_cleanup; +pub mod state_aware; diff --git a/src/backends/lxc/common/src/lxc_bindings.rs b/src/backends/lxc/common/src/lxc_bindings.rs index e32ded9fe..eae2cdc26 100644 --- a/src/backends/lxc/common/src/lxc_bindings.rs +++ b/src/backends/lxc/common/src/lxc_bindings.rs @@ -68,18 +68,152 @@ pub fn resolve_default_lxcpath() -> String { resolve_lxcpath_with_env(|k| std::env::var(k).ok(), current_euid) } +/// Environment variable stamped on every process an exec starts inside the +/// container, so a timeout can find its descendants and kill them. +/// +/// A timeout tears down `lxc-attach` on the host, which says nothing about the +/// processes the script started inside the container's PID namespace. The +/// container is persistent — it outlives the exec and lives until deprovision +/// — so those survivors keep holding CPU, memory, handles, and network inside a +/// sandbox the caller believes is idle, and the next exec shares the container +/// with them. +/// +/// The marker rides on the environment, which every `fork`/`exec` inherits, so +/// it reaches descendants at any depth. But the environment belongs to the +/// workload: anything that scrubs it escapes, so this cleans up after work that +/// is not trying to escape and is not a boundary against work that is. +/// `lxc-attach` joins the container's existing namespaces via `setns` and cannot +/// create one, so a per-exec PID namespace would have to be unshared by the +/// attached command itself — real, but a design that needs a live host to +/// validate, so #871 carries it rather than this improvising one. +/// +/// The name is **reserved**: `build_attach_args` drops any caller-supplied +/// entry that uses it, so a caller cannot set it to another exec's token and be +/// reaped by that exec's timeout. +#[cfg(any(target_os = "linux", test))] +const EXEC_MARKER_VAR: &str = "MXC_EXEC_ID"; + +/// Mint a token for one exec, unique to this process and this call. +/// +/// Uniqueness matters in both directions: two execs in the same container must +/// not reap each other, and a stale token from an earlier process must not +/// match anything live. +pub fn mint_exec_marker() -> String { + format!( + "{}-{}", + std::process::id(), + wxc_common::id::mint_random_token() + ) +} + +/// Build the post-binary argv for the `lxc-attach` that reaps an exec's +/// leftovers, given the marker value that exec was stamped with. +/// +/// The marker travels as a positional argument rather than being spliced into +/// the script, so nothing in it can be read as shell syntax. Only `cat` and +/// `kill` are used beyond shell builtins: `grep`'s `-a` and `-F` flags are not +/// dependable across the busybox and GNU userlands MXC containers are built +/// from. Command substitution drops the NULs that separate `environ` entries, +/// which concatenates neighbors but leaves each `KEY=VALUE` intact, so the +/// substring test still holds. +/// +/// The reaping shell is attached *without* the marker, so it cannot match +/// itself. +/// +/// `/proc/[0-9]*` is expanded once per pass, so a workload that forks after +/// the expansion has a child the pass never sees. The scan therefore stops +/// what it finds before killing anything: a stopped process cannot fork again, +/// so each pass shrinks the set that is still able to grow, and the loop +/// repeats until a pass discovers no marked process it had not already seen. +/// Only then is the collected set killed. The pass count is bounded so a +/// deliberate fork bomb cannot spin here forever. +/// +/// That is containment by convergence, not by construction. A per-exec cgroup +/// or PID namespace would make it race-free outright, and `lxc-attach` offers +/// neither. +/// +/// A final scan decides the exit status, because a process in uninterruptible +/// sleep outlives the `SIGKILL` that was sent to it. It reads the marker +/// rather than the kill's own result: `kill` reports failure when the process +/// is already gone, which is the case where the reap worked. A process torn +/// down but not yet reaped by its parent has no address space left, so its +/// `environ` cannot be opened and it matches nothing. The scan repeats a +/// bounded number of times to let a kill that is still in flight land. +#[cfg(any(target_os = "linux", test))] +fn build_reap_args(marker: &str) -> Vec { + vec![ + "--".to_string(), + "/bin/sh".to_string(), + "-c".to_string(), + "seen=\" \"; i=0; \ + while [ \"$i\" -lt 8 ]; do \ + found=0; \ + for d in /proc/[0-9]*; do \ + p=${d#/proc/}; \ + case \"$seen\" in *\" $p \"*) continue ;; esac; \ + e=$(cat \"$d/environ\" 2>/dev/null) || continue; \ + case \"$e\" in *\"$1\"*) \ + kill -STOP \"$p\" 2>/dev/null; \ + seen=\"$seen$p \"; \ + found=1 ;; \ + esac; \ + done; \ + [ \"$found\" -eq 0 ] && break; \ + i=$((i + 1)); \ + done; \ + for p in $seen; do kill -KILL \"$p\" 2>/dev/null; done; \ + j=0; \ + while [ \"$j\" -lt 4 ]; do \ + rc=0; \ + for d in /proc/[0-9]*; do \ + e=$(cat \"$d/environ\" 2>/dev/null) || continue; \ + case \"$e\" in *\"$1\"*) rc=1 ;; esac; \ + done; \ + [ \"$rc\" -eq 0 ] && break; \ + j=$((j + 1)); \ + done; \ + exit $rc" + .to_string(), + "_".to_string(), + format!("{}={}", EXEC_MARKER_VAR, marker), + ] +} + /// The keep-env argv shape, for tests that do not exercise env control. /// /// No production caller wants it, so outside `cfg(test)` this is dead code, /// and the workspace clippy lane runs with `-D warnings`. #[cfg(test)] -fn build_attach_args(env: &[String], working_directory: &str, command: &str) -> Vec { - build_attach_args_with_env_control(env, working_directory, command, false) +fn build_attach_args( + env: &[String], + working_directory: &str, + command: &str, + marker: Option<&str>, +) -> Vec { + build_attach_args_with_env_control(env, working_directory, command, false, marker) +} + +/// The first environment entry that cannot become a variable. +/// +/// `lxc-attach` receives each entry as `--set-var=KEY=VALUE`. An entry with no +/// `=` names nothing, and one whose key is empty names a variable with no name. +/// An empty VALUE is well formed, and an `=` inside VALUE is fine because the +/// split stops at the first one. +pub(crate) fn malformed_env_entry(env: &[String]) -> Option<&String> { + env.iter().find(|entry| match entry.split_once('=') { + Some((key, _)) => key.is_empty(), + None => true, + }) } /// Build the post-binary argv for `lxc-attach` (the args that follow the /// `-n NAME -P lxcpath` flags already appended by `lxc_command`). /// +/// `marker`, when present, is stamped into the child's environment as +/// [`EXEC_MARKER_VAR`] so a timeout can locate the whole process tree later; +/// see [`build_reap_args`]. It is only supplied when the caller set a timeout, +/// so the no-timeout argv is unchanged. +/// /// Extracted so the env / cwd / command layering is unit-testable without /// actually spawning `lxc-attach`. See [`LxcContainer::attach_run`] for /// the full contract. @@ -98,6 +232,7 @@ fn build_attach_args_with_env_control( working_directory: &str, command: &str, force_clear_env: bool, + marker: Option<&str>, ) -> Vec { // Loose upper bound; realloc-avoidance hint only. let mut args: Vec = Vec::with_capacity(env.len() + 8); @@ -109,17 +244,31 @@ fn build_attach_args_with_env_control( if force_clear_env || !env.is_empty() { args.push("--clear-env".to_string()); for kv in env { - // Well-formed = "KEY=VAL" with a non-empty KEY. `"=foo"` and - // `"BADENTRY"` are both silently skipped; embedded `=` in - // VAL is fine because split_once stops at the first one. + // Well-formed = "KEY=VAL" with a non-empty KEY. The runner refuses + // a malformed entry before any container is created, so this skip + // only catches a caller that bypassed it; emitting the entry + // anyway would hand lxc-attach an argument it cannot parse. if let Some((key, _)) = kv.split_once('=') { - if !key.is_empty() { + // `EXEC_MARKER_VAR` is reserved. Dropping a caller's copy is + // not just tidiness: the marker is what a timeout kills by, so + // a caller that set this name to another exec's token would be + // reaped by that exec. The drop is unconditional because a + // concurrent exec's timeout can reap this one even when this + // one carries no marker of its own. + if !key.is_empty() && key != EXEC_MARKER_VAR { args.push(format!("--set-var={}", kv)); } } } } + // Stamped after the caller's entries so `--clear-env` still leads, and + // outside the `env.is_empty()` gate so a caller that set no env still gets + // a reapable exec. + if let Some(token) = marker { + args.push(format!("--set-var={}={}", EXEC_MARKER_VAR, token)); + } + args.push("--".to_string()); args.push("/bin/sh".to_string()); args.push("-c".to_string()); @@ -141,6 +290,174 @@ fn build_attach_args_with_env_control( args } +/// What liblxc says the container's network interfaces are. +/// +/// Read through `lxc-info -c` rather than by parsing the container's config +/// file, because `lxc.include` can declare interfaces that file never mentions. +/// liblxc has already resolved those includes, so its answer is the set that +/// will actually be brought up; a count taken from the file alone would +/// understate the container and let a policy claim to be enforced when it is +/// not. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct NetInterfaceConfig { + /// How many interfaces liblxc will bring up. + pub count: usize, + /// The declared type of the container's only interface, present only when + /// `count == 1`. + /// + /// Enforcement refuses anything that is not a `veth`, so the type is read + /// to decide that. It comes from the same `lxc.net` read as the count, so + /// no separate indexed probe is needed and there is no window between + /// reading the count and reading the type. + pub sole_kind: Option, +} + +/// Interpret an `lxc-info -c ` answer as the values liblxc holds for that +/// key. +/// +/// liblxc prints the first value on a `key = value` line and any further values +/// bare on lines of their own. Each line is trimmed; a line beginning with +/// `key` followed by optional whitespace and `=` yields what follows, and any +/// other line is taken whole. Results are trimmed and empties dropped, so an +/// absent or empty key yields no values. +/// +/// The prefix is keyed on `key` rather than split on the first `=` anywhere in +/// the line: a continuation value can itself contain `=` (a hook command does), +/// and splitting on `=` would truncate it. +fn interpret_config_values(key: &str, stdout: &str) -> Vec { + stdout + .lines() + .map(|line| line.trim()) + .map(|line| { + line.strip_prefix(key) + .map(|rest| rest.trim_start()) + .and_then(|rest| rest.strip_prefix('=')) + .map_or(line, |value| value) + .trim() + }) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .collect() +} + +/// Build the `lxc.hook.start-host` value that runs the veth-pin script. +/// +/// The script takes the desired host veth name as its sole argument, so the +/// value is the script path and the target name separated by a space. +fn veth_pin_hook_command(script_path: &str, target_veth: &str) -> String { + format!("{} {}", script_path, target_veth) +} + +/// Shell body of the `lxc.hook.start-host` hook that renames the container's +/// host-side veth to the name passed as `$1`. +/// +/// It finds the container's sole peered interface (a veth in the netns prints +/// `eth0@if`; `lo` has no `@if`, so it is naturally excluded), resolves that +/// host ifindex to its current name, and renames it. It is idempotent and fails +/// closed. +const VETH_PIN_SCRIPT: &str = r#"#!/bin/sh +set -e +target="$1" +i=$(nsenter -t "$LXC_PID" -n ip -o link | sed -n 's/^[0-9]*: [^:@]*@if\([0-9]*\):.*/\1/p' | head -n1) +[ -n "$i" ] || { echo "mxc: no peered interface in container netns" >&2; exit 1; } +c=$(ip -o link | sed -n "s/^$i: \([^:@]*\)[@:].*/\1/p" | head -n1) +[ -n "$c" ] || { echo "mxc: host ifindex $i not resolvable" >&2; exit 1; } +[ "$c" = "$target" ] || ip link set "$c" name "$target" +"#; + +/// Read an `lxc-info` run as "does this container exist?". +/// +/// Split out as a pure function so the three-way answer is testable without +/// `lxc-info` on the box. +/// +/// A nonzero exit is ambiguous on its own: `lxc-info` reports a container it +/// does not know that way, but so do a permission error, a transient runtime +/// failure, and a malformed config. "Defined" means LXC has a config file for +/// the container, so that file settles the ambiguity, and `try_exists` reports +/// `false` only when it can prove absence -- a directory it cannot read is an +/// `Err`, not a `false`. +/// +/// The layout assumption is `{lxc_path}/{name}/config`. If that is ever wrong +/// the failure lands on `Ok(true)` for the config probe and therefore on `Err` +/// here, which refuses the phase; the old code returned `Ok(false)` and +/// unfiltered a live container. Wrong in the safe direction. +fn interpret_defined_probe( + probe_succeeded: bool, + stderr: &str, + config: &std::path::Path, + name: &str, +) -> Result { + if probe_succeeded { + return Ok(true); + } + let detail = stderr.trim(); + match config.try_exists() { + Ok(false) => Ok(false), + Ok(true) => Err(format!( + "lxc-info failed for container {name:?} but its config file at {} is present, so \ + whether the container is defined is unknown: {detail}", + config.display() + )), + Err(e) => Err(format!( + "lxc-info failed for container {name:?} and its config file at {} could not be \ + checked ({e}), so whether the container is defined is unknown: {detail}", + config.display() + )), + } +} + +/// The `lxc-info -s` states that still have a live container behind them. +/// +/// `STOPPED` is the only state that does not. `FROZEN` and `FREEZING` have +/// processes that thaw straight back into a running container, so unfiltering +/// one is the same fail-open as unfiltering a running one. The transitional +/// states count as live for the same reason: stopping a container that is +/// already going down is harmless, and unfiltering one that is coming up is +/// not. +const LIVE_STATES: [&str; 7] = [ + "RUNNING", "FROZEN", "FREEZING", "THAWED", "STARTING", "STOPPING", "ABORTING", +]; + +/// Read `lxc-info -s` output as "is this container running?". +/// +/// Split out as a pure function so the three-way answer is testable without a +/// container on the box. Only a `State:` line answers the question; anything +/// else is an error rather than `false`, because callers treat a stopped +/// container as safe to unfilter and safe to skip stopping. Guessing +/// "stopped" from output we could not read is the one answer that turns a +/// broken probe into an unfiltered running container. +fn interpret_state_output(stdout: &str) -> Result { + for line in stdout.lines() { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + if key.trim() == "State" { + let state = value.trim(); + if state == "STOPPED" { + return Ok(false); + } + if LIVE_STATES.contains(&state) { + return Ok(true); + } + return Err(format!( + "lxc-info -s named an unrecognized state {state:?}, so whether the container is \ + running is unknown" + )); + } + } + // No `State:` line. If a live-state name shows up anyway, answer in the + // safe direction rather than give up: reporting "running" only ever costs a + // refused operation, whereas reporting "stopped" is what unfilters a live + // container. + if LIVE_STATES.iter().any(|s| stdout.contains(s)) { + return Ok(true); + } + Err(format!( + "lxc-info -s named no state, so whether the container is running is unknown (output: {:?})", + stdout.trim() + )) +} + /// Safe wrapper around an LXC container. pub struct LxcContainer { name: String, @@ -201,19 +518,48 @@ impl LxcContainer { Ok(()) } - /// Check if the container exists. - pub fn is_defined(&self) -> bool { - let output = self.lxc_command("lxc-info").output(); - matches!(output, Ok(o) if o.status.success()) + /// Whether the container exists. + /// + /// `Err` means the probe could not answer, which is not evidence of + /// absence. Collapsing that into `false` reads "the probe broke" as "the + /// container is gone", which let deprovision skip the destroy and then + /// strip the firewall from a container that was still running. + /// + /// See [`interpret_defined_probe`] for why a nonzero exit is not an answer + /// on its own. + pub fn is_defined(&self) -> Result { + let output = self + .lxc_command("lxc-info") + .output() + .map_err(|e| format!("failed to run lxc-info: {e}"))?; + let config = self.config_file_path(); + interpret_defined_probe( + output.status.success(), + &String::from_utf8_lossy(&output.stderr), + std::path::Path::new(&config), + &self.name, + ) } - /// Check if the container is running. - pub fn is_running(&self) -> bool { - let output = self.lxc_command("lxc-info").arg("-s").output(); - match output { - Ok(o) => String::from_utf8_lossy(&o.stdout).contains("RUNNING"), - Err(_) => false, + /// Whether the container is running. + /// + /// `Err` covers both a probe that could not run and one whose output names + /// no state we recognize. Neither is evidence that the container is + /// stopped, and callers treat "stopped" as safe to unfilter or safe to + /// skip stopping -- so an unreadable probe must not answer `false`. + pub fn is_running(&self) -> Result { + let output = self + .lxc_command("lxc-info") + .arg("-s") + .output() + .map_err(|e| format!("failed to run lxc-info -s: {e}"))?; + if !output.status.success() { + return Err(format!( + "lxc-info -s failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); } + interpret_state_output(&String::from_utf8_lossy(&output.stdout)) } /// Return the PID of the container's init process, or `None` if the @@ -254,6 +600,14 @@ impl LxcContainer { Self::run_status(cmd, "lxc-create") } + /// Marker comment written immediately above every `lxc.mount.entry` line + /// that MXC itself adds, so + /// [`replace_mxc_mount_entries`](Self::replace_mxc_mount_entries) can + /// rewrite only MXC's own mounts and leave baseline entries the distro + /// template or the user placed in the config untouched. It is a real LXC + /// comment (`#`), so liblxc ignores it when parsing the file. + const MXC_MOUNT_MARKER: &'static str = "# mxc-managed-mount"; + /// Set a configuration item on the container. /// /// Appends `key = value` to the container's config file. The error @@ -279,6 +633,276 @@ impl LxcContainer { }) } + /// Replace the container's config file with `contents` in one atomic step. + /// + /// [`std::fs::write`] truncates in place, so a signal, crash, or OOM between + /// the truncate and the last byte leaves the container's durable config + /// partial or empty. liblxc re-reads that file on every start, so a + /// half-written rewrite silently drops the entries a tightened policy + /// depends on -- the failure lands on the next start, far from the write + /// that caused it. Writing a sibling temporary and renaming it over the + /// target makes the swap atomic: a concurrent reader observes either the + /// whole old config or the whole new one, never a truncated prefix. + /// + /// The temporary is created beside the target so the rename stays inside one + /// filesystem, and it is flushed before the rename so the bytes are durable + /// before anything points at them. It carries the process id so two + /// processes rewriting the same config cannot clobber each other's + /// temporary, and it is removed on every failure path so a failed rewrite + /// leaves no residue. + /// + /// A rename swaps in a *new* inode, so the target's mode and ownership are + /// whatever the temporary had rather than what the operator set. That would + /// silently relax a hardened `0600` root-owned config to a umask-derived + /// `0644` on the first start, and would hand the file to the executor's uid + /// when it runs as root. The original's metadata is therefore captured + /// before the write and restored onto the temporary before the rename. The + /// temporary is opened `0600` so its contents are never briefly readable by + /// anyone the final mode would exclude; when there is no original to mirror, + /// the platform default is left alone rather than a policy being invented. + fn write_config_atomically(config_path: &str, contents: &str) -> std::io::Result<()> { + use std::io::Write; + + let temp_path = format!("{}.mxc-tmp-{}", config_path, std::process::id()); + let original = std::fs::metadata(config_path).ok(); + let write_temp = || -> std::io::Result<()> { + let mut file = Self::create_config_temp(&temp_path, original.is_some())?; + file.write_all(contents.as_bytes())?; + file.sync_all() + }; + if let Err(e) = write_temp() { + let _ = std::fs::remove_file(&temp_path); + return Err(e); + } + if let Some(ref meta) = original { + if let Err(e) = Self::mirror_config_metadata(&temp_path, meta) { + let _ = std::fs::remove_file(&temp_path); + return Err(e); + } + } + if let Err(e) = std::fs::rename(&temp_path, config_path) { + let _ = std::fs::remove_file(&temp_path); + return Err(e); + } + Ok(()) + } + + /// Open the rewrite temporary, restricted to the owner when there is an + /// existing config whose mode will be restored before the rename. + #[cfg(unix)] + fn create_config_temp(temp_path: &str, has_original: bool) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + + if !has_original { + return std::fs::File::create(temp_path); + } + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(temp_path) + } + + #[cfg(not(unix))] + fn create_config_temp(temp_path: &str, _has_original: bool) -> std::io::Result { + std::fs::File::create(temp_path) + } + + /// Put the replaced config's mode and ownership onto its replacement. + /// + /// Ownership is restored only when it actually differs, so an unprivileged + /// executor rewriting a config it already owns is not failed by a `chown` + /// it never needed permission to make. + #[cfg(unix)] + fn mirror_config_metadata( + temp_path: &str, + original: &std::fs::Metadata, + ) -> std::io::Result<()> { + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + + // Masked to the permission bits: the mode read back from a `Metadata` + // also carries the file-type bits, which are not this call's to set. + std::fs::set_permissions( + temp_path, + std::fs::Permissions::from_mode(original.mode() & 0o7777), + )?; + let temp_meta = std::fs::metadata(temp_path)?; + if temp_meta.uid() != original.uid() || temp_meta.gid() != original.gid() { + std::os::unix::fs::chown(temp_path, Some(original.uid()), Some(original.gid())) + .map_err(|e| { + std::io::Error::new( + e.kind(), + format!( + "could not restore the config's owner {}:{} onto its replacement, so \ + the rewrite was abandoned rather than silently changing who owns it: \ + {e}", + original.uid(), + original.gid() + ), + ) + })?; + } + Ok(()) + } + + #[cfg(not(unix))] + fn mirror_config_metadata( + temp_path: &str, + original: &std::fs::Metadata, + ) -> std::io::Result<()> { + std::fs::set_permissions(temp_path, original.permissions()) + } + + /// Remove every configuration line for `key` from the container's config + /// file. + /// + /// [`set_config_item`](Self::set_config_item) *appends* a `key = value` + /// line, and list-type keys such as `lxc.mount.entry` accumulate one line + /// per call. liblxc replays every occurrence when it parses the file at + /// start, so a caller that re-derives a list from policy on each start must + /// clear the previous run's lines first — otherwise a restart inherits + /// stale entries (e.g. mounts a tightened policy meant to drop). + /// + /// A line matches when the token before its first `=` (trimmed) equals + /// `key`, so `lxc.mount.entry` is matched but neighbouring keys like + /// `lxc.mount` are left intact, and `=` inside a value (e.g. + /// `create=dir`) is irrelevant. A missing config file is treated as + /// already-clear (`Ok`). + pub fn clear_config_item(&self, key: &str) -> Result<(), String> { + let config_path = self.config_file_path(); + let contents = match std::fs::read_to_string(&config_path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => { + return Err(format!( + "Failed to read config to clear {}: {} (config file: {})", + key, e, config_path + )) + } + }; + + let mut out = String::with_capacity(contents.len()); + for line in contents.lines() { + let matches_key = line + .split_once('=') + .map(|(lhs, _)| lhs.trim() == key) + .unwrap_or(false); + if !matches_key { + out.push_str(line); + out.push('\n'); + } + } + + Self::write_config_atomically(&config_path, &out).map_err(|e| { + format!( + "Failed to rewrite config to clear {}: {} (config file: {})", + key, e, config_path + ) + }) + } + + /// Replace MXC's whole mount set in one atomic config rewrite. + /// + /// Each entry is written as [`MXC_MOUNT_MARKER`](Self::MXC_MOUNT_MARKER) on + /// its own line followed by `lxc.mount.entry = value`. liblxc treats the + /// marker as a comment and the entry exactly as if it had been added with + /// [`set_config_item`](Self::set_config_item). + /// + /// The set is the unit that matters: a container configured with half of + /// its policy's bind mounts is not a weaker sandbox, it is a different one. + /// Clearing and then appending each entry separately committed a config + /// per mount, so a crash, a signal, or a rejected path partway through left + /// a durable config that matched no policy anyone wrote. One rewrite means + /// a reader sees either the previous run's mounts or this run's, never a + /// prefix of this run's. + /// + /// Only MXC's own entries are replaced. Each is tagged with + /// [`MXC_MOUNT_MARKER`](Self::MXC_MOUNT_MARKER) on the line above it, which + /// liblxc treats as a comment; foreign `lxc.mount.entry` lines placed by the + /// distribution template or the operator carry no marker and survive + /// untouched. Clearing those instead would silently detach container + /// storage nobody asked us to manage. + /// + /// A missing config file is already free of MXC mounts, so an empty set + /// succeeds against one. A non-empty set does not: writing mount entries + /// into a config that liblxc never created would produce a container + /// definition with no template behind it. + pub fn replace_mxc_mount_entries(&self, values: &[String]) -> Result<(), String> { + let config_path = self.config_file_path(); + let contents = match std::fs::read_to_string(&config_path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if values.is_empty() { + return Ok(()); + } + return Err(format!( + "Failed to set {} MXC mount entries: no config file at {}", + values.len(), + config_path + )); + } + Err(e) => { + return Err(format!( + "Failed to read config to rewrite MXC mounts: {} (config file: {})", + e, config_path + )) + } + }; + + let mut out = Self::strip_mxc_mount_entries(&contents); + for value in values { + out.push_str(Self::MXC_MOUNT_MARKER); + out.push('\n'); + out.push_str("lxc.mount.entry = "); + out.push_str(value); + out.push('\n'); + } + + Self::write_config_atomically(&config_path, &out).map_err(|e| { + format!( + "Failed to rewrite config with {} MXC mount entries: {} (config file: {})", + values.len(), + e, + config_path + ) + }) + } + + /// `contents` with every MXC-added mount line removed. + /// + /// A marker line and the `lxc.mount.entry` line immediately following it are + /// dropped together. An orphaned marker -- one left by a config written + /// before the rewrite became atomic -- is dropped on its own so stray + /// comments cannot accumulate. + /// + /// Pure so the line-pairing is testable without a container on the box. + fn strip_mxc_mount_entries(contents: &str) -> String { + let lines: Vec<&str> = contents.lines().collect(); + let mut out = String::with_capacity(contents.len()); + let mut i = 0; + while i < lines.len() { + let line = lines[i]; + if line.trim() == Self::MXC_MOUNT_MARKER { + let next_is_entry = lines + .get(i + 1) + .map(|l| { + l.split_once('=') + .map(|(lhs, _)| lhs.trim() == "lxc.mount.entry") + .unwrap_or(false) + }) + .unwrap_or(false); + i += if next_is_entry { 2 } else { 1 }; + continue; + } + out.push_str(line); + out.push('\n'); + i += 1; + } + out + } + /// Start the container. pub fn start(&self) -> Result<(), String> { Self::run_status(self.lxc_command("lxc-start"), "lxc-start") @@ -358,6 +982,12 @@ impl LxcContainer { /// /// `timeout: Some(d)` kills the child if it runs longer than `d` and /// returns `Err("script timed out after {ms}ms")`. + /// + /// `marker: Some(token)` stamps the exec so its container-side processes + /// can be reaped later; see [`mint_exec_marker`]. The caller owns the + /// token because a timeout is not the only way an exec ends early -- a + /// signal kills this process outright, and the watchdog needs the same + /// token to reap on its way out. #[cfg(target_os = "linux")] pub fn attach_run( &self, @@ -366,6 +996,7 @@ impl LxcContainer { env: &[String], force_clear_env: bool, timeout: Option, + marker: Option<&str>, ) -> Result<(i32, String, String), String> { use mxc_pty::{run_with_pty, PtyOptions, PtyOutcome, Signal}; @@ -377,6 +1008,7 @@ impl LxcContainer { working_directory, command, force_clear_env, + marker, )); let options = PtyOptions { @@ -392,11 +1024,60 @@ impl LxcContainer { PtyOutcome::TimedOut => { let ms = timeout.map(|d| d.as_millis()).unwrap_or(0); + + // Killing lxc-attach ended the caller's view of the work, not + // the work. Reap before reporting, and if the reap fails say + // so: a bare timeout message would tell the caller the script + // stopped when it may still be running. A reap that succeeds + // is not a containment guarantee either — see + // `reap_marked_processes` for what escapes it. + if let Some(token) = marker { + if let Err(e) = self.reap_marked_processes(token) { + return Err(format!( + "script timed out after {}ms, and its processes could not be \ + reaped from the container, so they may still be running: {}", + ms, e + )); + } + } + Err(format!("script timed out after {}ms", ms)) } } } + /// Kill every process in the container whose environment carries `marker`. + /// + /// Called on timeout by [`attach_run`](Self::attach_run) and on a fatal + /// signal by the cleanup watchdog. Reaching into the container's PID + /// namespace requires a second attach; the alternative the issue raised — + /// stopping and restarting the container — would discard the ingress chain + /// that lives in its network namespace and the rest of the start-time + /// enforcement, so it trades an orphaned process for an unfiltered sandbox. + /// + /// The guarantee is exactly what the sentence above says and no more: this + /// reaps processes *carrying the marker*, not every descendant of the exec. + /// The marker is inherited across `fork`/`exec`, so it reaches descendants + /// at any depth — but the environment belongs to the workload, and a + /// workload that scrubs it (`env -i`, `env -u MXC_EXEC_ID`, an explicit + /// `unsetenv`) drops off the list and survives the reap. Returning `Ok(())` + /// means every *marked* process was killed, not that the container is quiet. + /// + /// **This is hygiene, not containment.** Contained code is untrusted, so a + /// handle the workload can erase is not a boundary against it — it only + /// cleans up after work that is not trying to escape, which is the case that + /// actually leaks into the next exec today. A boundary needs kernel-owned + /// membership the workload cannot leave (a per-exec PID namespace or + /// cgroup); #871 carries that design and the host-dependent questions it + /// has to answer first, because a containment mechanism that silently fails + /// is worse than one documented not to be one. + #[cfg(target_os = "linux")] + pub(crate) fn reap_marked_processes(&self, marker: &str) -> Result<(), String> { + let mut cmd = self.lxc_command("lxc-attach"); + cmd.args(build_reap_args(marker)); + Self::run_status(cmd, "lxc-attach (reap)") + } + /// Stub for the workspace-wide clippy lane that runs on Windows. #[cfg(not(target_os = "linux"))] pub fn attach_run( @@ -406,15 +1087,39 @@ impl LxcContainer { _env: &[String], _force_clear_env: bool, _timeout: Option, + _marker: Option<&str>, ) -> Result<(i32, String, String), String> { Err("LxcContainer::attach_run is only supported on Linux".to_string()) } /// Stop the container. + /// + /// Graceful: `lxc-stop` asks init to shut down and waits. That is right for + /// an explicit lifecycle stop, and wrong for every rollback -- see + /// [`kill`](Self::kill). pub fn stop(&self) -> Result<(), String> { Self::run_status(self.lxc_command("lxc-stop"), "lxc-stop") } + /// Stop the container immediately, without waiting for a graceful shutdown. + /// + /// `lxc-stop` on its own waits up to 60 s for init to respond, and on + /// distros running systemd as PID 1 in an unprivileged userns init never + /// cleanly responds to SIGPWR at all -- so the wait can be the full timeout + /// and the stop can still fail. + /// + /// That is merely slow when a caller asked to stop a sandbox. It is a hole + /// when a rollback is stopping a container *because its isolation is not in + /// force*: the container keeps running, and keeps accepting traffic, for as + /// long as the graceful stop takes. Rollback paths use this instead, so the + /// exposure ends now rather than after a shutdown negotiation the guest can + /// decline. + pub fn kill(&self) -> Result<(), String> { + let mut cmd = self.lxc_command("lxc-stop"); + cmd.arg("-k"); + Self::run_status(cmd, "lxc-stop -k") + } + /// Destroy the container (removes rootfs and config). /// /// `lxc-destroy -f` already force-stops a running container; we used to @@ -430,10 +1135,128 @@ impl LxcContainer { } /// Get the path to the container's config file. - fn config_file_path(&self) -> String { + pub(crate) fn config_file_path(&self) -> String { format!("{}/{}/config", self.lxc_path, self.name) } + /// What liblxc says this container's network interfaces are. + /// + /// Provision adopts an existing container as readily as it creates one, and + /// an adopted container can carry more network interfaces than the single + /// `lxc.net.0` MXC configures for itself. A caller that filters egress needs + /// to know that before it claims to have filtered anything. + /// + /// The question goes to liblxc rather than to the config file because + /// `lxc.include` can add interfaces the file never mentions, and resolving + /// includes here -- relative paths and directory globs both -- would mean + /// reimplementing liblxc's own resolution and getting it subtly wrong. + /// liblxc answers for a stopped container, so the answer is available while + /// there is still time to act on it before start. + /// + /// The count and the sole interface's type both come from a single + /// `lxc.net` read, so there is no longer a window between reading the count + /// and reading the interface. This is not atomic with respect to the start + /// itself -- the config could still change before liblxc reads it -- only + /// with respect to this pair of observations. + /// + /// A probe that cannot run is an error rather than an empty answer: no + /// evidence of an interface is not evidence of no interface. + pub fn configured_net_interfaces(&self) -> Result { + let values = interpret_config_values("lxc.net", &self.query_config_item("lxc.net")?); + let count = values.len(); + // The type only matters when there is exactly one interface; every + // other count is refused upstream without reference to it. + let sole_kind = if count == 1 { + values.into_iter().next() + } else { + None + }; + Ok(NetInterfaceConfig { count, sole_kind }) + } + + /// Install the `lxc.hook.start-host` hook that pins the container's + /// host-side veth to `target_veth`. + /// + /// The hook runs after liblxc has created the veth pair and attached it to + /// the bridge but before the container's init execs, so the deterministic + /// name is in place before anything in the container can transmit. The hook + /// key is container-global -- it carries no `lxc.net.` index -- so + /// enforcement no longer depends on which index the interface uses. + /// + /// The script is written fresh every time so it cannot go stale, and made + /// executable. The hook entry is appended only when an identical one is not + /// already present; the key is never cleared, because a container's own + /// config may declare start-host hooks that clearing would destroy. + pub fn ensure_veth_pin_hook(&self, target_veth: &str) -> Result<(), String> { + let script_path = format!("{}/{}/mxc-veth-pin.sh", self.lxc_path, self.name); + + std::fs::write(&script_path, VETH_PIN_SCRIPT) + .map_err(|e| format!("Failed to write veth pin hook script {script_path}: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("Failed to chmod veth pin hook script {script_path}: {e}"))?; + } + + if self.has_veth_pin_hook(target_veth)? { + return Ok(()); + } + self.set_config_item( + "lxc.hook.start-host", + &veth_pin_hook_command(&script_path, target_veth), + ) + } + + /// Whether this container's own config already pins its veth to + /// `target_veth`. + /// + /// A container carrying this hook has had its host-side veth renamed by the + /// time its init execs, because the hook runs before that and a nonzero + /// exit aborts the start. So the answer decides which name describes the + /// live interface: with the hook, the pinned name; without it, whatever + /// liblxc recorded when it created the pair. + /// + /// This asks the container rather than the host on purpose. Asking the host + /// whether an interface of the pinned name exists answers a different + /// question -- it cannot tell this container's interface from a stranger's + /// that happens to hold the name, and it turns a transient failure of the + /// probe into the wrong answer rather than into an error. + /// + /// It reuses the comparison `ensure_veth_pin_hook` writes with, so the + /// reader and the writer cannot drift apart. + pub fn has_veth_pin_hook(&self, target_veth: &str) -> Result { + let script_path = format!("{}/{}/mxc-veth-pin.sh", self.lxc_path, self.name); + let value = veth_pin_hook_command(&script_path, target_veth); + let existing = interpret_config_values( + "lxc.hook.start-host", + &self.query_config_item("lxc.hook.start-host")?, + ); + Ok(existing.iter().any(|present| present == &value)) + } + + /// Ask liblxc for one config key, with any `lxc.include` already resolved. + /// + /// A key liblxc does not hold is not a failure -- it reports that on stderr + /// and exits zero, leaving stdout empty -- so only a nonzero exit is treated + /// as one. + fn query_config_item(&self, key: &str) -> Result { + let output = self + .lxc_command("lxc-info") + .arg("-c") + .arg(key) + .output() + .map_err(|e| format!("failed to run lxc-info -c {key}: {e}"))?; + if !output.status.success() { + return Err(format!( + "lxc-info -c {} failed: {}", + key, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + /// Get the current system architecture string for LXC templates. fn current_arch() -> &'static str { #[cfg(target_arch = "x86_64")] @@ -459,6 +1282,101 @@ mod tests { None } + #[test] + fn an_atomic_config_rewrite_replaces_the_file_and_leaves_no_temporary() { + let dir = std::env::temp_dir().join(format!("mxc-atomic-write-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("test temp dir"); + let target = dir.join("config"); + let path = target.to_string_lossy().to_string(); + + std::fs::write(&target, "lxc.mount.entry = old\nlxc.mount.entry = stale\n") + .expect("seed the original config"); + LxcContainer::write_config_atomically(&path, "lxc.mount.entry = new\n") + .expect("rewrite must succeed"); + + assert_eq!( + std::fs::read_to_string(&target).expect("read the rewritten config"), + "lxc.mount.entry = new\n", + "the rewrite must fully replace the previous contents" + ); + + // The swap must not leave its sibling behind. liblxc reads the config + // directory, and a surviving *.mxc-tmp-* is a second copy of a policy + // that was meant to be replaced, not merely litter. + let leftovers: Vec = std::fs::read_dir(&dir) + .expect("list the config directory") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|name| name != "config") + .collect(); + assert!( + leftovers.is_empty(), + "a successful write must leave no temporary, found: {:?}", + leftovers + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_atomic_config_rewrite_reports_failure_without_touching_the_original() { + // A directory standing where the config should be makes both the + // temporary create and the rename fail. The original must survive an + // unwritable target rather than being truncated on the way to an error, + // which is the whole reason the write does not go in place. + let dir = std::env::temp_dir().join(format!("mxc-atomic-fail-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("config")).expect("test temp dir"); + + let err = LxcContainer::write_config_atomically( + &dir.join("config").to_string_lossy(), + "lxc.mount.entry = new\n", + ); + assert!(err.is_err(), "writing over a directory must report failure"); + assert!( + dir.join("config").is_dir(), + "the failed write must not have replaced the target" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn an_atomic_config_rewrite_keeps_the_operator_s_permissions() { + use std::os::unix::fs::PermissionsExt; + + // A rename swaps in a new inode, so without explicit restoration a + // hardened config silently relaxes to whatever the umask allows the + // first time a start rewrites it. An operator who set 0600 gets to keep + // it. + let dir = std::env::temp_dir().join(format!("mxc-atomic-mode-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("test temp dir"); + let target = dir.join("config"); + let path = target.to_string_lossy().to_string(); + + std::fs::write(&target, "lxc.mount.entry = old\n").expect("seed the original config"); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)) + .expect("harden the original config"); + + LxcContainer::write_config_atomically(&path, "lxc.mount.entry = new\n") + .expect("rewrite must succeed"); + + let mode = std::fs::metadata(&target) + .expect("stat the rewritten config") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o600, + "the rewrite must not widen the config's permissions" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn lxcpath_honors_lxc_path_env() { let p = resolve_lxcpath_with_env( @@ -588,6 +1506,334 @@ mod tests { assert!(!c.lxc_path().is_empty()); } + #[test] + fn one_interface_reports_one() { + assert_eq!( + interpret_config_values("lxc.net", "lxc.net = veth\n\n").len(), + 1 + ); + } + + #[test] + fn a_state_line_answers_whether_the_container_is_running() { + assert_eq!( + interpret_state_output("State: RUNNING\n"), + Ok(true) + ); + assert_eq!( + interpret_state_output("State: STOPPED\n"), + Ok(false) + ); + } + + #[test] + fn stripping_drops_mxc_mounts_and_keeps_baseline_ones() { + let config = concat!( + "lxc.uts.name = box\n", + "lxc.mount.entry = /srv /srv none bind 0 0\n", + "# mxc-managed-mount\n", + "lxc.mount.entry = /tmp/a a none bind,create=dir 0 0\n", + "lxc.rootfs.path = /var/lib/lxc/box/rootfs\n", + ); + let out = LxcContainer::strip_mxc_mount_entries(config); + assert!( + out.contains("/srv /srv none bind 0 0"), + "a baseline mount the operator placed must survive, got {out:?}" + ); + assert!( + !out.contains("/tmp/a"), + "MXC's own mount must go, got {out:?}" + ); + assert!(!out.contains("mxc-managed-mount"), "got {out:?}"); + assert!(out.contains("lxc.rootfs.path"), "got {out:?}"); + } + + #[test] + fn stripping_drops_a_marker_left_without_its_entry() { + // Configs written before the rewrite became atomic can hold a marker + // whose entry never landed. Left behind, those accumulate one stray + // comment per interrupted start. + let out = LxcContainer::strip_mxc_mount_entries("# mxc-managed-mount\nlxc.uts.name = b\n"); + assert_eq!(out, "lxc.uts.name = b\n"); + } + + #[test] + fn a_mount_set_replaces_the_previous_one_in_a_single_config() { + let dir = std::env::temp_dir().join(format!("mxc-mountset-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("box")).expect("test temp dir"); + let config = dir.join("box").join("config"); + std::fs::write( + &config, + "lxc.uts.name = box\n# mxc-managed-mount\nlxc.mount.entry = /old old none bind 0 0\n", + ) + .expect("seed config"); + + let container = LxcContainer::new("box", Some(&dir.to_string_lossy())); + container + .replace_mxc_mount_entries(&[ + "/new new none bind,create=dir 0 0".to_string(), + "/two two none bind,ro,create=dir 0 0".to_string(), + ]) + .expect("rewrite must succeed"); + + let after = std::fs::read_to_string(&config).expect("read back"); + assert!( + !after.contains("/old"), + "the previous run's mounts must not accumulate, got {after:?}" + ); + assert!(after.contains("/new new"), "got {after:?}"); + assert!(after.contains("/two two"), "got {after:?}"); + assert!(after.contains("lxc.uts.name = box"), "got {after:?}"); + assert_eq!( + after.matches("# mxc-managed-mount").count(), + 2, + "one marker per entry, got {after:?}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_empty_mount_set_clears_the_previous_one() { + let dir = std::env::temp_dir().join(format!("mxc-mountclear-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("box")).expect("test temp dir"); + let config = dir.join("box").join("config"); + std::fs::write( + &config, + "lxc.uts.name = box\n# mxc-managed-mount\nlxc.mount.entry = /old old none bind 0 0\n", + ) + .expect("seed config"); + + LxcContainer::new("box", Some(&dir.to_string_lossy())) + .replace_mxc_mount_entries(&[]) + .expect("an empty set is a valid policy"); + + let after = std::fs::read_to_string(&config).expect("read back"); + assert!(!after.contains("/old"), "got {after:?}"); + assert!(after.contains("lxc.uts.name = box"), "got {after:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_successful_probe_answers_defined_without_touching_the_filesystem() { + // The common path: `lxc-info` knows the container, so the config file + // never needs consulting. + assert_eq!( + interpret_defined_probe( + true, + "", + std::path::Path::new("/nonexistent/box/config"), + "b" + ), + Ok(true) + ); + } + + #[test] + fn a_failed_probe_with_no_config_file_means_absent() { + let dir = std::env::temp_dir().join(format!("mxc-defined-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let config = dir.join("no-such-container").join("config"); + assert_eq!( + interpret_defined_probe(false, "container not found", &config, "no-such-container"), + Ok(false) + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_failed_probe_with_a_config_file_present_is_unknown_rather_than_absent() { + // The fail-open this closes: a permission or transient error made + // deprovision skip `destroy()` and then authoritatively strip the + // firewall from a container that was still running. The config file + // proves the container is defined, so the honest answer is "unknown". + let dir = std::env::temp_dir().join(format!("mxc-defined-live-{}", std::process::id())); + let container = dir.join("box"); + std::fs::create_dir_all(&container).expect("temp dir"); + let config = container.join("config"); + std::fs::write(&config, "lxc.uts.name = box\n").expect("config file"); + + let answer = interpret_defined_probe(false, "permission denied", &config, "box"); + assert!( + answer.is_err(), + "a present config file must not read as absent, got {answer:?}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_frozen_container_is_live_not_stopped() { + // A frozen container still has processes; thawing resumes them. Reading + // it as stopped is what lets `stop` skip `lxc-stop`, tear the firewall + // down as authoritative, and leave a thawable container unfiltered. + assert_eq!(interpret_state_output("State: FROZEN\n"), Ok(true)); + assert_eq!( + interpret_state_output("State: FREEZING\n"), + Ok(true) + ); + assert_eq!(interpret_state_output("State: THAWED\n"), Ok(true)); + } + + #[test] + fn a_transitional_state_is_live_not_stopped() { + // STOPPED is the only state with nothing left to unfilter. Stopping a + // container that is already going down costs an idempotent `lxc-stop`; + // unfiltering one that is coming up costs the isolation. + assert_eq!( + interpret_state_output("State: STARTING\n"), + Ok(true) + ); + assert_eq!( + interpret_state_output("State: STOPPING\n"), + Ok(true) + ); + assert_eq!( + interpret_state_output("State: ABORTING\n"), + Ok(true) + ); + } + + #[test] + fn a_state_name_we_do_not_know_is_unknown_rather_than_stopped() { + // A state added by a future liblxc must not default into the answer + // that unfilters a container. + assert!(interpret_state_output("State: MARVELLOUS\n").is_err()); + } + + #[test] + fn output_that_names_no_state_is_unknown_rather_than_stopped() { + // The whole point of the three-way answer. Callers read "stopped" as + // safe to unfilter and safe to skip stopping, so inferring it from + // output we could not read is what turns a broken probe into an + // unfiltered running container. + assert!(interpret_state_output("").is_err()); + assert!(interpret_state_output("Name: box\n").is_err()); + } + + #[test] + fn an_unlabelled_running_is_still_read_as_running() { + // The negative control for the test above: unknown must not swallow a + // state we can plainly see. If the label ever changes, the failure has + // to land on the safe side -- a refused operation, never an unfiltered + // container. + assert_eq!(interpret_state_output("RUNNING\n"), Ok(true)); + } + + #[test] + fn every_interface_is_counted_so_a_caller_can_refuse_to_half_filter() { + // liblxc prints the first value inline and the rest bare. The count is + // what decides whether one FORWARD hook covers the container, so a + // second interface has to survive the parse -- including one that only + // an lxc.include declared, which is why the question goes to liblxc. + assert_eq!( + interpret_config_values("lxc.net", "lxc.net = veth\nveth\n\n").len(), + 2 + ); + } + + #[test] + fn a_container_with_no_network_reports_no_interfaces() { + assert_eq!(interpret_config_values("lxc.net", "lxc.net =\n").len(), 0); + } + + #[test] + fn a_netdev_of_type_empty_still_counts_as_an_interface() { + // A config that declares lxc.net.0 properties without a type does not + // leave the type undeclared -- liblxc supplies `empty`, a real netdev + // type that gives the container only a loopback. The absence signal is + // an empty value, so reading the word as absence would report no + // interface for a container that has one and refuse it for the wrong + // reason. + assert_eq!( + interpret_config_values("lxc.net", "lxc.net = empty\n").len(), + 1 + ); + } + + #[test] + fn trailing_blank_lines_are_not_counted_as_interfaces() { + // Counting them would report a second interface that does not exist and + // refuse a container MXC can fully filter. + assert_eq!( + interpret_config_values("lxc.net", "lxc.net = veth\n\n\n\n").len(), + 1 + ); + } + + #[test] + fn a_single_value_is_returned_intact() { + assert_eq!( + interpret_config_values("lxc.net", "lxc.net = veth\n"), + vec!["veth".to_string()] + ); + } + + #[test] + fn multiple_values_span_the_inline_and_continuation_lines() { + // liblxc prints the first value on the key line and the rest bare, so + // every value has to be recovered regardless of which line carries it. + assert_eq!( + interpret_config_values( + "lxc.hook.start-host", + "lxc.hook.start-host = /a/one.sh\n/a/two.sh\n" + ), + vec!["/a/one.sh".to_string(), "/a/two.sh".to_string()] + ); + } + + #[test] + fn an_empty_value_yields_no_values() { + assert!(interpret_config_values("lxc.net", "lxc.net =\n").is_empty()); + } + + #[test] + fn a_value_containing_an_equals_sign_is_returned_intact() { + // A hook command can carry `=` in an argument. Splitting on the first + // `=` anywhere in the line would truncate it; keying the strip on the + // config key preserves the whole value -- both on the inline line and + // on a bare continuation line. + assert_eq!( + interpret_config_values( + "lxc.hook.start-host", + "lxc.hook.start-host = /a/pin.sh --name=veth0\n/a/other.sh k=v\n" + ), + vec![ + "/a/pin.sh --name=veth0".to_string(), + "/a/other.sh k=v".to_string() + ] + ); + } + + #[test] + fn leading_and_trailing_blank_lines_are_ignored() { + assert_eq!( + interpret_config_values("lxc.net", "\n\nlxc.net = veth\n\n"), + vec!["veth".to_string()] + ); + } + + #[test] + fn the_pin_hook_command_passes_the_target_as_a_separate_argument() { + // The script reads the target from $1, so the value has to be the + // script path and the target separated by whitespace, not concatenated. + let cmd = veth_pin_hook_command("/var/lib/lxc/box/mxc-veth-pin.sh", "mxcveth-box"); + assert!(cmd.contains("/var/lib/lxc/box/mxc-veth-pin.sh"), "{cmd}"); + assert!(cmd.contains("mxcveth-box"), "{cmd}"); + let mut parts = cmd.split_whitespace(); + assert_eq!(parts.next(), Some("/var/lib/lxc/box/mxc-veth-pin.sh")); + assert_eq!(parts.next(), Some("mxcveth-box")); + } + + #[test] + fn an_unreadable_container_is_an_error_rather_than_an_empty_answer() { + // No evidence of an interface is not evidence of no interface. Answering + // "none" would send the caller down the zero-interface path and report a + // refusal reason that was never established. + let c = LxcContainer::new("definitely-not-provisioned", Some("/nonexistent-lxcpath")); + assert!(c.configured_net_interfaces().is_err()); + } + #[test] fn lxc_container_honors_explicit_lxc_path() { let c = LxcContainer::new("my-box", Some("/opt/lxc")); @@ -635,14 +1881,71 @@ mod tests { ); } - // ---- build_attach_args ---------------------------------------------- + #[test] + fn clear_config_item_removes_only_matching_key_lines() { + // Set up a real config file with two `lxc.mount.entry` lines (the + // list-type key that accumulates across restarts), a similarly-named + // key that must be preserved, and unrelated keys. + let base = std::env::temp_dir().join(format!( + "mxc-clear-cfg-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let name = "box"; + std::fs::create_dir_all(base.join(name)).unwrap(); + let container = LxcContainer::new(name, Some(base.to_str().unwrap())); + + let original = "lxc.arch = amd64\n\ + lxc.mount.entry = /host/a a none bind,create=dir 0 0\n\ + lxc.mount = /some/fstab\n\ + lxc.mount.entry = /host/b b none bind,ro,create=dir 0 0\n\ + lxc.uts.name = box\n"; + std::fs::write(container.config_file_path(), original).unwrap(); + + container.clear_config_item("lxc.mount.entry").unwrap(); + + let after = std::fs::read_to_string(container.config_file_path()).unwrap(); + assert!( + !after.contains("lxc.mount.entry"), + "all lxc.mount.entry lines must be removed, got:\n{after}" + ); + // The prefix-sharing `lxc.mount` key and unrelated keys survive. + assert!(after.contains("lxc.mount = /some/fstab")); + assert!(after.contains("lxc.arch = amd64")); + assert!(after.contains("lxc.uts.name = box")); + + // Re-clearing is idempotent. + container.clear_config_item("lxc.mount.entry").unwrap(); + let after2 = std::fs::read_to_string(container.config_file_path()).unwrap(); + assert_eq!(after, after2); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn clear_config_item_missing_file_is_ok() { + // A container whose config file does not exist is already "clear". + let bogus_base = std::env::temp_dir().join(format!( + "mxc-clear-missing-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let container = LxcContainer::new("ghost", Some(bogus_base.to_str().unwrap())); + assert!(container.clear_config_item("lxc.mount.entry").is_ok()); + } #[test] fn build_attach_args_no_env_no_cwd_is_unchanged_legacy_shape() { // Empty env + empty cwd must reproduce the original argv shape: // `-- /bin/sh -c ` so we don't perturb existing call sites // when neither cwd nor env is set. - let args = build_attach_args(&[], "", "echo hi"); + let args = build_attach_args(&[], "", "echo hi", None); assert_eq!(args, vec!["--", "/bin/sh", "-c", "echo hi"]); } @@ -653,7 +1956,7 @@ mod tests { "EMPTY=".to_string(), "HAS_EQ_IN_VAL=a=b=c".to_string(), ]; - let args = build_attach_args(&env, "", "cmd"); + let args = build_attach_args(&env, "", "cmd", None); assert_eq!( args, vec![ @@ -673,7 +1976,7 @@ mod tests { fn build_attach_args_env_entries_without_equals_are_skipped() { // Malformed entry can't poison the whole attach call. let env = vec!["BADENTRY".to_string(), "OK=val".to_string()]; - let args = build_attach_args(&env, "", "cmd"); + let args = build_attach_args(&env, "", "cmd", None); assert_eq!( args, vec![ @@ -698,7 +2001,7 @@ mod tests { "=val=more".to_string(), "OK=val".to_string(), ]; - let args = build_attach_args(&env, "", "cmd"); + let args = build_attach_args(&env, "", "cmd", None); assert_eq!( args, vec![ @@ -714,7 +2017,7 @@ mod tests { #[test] fn build_attach_args_cwd_wraps_command_with_cd_prelude() { - let args = build_attach_args(&[], "/opt/work", "echo hi"); + let args = build_attach_args(&[], "/opt/work", "echo hi", None); assert_eq!( args, vec![ @@ -736,7 +2039,7 @@ mod tests { // pass through sh as `$1` verbatim — no escaping needed here. let cwd = "/tmp/has spaces & 'quotes' $vars `cmd`"; let cmd = "printf '%s' \"$PWD\""; - let args = build_attach_args(&[], cwd, cmd); + let args = build_attach_args(&[], cwd, cmd, None); // cwd and command must appear verbatim as the last two argv entries. assert_eq!(args[args.len() - 2], cwd); @@ -750,7 +2053,7 @@ mod tests { #[test] fn build_attach_args_combines_env_and_cwd() { let env = vec!["FOO=bar".to_string()]; - let args = build_attach_args(&env, "/work", "cmd"); + let args = build_attach_args(&env, "/work", "cmd", None); assert_eq!( args, vec![ @@ -774,7 +2077,7 @@ mod tests { // also has to land BEFORE the `--set-var` entries so lxc-attach // clears first, then applies user vars on top. let env = vec!["FOO=bar".to_string()]; - let args = build_attach_args(&env, "", "cmd"); + let args = build_attach_args(&env, "", "cmd", None); let clear_idx = args .iter() .position(|a| a == "--clear-env") @@ -795,7 +2098,7 @@ mod tests { // Backward-compat guarantee: empty env preserves the legacy // keep-env shape so existing call sites with no explicit env are // undisturbed. - let args = build_attach_args(&[], "", "echo hi"); + let args = build_attach_args(&[], "", "echo hi", None); assert!( !args.iter().any(|a| a == "--clear-env"), "--clear-env must not appear when env is empty, got {:?}", @@ -805,7 +2108,7 @@ mod tests { #[test] fn build_attach_args_can_force_clear_env_when_env_empty() { - let args = build_attach_args_with_env_control(&[], "", "cmd", true); + let args = build_attach_args_with_env_control(&[], "", "cmd", true, None); assert_eq!(args, vec!["--clear-env", "--", "/bin/sh", "-c", "cmd"]); } @@ -816,7 +2119,7 @@ mod tests { // host env doesn't leak in through a back door. lxc-attach's own // baseline (HOME, PATH, USER, ...) keeps the child runnable. let env = vec!["BADENTRY".to_string(), "=alsobad".to_string()]; - let args = build_attach_args(&env, "", "cmd"); + let args = build_attach_args(&env, "", "cmd", None); assert_eq!(args, vec!["--clear-env", "--", "/bin/sh", "-c", "cmd"]); } @@ -831,7 +2134,7 @@ mod tests { // `MXC_TEST_FOO=HOST_LEAK_SHOULD_NOT_APPEAR` and asserts the // child sees the config's `MXC_TEST_FOO=bar baz`. let env = vec!["MXC_TEST_FOO=bar baz".to_string()]; - let args = build_attach_args(&env, "", "cmd"); + let args = build_attach_args(&env, "", "cmd", None); let clear_idx = args.iter().position(|a| a == "--clear-env").unwrap(); let set_idx = args .iter() @@ -844,6 +2147,18 @@ mod tests { ); } + #[test] + fn a_timed_exec_is_stamped_so_its_descendants_can_be_found() { + // The marker is the only handle a timeout has on work that outlived + // the attach. Without it the reap has nothing to match. + let args = build_attach_args(&[], "", "sleep 99", Some("tok123")); + assert!( + args.iter().any(|a| a == "--set-var=MXC_EXEC_ID=tok123"), + "timed exec must carry the marker, got {:?}", + args + ); + } + // ── End-to-end: proxy policy → env → attach args ───────────────────────── // These tests drive apply_proxy_env then build_attach_args_with_env_control // together so the observable output (the lxc-attach argv) is what is @@ -860,7 +2175,7 @@ mod tests { "PATH=/usr/bin".to_string(), ]; apply_proxy_env(&mut env, &ProxyConfig::default()); - let args = build_attach_args_with_env_control(&env, "", "cmd", true); + let args = build_attach_args_with_env_control(&env, "", "cmd", true, None); assert!( args.iter().any(|a| a == "--clear-env"), "the host environment must still be cleared; got {args:?}" @@ -876,6 +2191,217 @@ mod tests { ); } + #[test] + fn stamping_a_marker_does_not_clear_an_untouched_environment() { + // `--clear-env` is the caller's choice, keyed on the caller's env. + // Reaping must not smuggle in a wipe of the container's own + // environment as a side effect. + let args = build_attach_args(&[], "", "sleep 99", Some("tok123")); + assert!( + !args.iter().any(|a| a == "--clear-env"), + "marker must not pull in --clear-env, got {:?}", + args + ); + } + + #[test] + fn the_marker_is_applied_after_the_environment_is_cleared() { + // Same ordering rule as the caller's own vars: a marker set before + // `--clear-env` would be wiped, leaving a timed exec unreapable. + let env = vec!["FOO=bar".to_string()]; + let args = build_attach_args(&env, "", "cmd", Some("tok123")); + let clear_idx = args.iter().position(|a| a == "--clear-env").unwrap(); + let marker_idx = args + .iter() + .position(|a| a == "--set-var=MXC_EXEC_ID=tok123") + .expect("marker should be present"); + assert!( + clear_idx < marker_idx, + "--clear-env must precede the marker, got {:?}", + args + ); + } + + #[test] + fn a_caller_cannot_supply_its_own_marker() { + // A caller that sets the reserved name could otherwise be reaped by + // whichever exec owns that token, so the entry is dropped whether or + // not this exec carries a marker of its own. + let env = vec!["MXC_EXEC_ID=stolen".to_string(), "KEEP=yes".to_string()]; + + let timed = build_attach_args(&env, "", "cmd", Some("mine")); + assert!( + timed.iter().any(|a| a == "--set-var=KEEP=yes"), + "unrelated caller vars must survive, got {:?}", + timed + ); + assert!( + !timed.iter().any(|a| a == "--set-var=MXC_EXEC_ID=stolen"), + "the caller's marker must not reach the guest, got {:?}", + timed + ); + assert_eq!( + timed + .iter() + .filter(|a| a.starts_with("--set-var=MXC_EXEC_ID=")) + .count(), + 1, + "exactly one marker must survive, got {:?}", + timed + ); + + let untimed = build_attach_args(&env, "", "cmd", None); + assert!( + !untimed + .iter() + .any(|a| a.starts_with("--set-var=MXC_EXEC_ID=")), + "an untimed exec must carry no marker at all, got {:?}", + untimed + ); + } + + #[test] + fn an_untimed_exec_carries_no_marker() { + // Nothing can time out, so nothing needs reaping, and the argv stays + // exactly what it was before reaping existed. + let args = build_attach_args(&[], "", "echo hi", None); + assert!( + !args.iter().any(|a| a.contains("MXC_EXEC_ID")), + "untimed exec must not be stamped, got {:?}", + args + ); + } + + #[test] + fn the_reaper_matches_the_marker_of_exactly_one_exec() { + // Two concurrent execs in one container must not reap each other, so + // the argv has to carry the specific token and not just the var name. + let mine = build_reap_args("tok123"); + let theirs = build_reap_args("tok456"); + assert_eq!(mine.last().unwrap(), "MXC_EXEC_ID=tok123"); + assert_eq!(theirs.last().unwrap(), "MXC_EXEC_ID=tok456"); + assert_ne!(mine, theirs); + } + + #[test] + fn the_reaper_never_splices_the_marker_into_its_script() { + // The token reaches the shell as `$1`. Spliced in, a token bearing + // shell syntax would run as code inside the container. + let args = build_reap_args("t'; rm -rf /; #"); + let script = args + .iter() + .find(|a| a.contains("/proc/")) + .expect("reap script should be present"); + assert!( + !script.contains("rm -rf"), + "marker must not appear in the script body, got {:?}", + script + ); + assert!( + script.contains("\"$1\""), + "script must read the marker positionally, got {:?}", + script + ); + } + + #[test] + fn the_reaper_reports_success_when_nothing_matched() { + // `kill` finding no targets is the normal case for a script that had + // already finished; a nonzero exit there would be read as a failed + // reap and reported to the caller as possibly-still-running work. + let args = build_reap_args("tok123"); + let script = args.iter().find(|a| a.contains("/proc/")).unwrap(); + assert!( + script.contains("do rc=0;"), + "each pass must open clean, got {:?}", + script + ); + assert_eq!( + script.matches("rc=1").count(), + 1, + "only a marker match may raise the failure status, got {:?}", + script + ); + assert!( + script.contains("*\"$1\"*) rc=1"), + "the one thing that raises it must be the marker match, got {:?}", + script + ); + } + + #[test] + fn the_reaper_stops_a_process_before_it_kills_anything() { + // The `/proc` glob expands once per pass, so a workload that forks + // after the expansion has a child that pass never sees. Stopping first + // means a process that has been found cannot fork again, so the set + // that can still grow only shrinks. + let script = build_reap_args("tok123") + .into_iter() + .find(|a| a.contains("/proc/")) + .expect("reap script should be present"); + let stop = script.find("kill -STOP").expect("must stop what it finds"); + let kill = script.find("kill -KILL").expect("must then kill it"); + assert!( + stop < kill, + "the stop pass has to precede the kill pass, got {script:?}" + ); + } + + #[test] + fn the_reaper_rescans_until_it_finds_nothing_new() { + // One pass cannot see a process forked after its glob expanded, so a + // single pass leaves that child alive to outlive the exec. + let script = build_reap_args("tok123") + .into_iter() + .find(|a| a.contains("/proc/")) + .expect("reap script should be present"); + assert!( + script.contains("while ["), + "the scan must repeat, got {script:?}" + ); + assert!( + script.contains("break"), + "the scan must stop once a pass finds nothing new, got {script:?}" + ); + } + + #[test] + fn the_reaper_names_its_signals_rather_than_numbering_them() { + // SIGSTOP is 19 on x86 and ARM but 17 on Alpha and SPARC and 23 on + // MIPS. A number here would stop the wrong thing on those hosts. + let script = build_reap_args("tok123") + .into_iter() + .find(|a| a.contains("/proc/")) + .expect("reap script should be present"); + assert!(!script.contains("kill -9"), "got {script:?}"); + assert!(!script.contains("kill -19"), "got {script:?}"); + } + + #[test] + fn the_reaper_reports_a_process_that_outlived_the_kill() { + // A clean reap is what lets the caller report a plain timeout. A script + // that always exits zero makes that report true by construction, so a + // process SIGKILL could not remove is announced as reaped. + let script = build_reap_args("tok123") + .into_iter() + .find(|a| a.contains("/proc/")) + .expect("reap script should be present"); + assert!( + script.trim_end().ends_with("exit $rc"), + "the exit status must be the scan's, got {script:?}" + ); + let kill = script + .rfind("kill -KILL") + .expect("must kill what it stopped"); + let raise = script + .rfind("*\"$1\"*) rc=1") + .expect("a surviving marked process must raise the status"); + assert!( + kill < raise, + "the survivor scan has to run after the kill, got {script:?}" + ); + } + #[test] fn proxy_enabled_emits_clear_env_and_proxy_keys_in_attach_args() { use wxc_common::{ @@ -888,7 +2414,7 @@ mod tests { }; let mut env = vec!["PATH=/usr/bin".to_string()]; apply_proxy_env(&mut env, &proxy); - let args = build_attach_args_with_env_control(&env, "", "cmd", true); + let args = build_attach_args_with_env_control(&env, "", "cmd", true, None); assert!( args.iter().any(|a| a == "--clear-env"), "proxy enabled must emit --clear-env; got {args:?}" diff --git a/src/backends/lxc/common/src/lxc_runner.rs b/src/backends/lxc/common/src/lxc_runner.rs index c9b08b68e..9d90d762e 100644 --- a/src/backends/lxc/common/src/lxc_runner.rs +++ b/src/backends/lxc/common/src/lxc_runner.rs @@ -1,1547 +1,1922 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! `LxcScriptRunner` — executes scripts inside LXC containers. -//! -//! Implements the `ScriptRunner` trait for LXC-based containment on Linux. - -use std::fmt::Write; -use std::thread; -use std::time::{Duration, Instant}; - -use wxc_common::logger::Logger; -use wxc_common::models::{ExecutionRequest, LifecycleConfig, LxcConfig, ScriptResponse}; -use wxc_common::script_runner::ScriptRunner; -use wxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; - -use crate::filesystem_mounts; -use crate::lxc_bindings::LxcContainer; -use crate::network_ingress::IngressManager; -use crate::network_iptables::{installs_firewall, needs_network, NetworkIptablesManager}; -use crate::signal_cleanup; - -/// Comment marker on every `/etc/hosts` line this runner writes, so a later -/// run can strip its own previous entries without disturbing the -/// distribution's. -const HOSTS_PIN_MARKER: &str = "#mxc-proxy-pin"; - -/// Ceiling for the two `/etc/hosts` rewrites, which are a handful of shell -/// builtins and must never inherit the script's own timeout budget. -const HOSTS_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); - -/// Script runner that executes commands inside an LXC container. -pub struct LxcScriptRunner { - config: LxcConfig, - container_id: String, - destroy_on_exit: bool, - cleanup_policy: bool, -} - -impl LxcScriptRunner { - pub fn new(config: &LxcConfig, container_id: &str, lifecycle: &LifecycleConfig) -> Self { - Self { - config: config.clone(), - container_id: container_id.to_string(), - destroy_on_exit: lifecycle.destroy_on_exit, - cleanup_policy: !lifecycle.preserve_policy, - } - } - - /// Generate a container name if one wasn't provided. - fn resolve_container_name(&self) -> String { - if self.container_id.is_empty() { - format!("mxc-{}", uuid_simple()) - } else { - self.container_id.clone() - } - } - - /// Wait for the container's network stack to initialize. - /// Polls `lxc-info` until the container has an IP address or the timeout is reached. - fn wait_for_network(container_name: &str, timeout: Duration, logger: &mut Logger) -> bool { - let start = Instant::now(); - let poll_interval = Duration::from_millis(500); - - let _ = writeln!(logger, "Waiting for container network to initialize..."); - - while start.elapsed() < timeout { - let output = std::process::Command::new("lxc-info") - .arg("-n") - .arg(container_name) - .arg("-iH") - .output(); - - if let Ok(out) = output { - let stdout = String::from_utf8_lossy(&out.stdout); - let ip = stdout.trim(); - if !ip.is_empty() { - let _ = writeln!( - logger, - "Container network ready (IP: {}, waited {:.1}s)", - ip, - start.elapsed().as_secs_f64() - ); - return true; - } - } - - thread::sleep(poll_interval); - } - - let _ = writeln!( - logger, - "Warning: container network not ready after {:.1}s", - timeout.as_secs_f64() - ); - false - } - - fn enforce_network_readiness( - &self, - container_name: &str, - container_created: bool, - timeout: Duration, - logger: &mut Logger, - readiness_probe: P, - mut destroy_container: D, - ) -> Option - where - P: FnOnce(&str, Duration, &mut Logger) -> bool, - D: FnMut(), - { - if readiness_probe(container_name, timeout, logger) { - return None; - } - - if self.destroy_on_exit || container_created { - destroy_container(); - } - - Some(ScriptResponse::error(&format!( - "Container network did not initialize within {:.0}s; \ - check that lxc-net/dnsmasq is running and able to assign an IP.", - timeout.as_secs_f64() - ))) - } - - /// Core execution logic. - fn run_internal(&self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { - // Object-based FS-policy normalization (D6): tighten aliases of the same - // host object to the strictest intent (deny > ro > rw) before building - // mounts. See `wxc_common::filesystem_object`. Only clone the request - // when an aliasing conflict actually needs tightening; an unresolvable - // path with deniedPaths present fails closed. - let normalized; - let request = match wxc_common::filesystem_object::normalize_object_conflicts( - &request.policy, - logger, - ) { - Ok(Some(policy)) => { - normalized = ExecutionRequest { - policy, - ..request.clone() - }; - &normalized - } - Ok(None) => request, - Err(msg) => return ScriptResponse::error(&msg), - }; - // Delegation check (D3): reject any policy path the invoking user cannot - // access, so the sandbox never gains access the caller lacks. Runs AFTER - // object normalization so it is evaluated against the already-tightened - // intents. - if let Err(msg) = wxc_common::filesystem_access::check_delegation(&request.policy) { - return ScriptResponse::error(&msg); - } - - // Validate required LXC fields - if self.config.distribution.is_empty() || self.config.release.is_empty() { - return ScriptResponse::error( - "LXC distribution and release are required \ - (e.g., \"distribution\": \"alpine\", \"release\": \"3.23\")", - ); - } - - let container_name = self.resolve_container_name(); - // Refuse a credential-bearing proxy URL here as well as at parse time. - // The parser guard only covers requests it built; `ExecutionRequest` - // and `ProxyAddress::from_url` are public, so a caller can hand this - // runner a policy the parser never saw. Below, `apply_proxy_env` sets - // HTTP(S)_PROXY to `to_url()`, which returns the original URL verbatim, - // and `build_attach_args_with_env_control` turns every environment - // entry into a `--set-var=KEY=VALUE` argument of the `lxc-attach` - // process this backend spawns (lxc_bindings.rs). A process's argv is - // readable through /proc//cmdline by any local user for the - // lifetime of the command. The check sits ahead of container creation - // and firewall programming so a rejected request leaves no state - // behind. - if let Some(url) = request - .policy - .network_proxy - .address - .as_ref() - .map(|address| address.to_url()) - { - if wxc_common::proxy_env::proxy_url_has_credentials(&url) { - // Built from the redacted form so the rejection cannot become - // the leak it is rejecting. - return ScriptResponse::error(&format!( - "LXC: network.proxy.url must not carry credentials ('{}'). LXC passes the \ - proxy URL to lxc-attach as a --set-var command-line argument, and process \ - arguments are world-readable through /proc//cmdline, so the password \ - would be visible to every local user while the command runs. Use a proxy \ - that does not require inline credentials, or supply them to the proxy \ - itself rather than through the URL.", - wxc_common::proxy_env::redact_proxy_url(&url) - )); - } - } - // Make the name visible to the signal-cleanup watchdog so a fatal - // signal during create/start/attach still tears the container down — - // but only when the caller actually wants the container destroyed at - // exit. With `destroyOnExit = false` the normal completion path - // preserves the container, so the signal path must too. - if self.destroy_on_exit { - signal_cleanup::set_active(&container_name); - } - let _ = writeln!(logger, "Container name: {}", container_name); - let _ = writeln!( - logger, - "Distribution: {}:{}", - self.config.distribution, self.config.release - ); - - // Apply experimental features when flag is set - if request.experimental_enabled { - if let Some(ref test) = request.experimental.test { - let _ = writeln!( - logger, - "Experimental feature 'test' applied: {}", - test.message - ); - } - } - - // Create container handle - let container = LxcContainer::new(&container_name, None); - let mut container_created = false; - - // Create the container if it doesn't exist - if !container.is_defined() { - let _ = writeln!(logger, "Creating LXC container..."); - if let Err(e) = container.create(&self.config.distribution, &self.config.release) { - return ScriptResponse::error(&format!("Failed to create container: {}", e)); - } - let _ = writeln!(logger, "Container created successfully."); - container_created = true; - } else { - let _ = writeln!(logger, "Container already exists, reusing."); - } - - // Configure filesystem mounts - if let Err(e) = - filesystem_mounts::configure_filesystem_mounts(&container, &request.policy, logger) - { - if self.destroy_on_exit || container_created { - let _ = container.destroy(); - } - return ScriptResponse::error(&format!("Failed to configure filesystem: {}", e)); - } - - // Ensure the container is running so that the veth interface exists - if !container.is_running() { - let _ = writeln!(logger, "Starting LXC container..."); - if let Err(e) = container.start() { - if self.destroy_on_exit || container_created { - let _ = container.destroy(); - } - return ScriptResponse::error(&format!("Failed to start container: {}", e)); - } - let _ = writeln!(logger, "Container started successfully."); - } else { - let _ = writeln!(logger, "Container already running."); - } - - let uses_directional_schema = - wxc_common::supports_directional_network(&request.schema_version); - - let needs_network = needs_network(&request.policy, uses_directional_schema); - - if needs_network { - // Fail closed: proceeding without an IP silently breaks DNS and produces - // flaky failures. Alpine DHCP leases can arrive at ~9s, so allow 30s. - let timeout = Duration::from_secs(30); - if let Some(response) = self.enforce_network_readiness( - &container_name, - container_created, - timeout, - logger, - Self::wait_for_network, - || { - let _ = container.destroy(); - }, - ) { - return response; - } - } - - // Configure network rules - let mut fw_manager = NetworkIptablesManager::new(&container_name); - fw_manager.set_preserve_policy(!self.cleanup_policy); - fw_manager.set_directional_schema(uses_directional_schema); - - // Try to discover the container's veth interface for scoped rules - if let Some(veth) = NetworkIptablesManager::discover_veth_interface(&container_name) { - let _ = writeln!(logger, "Discovered veth interface: {}", veth); - fw_manager.set_veth_interface(&veth); - if self.destroy_on_exit { - // Tell the watchdog about the veth so signal-time cleanup - // can also remove the FORWARD hook, not just the chain. - signal_cleanup::set_active_veth(&veth); - } - } - - match fw_manager.apply_firewall_rules(&request.policy, logger) { - Ok(true) => {} - Ok(false) => { - if self.destroy_on_exit || container_created { - let _ = container.destroy(); - } - return ScriptResponse::error("Failed to apply network firewall rules."); - } - Err(e) => { - if self.destroy_on_exit || container_created { - let _ = container.destroy(); - } - return ScriptResponse::error(&format!("Network policy error: {}", e)); - } - } - - let use_firewall = installs_firewall(&request.policy, uses_directional_schema); - - // Kept in scope for post-execution cleanup; `None` when there is no - // netns PID and no firewall was requested (nothing to enforce). - let mut ingress_manager: Option = None; - - match container.init_pid() { - Some(pid) => { - let _ = writeln!(logger, "Container init PID: {}", pid); - if self.destroy_on_exit { - // Tell the watchdog about the netns PID so signal-time - // cleanup can remove the container's INPUT rules before it's - // destroyed. - signal_cleanup::set_active_pid(pid); - } - let mut mgr = IngressManager::new(&container_name, pid, uses_directional_schema); - match mgr.apply_firewall_rules(&request.policy, logger) { - Ok(true) => {} - Ok(false) => { - if self.destroy_on_exit || container_created { - let _ = container.destroy(); - } - return ScriptResponse::error( - "Failed to apply inbound network firewall rules.", - ); - } - Err(e) => { - if self.destroy_on_exit || container_created { - let _ = container.destroy(); - } - return ScriptResponse::error(&format!( - "Inbound network policy error: {}", - e - )); - } - } - // Every non-success arm above returns, so the apply call - // succeeded. Only now may the policy be marked for - // preservation: the flag also suppresses `Drop`, and a partial - // chain from a failed install must still be torn down. Success - // does not imply a chain exists — a non-firewall enforcement - // mode succeeds without installing one — but in that case no - // ownership flag is set and `Drop` has nothing to do either way. - mgr.set_preserve_policy(!self.cleanup_policy); - ingress_manager = Some(mgr); - } - None if use_firewall => { - // The run asked for a firewall but we could not find the - // container netns to enforce it in. There is no legitimate - // ingress-without-a-netns case: enforcing inbound requires - // entering the container's namespace, so running anyway would - // silently disable the requested inbound deny (a fail-open). - // Abort instead. This guard is specific to the LXC ingress - // path, which addresses its namespace by init PID; other - // backends reach their firewall handling through their own - // runners and never construct an `IngressManager`. - if self.destroy_on_exit || container_created { - let _ = container.destroy(); - } - return ScriptResponse::error( - "Failed to discover the container init PID; cannot enter the container \ - network namespace to enforce the requested inbound firewall. Aborting \ - rather than running with inbound enforcement silently disabled.", - ); - } - None => { - // No firewall requested and no netns PID: nothing to enforce - // inbound, so no ingress chain is installed. - } - } - - let mut pinned = false; - - // A proxied chain opens no port 53, so without this the container has - // no resolver to find its proxy with. - if let Some(pin) = fw_manager.proxy_host_pin() { - let command = Self::build_hosts_pin_command(&pin.hosts_line()); - let _ = writeln!( - logger, - "Pinning proxy host {} to {} in the container's /etc/hosts.", - pin.hostname(), - pin.ip() - ); - let pin_outcome = - container.attach_run(&command, "/", &[], true, Some(HOSTS_COMMAND_TIMEOUT)); - let pin_error = match pin_outcome { - Ok((0, _, _)) => None, - - // `attach_run` streams the child's output and returns empty - // strings, so the exit code is the whole report. - Ok((code, _, _)) => Some(Self::hosts_command_failure("writing", code)), - Err(e) => Some(e.to_string()), - }; - if let Some(reason) = pin_error { - if self.destroy_on_exit || container_created { - let _ = container.destroy(); - } else { - // A reused container this run will not destroy would - // otherwise be left running with a policy it cannot use. - let _ = container.stop(); - } - return ScriptResponse::error(&format!( - "Failed to pin the network proxy host inside the container: {}. \ - The proxy would be unreachable, so the script was not run.", - reason - )); - } - pinned = true; - } else if !container_created { - // A run that pins rewrites /etc/hosts from a filtered copy, so it - // disposes of an earlier pin on its way past. A run that pins - // nothing has no such side effect, and a container this run did not - // create can still be carrying one -- left by a run interrupted - // after pinning, or by a removal that failed and only warned. Left - // in place it keeps resolving a hostname to an address that only - // some earlier policy authorized. - let unpin = Self::build_hosts_unpin_command(); - let stale_pin_error = - match container.attach_run(&unpin, "/", &[], true, Some(HOSTS_COMMAND_TIMEOUT)) { - Ok((0, _, _)) => None, - Ok((code, _, _)) => Some(Self::hosts_command_failure("clearing", code)), - Err(e) => Some(e.to_string()), - }; - - // Unlike the post-run removal, this one runs *before* the script, so - // a failure still changes what the script would resolve. Refuse the - // run rather than execute against a mapping this policy never made. - if let Some(reason) = stale_pin_error { - if self.destroy_on_exit { - let _ = container.destroy(); - } else { - let _ = container.stop(); - } - return ScriptResponse::error(&format!( - "Failed to clear a stale network proxy pin from the container's \ - /etc/hosts: {}. The script was not run, because it could have resolved \ - the pinned hostname to an address this policy did not authorize.", - reason - )); - } - } - - // Execute the script using lxc-attach (container is already running). - // `script_timeout == 0` means "no timeout" per the SDK contract. - let timeout = if request.script_timeout == 0 { - None - } else { - Some(Duration::from_millis(u64::from(request.script_timeout))) - }; - let _ = writeln!(logger, "Executing script inside container..."); - let mut exec_env = request.env.clone(); - // Scrub every inherited proxy variable and, when the policy carries a - // proxy, point HTTP(S)_PROXY at it. - wxc_common::proxy_env::apply_proxy_env(&mut exec_env, &request.policy.network_proxy); - - // Always clear, including for an empty env: otherwise `lxc-attach` - // falls back to keep-env mode and inherits the MXC host process - // environment, proxy variables and credentials included. - let result = container.attach_run( - &request.script_code, - &request.working_directory, - &exec_env, - true, - timeout, - ); - - let response = match result { - Ok((exit_code, stdout, stderr)) => ScriptResponse { - exit_code, - standard_out: stdout, - standard_err: stderr, - error_message: String::new(), - ..Default::default() - }, - Err(e) => ScriptResponse::error(&format!("Execution failed: {}", e)), - }; - - // The pin names an address only this run's chain authorized, so it - // must not outlive that chain. - if pinned && self.cleanup_policy { - let unpin = Self::build_hosts_unpin_command(); - let unpin_error = - match container.attach_run(&unpin, "/", &[], true, Some(HOSTS_COMMAND_TIMEOUT)) { - Ok((0, _, _)) => None, - Ok((code, _, _)) => Some(Self::hosts_command_failure("clearing", code)), - Err(e) => Some(e.to_string()), - }; - - // The script has already run, so a failure here cannot change its - // result and must not replace it. - if let Some(reason) = unpin_error { - let _ = writeln!( - logger, - "Warning: failed to clear the proxy host pin: {}", - reason - ); - } - } - - // Cleanup: remove network rules - if fw_manager.rules_applied() && self.cleanup_policy { - let _ = fw_manager.remove_firewall_rules(logger); - } - if let Some(mgr) = &mut ingress_manager { - if mgr.rules_applied() && self.cleanup_policy { - let _ = mgr.remove_firewall_rules(logger); - } - } - - // Cleanup: destroy container if configured - if self.destroy_on_exit { - let _ = writeln!(logger, "Destroying container..."); - if let Err(e) = container.destroy() { - let _ = writeln!(logger, "Warning: failed to destroy container: {}", e); - } - } - - response - } - - /// Build the shell command that installs `hosts_line` into the - /// container's `/etc/hosts`. - /// - /// The first match wins in a hosts file, so an entry left by an earlier - /// run on a container reused across runs (`destroy_on_exit = false`) would - /// shadow the pin this run authorized. Marking each written line is what - /// lets a later run recognize its own without disturbing the ones the - /// distribution shipped. - /// - /// LXC may bind-mount `/etc/hosts`, so the file is rewritten in place: - /// replacing the inode would leave the container reading the old one. The - /// toolset is held to `grep` and `printf` because the image may be BusyBox - /// rather than coreutils. - /// - /// The kept lines are staged in a shell variable because a scratch file - /// has a name in the container's own filesystem and this command runs - /// privileged through `lxc-attach`. A previous workload on a reused - /// container can pre-create any predictable name as a symlink, and `>` - /// follows symlinks, so staging in a file aims a privileged truncating - /// write at whatever the link names -- another container file, or a host - /// path exposed through a writable bind mount. A variable has no name to - /// hijack. - /// - /// Staging must also finish before the redirect opens `/etc/hosts`, - /// because `>` truncates on open. Past that point nothing reads the - /// filesystem again: what remains works from text already in memory, so - /// no read failure can strand a file that has already been emptied. - /// - /// Single-quoting `hosts_line` is safe by construction: `ProxyHostPin` - /// builds it from a parsed [`std::net::IpAddr`] and a validated - /// hostname, neither of which can carry a quote or a newline. The one - /// space it does contain is the hosts-file separator, and single quotes - /// render that inert. - fn build_hosts_pin_command(hosts_line: &str) -> String { - // `$(...)` strips trailing newlines, so the kept text is re-emitted - // with an explicit one and the guard keeps an empty result from - // becoming a blank first line. The group's exit status is the final - // printf's, so a grep that matches nothing and exits 1 does not fail - // the command. - format!( - "{}{{ if [ -n \"$kept\" ]; then printf '%s\\n' \"$kept\"; fi; \ - printf '%s {marker}\\n' '{hosts_line}'; }} > /etc/hosts", - Self::hosts_read_prologue(), - marker = HOSTS_PIN_MARKER, - hosts_line = hosts_line - ) - } - - /// Read the existing `/etc/hosts` into `$kept`, or abort before anything - /// opens the file for writing. - /// - /// `> /etc/hosts` truncates the moment it is opened, so every reason the - /// read could fail has to be settled before that point. Were the status - /// left unexamined, the failure would run in the worst possible - /// direction: `$kept` would be empty whether `grep` is absent, - /// `/etc/hosts` is unreadable, or the binary is killed, and the closing - /// `printf` would still exit 0 -- a successful pin reported over a hosts - /// file emptied of every entry the image shipped. - /// - /// Only 0 (lines kept) and 1 (nothing kept) are outcomes rather than - /// failures. Status 1 is legitimate and common: an empty file, or a re-pin - /// where every existing line carries the marker. Anything above 1 is a - /// failed read, which also catches the `127` a missing `grep` produces. A - /// missing file is separated out ahead of the read because `grep` reports - /// both "absent" and "unreadable" as status 2, and an image that ships no - /// `/etc/hosts` has no content to protect. - /// - /// Two gaps survive this guard, and neither is closable while the command - /// is restricted to `grep` and `printf` for BusyBox: - /// - /// * A successful read still loses NUL bytes, because a shell variable - /// cannot hold them. A hosts file containing one is rewritten truncated - /// at that byte with status 0, and no check here observes it. A NUL in - /// `/etc/hosts` is malformed to begin with, and the alternatives -- a - /// scratch file, `sed`, `awk` -- each cost either a filesystem name an - /// attacker can hijack or a tool BusyBox may not ship. - /// - /// * A symlink swapped in between the `-h` test and the redirect is still - /// followed. Closing that race needs an open-once-and-rewrite primitive - /// -- `openat` with `O_NOFOLLOW` inside the container's mount namespace - /// -- which is a Rust-side change rather than a shell one. The `-h` test - /// covers the unraced shape: a workload reused across runs can simply - /// leave `/etc/hosts` as a symlink. A dangling one is the worst of - /// those, because it fails `-e` as well, and a redirect onto a dangling - /// link creates the link's target -- a write to an attacker-named path, - /// which on a writable host bind mount lands outside the container. - fn hosts_read_prologue() -> String { - format!( - "if [ -h /etc/hosts ]; then \ - printf 'mxc: refusing to rewrite /etc/hosts: it is a symbolic link\\n' >&2; \ - exit 4; \ - fi; \ - kept=''; \ - if [ -e /etc/hosts ]; then \ - kept=$(grep -v '{marker}' /etc/hosts 2>/dev/null); \ - status=$?; \ - if [ \"$status\" -gt 1 ]; then \ - printf 'mxc: refusing to rewrite /etc/hosts: reading it exited %s\\n' \ - \"$status\" >&2; \ - exit \"$status\"; \ - fi; \ - fi; ", - marker = HOSTS_PIN_MARKER - ) - } - - /// Strip every pin this runner has ever written from `/etc/hosts`. - /// - /// Only a run that pins something removes the previous pin on its way - /// past, so a run that pins nothing needs its own removal. Left in place - /// on a container kept alive across runs, a stale pin outlives the policy - /// that authorized it: the new policy resolves the hostname fresh to build - /// its rules, so a deny would be written against one address while the - /// container still reaches the other. - fn build_hosts_unpin_command() -> String { - format!( - "{}{{ if [ -n \"$kept\" ]; then printf '%s\\n' \"$kept\"; fi; }} > /etc/hosts", - Self::hosts_read_prologue() - ) - } - - /// Why a `/etc/hosts` command that ran to completion is being treated as a - /// failure, given the verb for what it was doing and the code it exited - /// with. - /// - /// The exit code is the entire report, and that is a property of how these - /// commands are run rather than a choice about brevity. `attach_run` - /// streams the child's output to the caller as it arrives and hands back - /// empty strings for both streams, so a reason that appended the returned - /// stderr would read `exited with 1: ` every single time -- a colon - /// offering an explanation, followed by nothing. Taking only the code - /// leaves the message true, and leaves the actual diagnostics where the - /// child already wrote them. - fn hosts_command_failure(verb: &str, exit_code: i32) -> String { - format!("{} /etc/hosts exited with {}", verb, exit_code) - } -} - -fn lxc_network_policy_support() -> NetworkPolicySupport { - NetworkPolicySupport::EGRESS_DEFAULT - | NetworkPolicySupport::EGRESS_RULES - | NetworkPolicySupport::INGRESS_DEFAULT - | NetworkPolicySupport::HOST_LOOPBACK -} - -impl ScriptRunner for LxcScriptRunner { - fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { - validate_network_policy_support(request, lxc_network_policy_support())?; - Ok(()) - } - - fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { - // Run with panic catching for safety - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - self.run_internal(request, logger) - })) { - Ok(r) => r, - Err(_) => ScriptResponse::error("Unknown error during LXC script execution."), - } - } -} - -/// Generate a simple 8-character hex ID (no uuid crate dependency needed). -fn uuid_simple() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let t = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - format!("{:08x}", (t & 0xFFFF_FFFF) as u32) -} - -#[cfg(test)] -mod tests { - use super::*; - use wxc_common::logger::Mode; - - #[test] - fn uuid_simple_is_8_chars() { - let id = uuid_simple(); - assert_eq!(id.len(), 8); - assert!(id.chars().all(|c| c.is_ascii_hexdigit())); - } - - #[test] - fn resolve_container_name_uses_config() { - let config = LxcConfig::default(); - let lifecycle = LifecycleConfig::default(); - let runner = LxcScriptRunner::new(&config, "my-test", &lifecycle); - assert_eq!(runner.resolve_container_name(), "my-test"); - } - - #[test] - fn resolve_container_name_generates_when_empty() { - let config = LxcConfig::default(); - let lifecycle = LifecycleConfig::default(); - let runner = LxcScriptRunner::new(&config, "", &lifecycle); - let name = runner.resolve_container_name(); - assert!(name.starts_with("mxc-")); - } - - // The pin is worthless if the mapping it was built from is not the one - // that lands in the file. - #[test] - fn the_hosts_pin_command_writes_the_requested_mapping() { - let command = LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"); - - assert!( - command.contains("'10.0.0.5 proxy.example.com'"), - "the command must carry the mapping verbatim, got: {command}" - ); - assert!( - command.contains("/etc/hosts"), - "the command must target /etc/hosts, got: {command}" - ); - } - - // A container reused across runs would otherwise accumulate entries, and - // the first match in a hosts file wins -- so a stale line would shadow the - // pin this run just authorized. - #[test] - fn the_hosts_pin_command_strips_its_own_previous_entries_first() { - let command = LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"); - - assert!( - command.contains(&format!("grep -v '{}'", HOSTS_PIN_MARKER)), - "the command must remove prior pins before writing; got: {command}" - ); - assert!( - command.matches(HOSTS_PIN_MARKER).count() >= 2, - "the written line must carry the marker that the strip looks for; got: {command}" - ); - } - - #[test] - fn a_failed_hosts_command_reports_a_reason_it_can_actually_supply() { - // `attach_run` streams the child's output live and returns empty - // strings for both streams, so the earlier form of this message - // appended an empty stderr and always read "exited with 1: " -- a - // colon offering an explanation, with nothing after it. Both verbs are - // checked because the pin and unpin paths build this independently. - for verb in ["writing", "clearing"] { - let reason = LxcScriptRunner::hosts_command_failure(verb, 1); - - assert!( - reason.contains('1'), - "the reason must name the exit code; got: {reason}" - ); - assert!( - !reason.trim_end().ends_with(':'), - "the reason must not trail a separator promising more; got: {reason:?}" - ); - assert_eq!( - reason.trim_end(), - reason, - "the reason must not trail whitespace where a stderr used to go; got: {reason:?}" - ); - } - } - - #[test] - fn a_failed_hosts_command_distinguishes_the_step_that_failed() { - // Pinning and unpinning fail for different reasons and are fixed - // differently, so a message that named neither would send whoever - // reads it to the wrong half of the path. - let pinning = LxcScriptRunner::hosts_command_failure("writing", 2); - let clearing = LxcScriptRunner::hosts_command_failure("clearing", 2); - - assert_ne!( - pinning, clearing, - "the two steps must not produce the same reason; got: {pinning:?}" - ); - } - - #[test] - fn the_hosts_unpin_command_removes_the_marker_without_writing_a_new_one() { - let command = LxcScriptRunner::build_hosts_unpin_command(); - - assert!( - command.contains(&format!("grep -v '{}'", HOSTS_PIN_MARKER)), - "the command must filter out every marked line; got: {command}" - ); - // Banning `printf` outright would fail here: re-emitting the *kept* - // lines needs one, on a command that writes no marked line. The - // marker count states the invariant directly -- the marker's one - // appearance is inside the filter, so no marked line can be written. - assert_eq!( - command.matches(HOSTS_PIN_MARKER).count(), - 1, - "the marker should appear only in the filter; got: {command}" - ); - } - - #[test] - fn the_hosts_unpin_command_rewrites_the_file_in_place_rather_than_replacing_it() { - // Same bind-mount reasoning as the pin: replacing the inode would leave - // the container still reading the file that carries the stale pin. - let command = LxcScriptRunner::build_hosts_unpin_command(); - - assert!( - command.contains("> /etc/hosts"), - "the command must rewrite the existing file; got: {command}" - ); - assert!( - !command.contains("mv "), - "the command must not replace the inode; got: {command}" - ); - } - - // LXC may bind-mount /etc/hosts. Replacing the inode with `mv` would leave - // the container reading the file it had before. - #[test] - fn the_hosts_pin_command_rewrites_the_file_in_place_rather_than_replacing_it() { - let command = LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"); - - assert!( - command.contains("> /etc/hosts"), - "the command must redirect into the existing file, got: {command}" - ); - assert!( - !command.contains("mv "), - "the command must not replace the inode, got: {command}" - ); - } - - // Everything the pin needs must exist in a minimal image; a container - // built on BusyBox has no coreutils to fall back on. - #[test] - fn the_hosts_pin_command_uses_only_busybox_available_tools() { - let command = LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"); - - for forbidden in ["sed ", "awk ", "tee ", "sponge "] { - assert!( - !command.contains(forbidden), - "the command must not depend on {forbidden:?}, got: {command}" - ); - } - } - - // `/tmp` is the container's, and this command runs privileged through - // `lxc-attach`. On a container reused across runs a previous workload can - // pre-create any predictable name there as a symlink, and `>` follows - // symlinks -- so a scratch file would let it aim a privileged truncating - // write at another container file or at a host path exposed through a - // writable bind mount. Neither command may stage anything in a directory - // the container can write. - #[test] - fn the_hosts_commands_stage_nothing_in_a_container_writable_directory() { - let commands = [ - LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"), - LxcScriptRunner::build_hosts_unpin_command(), - ]; - - for command in commands { - for scratch in ["/tmp/", "/var/tmp/", "/dev/shm/", "/run/"] { - assert!( - !command.contains(scratch), - "the command must not stage under {scratch:?}, got: {command}" - ); - } - assert_eq!( - command.matches("> /etc/hosts").count(), - 1, - "/etc/hosts must be the only redirect target; got: {command}" - ); - } - } - - // Staging in a variable is only an improvement if the content is complete - // before the target is truncated. `>` truncates as the redirect opens, so - // any command that still had to *produce* content after that point would - // leave the container with an empty /etc/hosts if it failed. - #[test] - fn the_hosts_commands_build_their_content_before_truncating_the_target() { - let commands = [ - LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"), - LxcScriptRunner::build_hosts_unpin_command(), - ]; - - for command in commands { - let capture = command.find("kept=$(").unwrap_or_else(|| { - panic!("the command must stage into a variable; got: {command}") - }); - let redirect = command - .find("> /etc/hosts") - .unwrap_or_else(|| panic!("the command must target /etc/hosts; got: {command}")); - - assert!( - capture < redirect, - "the content must be captured before /etc/hosts is truncated; got: {command}" - ); - assert!( - !command[redirect..].contains("grep"), - "no file-reading command may run after the target is truncated; got: {command}" - ); - } - } - - // The parser rejects a credential-bearing proxy URL, but `ExecutionRequest` - // and `ProxyAddress::from_url` are public: a caller can build a request the - // parser never saw and hand it straight to this runner. These tests take - // that path deliberately -- no parser anywhere in them -- because a guard - // that only exists on the parse path does not protect the process spawn. - use wxc_common::models::{ - NetworkEgressPolicy, NetworkIngressPolicy, ProxyAddress, ProxyConfig, - }; - - fn request_with_proxy_url(url: &str) -> ExecutionRequest { - let mut request = ExecutionRequest::default(); - request.policy.network_proxy = ProxyConfig { - address: Some(ProxyAddress::from_url( - url, - "proxy.example.com".to_string(), - 8080, - )), - builtin_test_server: false, - }; - request - } - - fn runner_for_guard_tests() -> LxcScriptRunner { - let config = LxcConfig { - distribution: "alpine".to_string(), - release: "3.23".to_string(), - }; - LxcScriptRunner::new(&config, "mxc-guard-test", &LifecycleConfig::default()) - } - - fn runner_for_network_readiness_tests(destroy_on_exit: bool) -> LxcScriptRunner { - let config = LxcConfig { - distribution: "alpine".to_string(), - release: "3.23".to_string(), - }; - let lifecycle = LifecycleConfig { - destroy_on_exit, - ..LifecycleConfig::default() - }; - LxcScriptRunner::new(&config, "mxc-network-test", &lifecycle) - } - - fn fail_network_readiness( - runner: &LxcScriptRunner, - container_created: bool, - ) -> (ScriptResponse, bool) { - let mut logger = Logger::new(Mode::Buffer); - let mut destroyed = false; - let response = runner - .enforce_network_readiness( - "mxc-network-test", - container_created, - Duration::from_secs(1), - &mut logger, - |_name, _timeout, _logger| false, - || destroyed = true, - ) - .expect("a failing readiness probe should return an error response"); - (response, destroyed) - } - - /// What the 0.8 parser produces for a config stating only `network.egress`: - /// the egress section as written, plus an ingress section filled in from - /// its defaults. - fn egress_only_directional_request() -> ExecutionRequest { - let mut request = ExecutionRequest::default(); - request.policy.network_mode_specified = true; - request.policy.network_egress = Some(NetworkEgressPolicy::default()); - request.policy.network_ingress = Some(NetworkIngressPolicy::default()); - request - } - - #[test] - fn an_egress_only_directional_config_passes_validation() { - let runner = runner_for_guard_tests(); - - assert!( - runner - .validate_runner(&egress_only_directional_request()) - .is_ok(), - "a 0.8 config stating only network.egress must reach the backend; rejecting it \ - here would make every directional egress policy unusable on LXC" - ); - } - - // Pins the directional claim as all-or-nothing: dropping to the two egress - // bits alone would reject the config above. - #[test] - fn claiming_only_the_egress_bits_would_reject_an_egress_only_config() { - let egress_bits_only = - NetworkPolicySupport::EGRESS_DEFAULT | NetworkPolicySupport::EGRESS_RULES; - - assert!( - validate_network_policy_support(&egress_only_directional_request(), egress_bits_only) - .is_err(), - "expected the two-bit claim to reject an egress-only config; if this now passes, \ - the directional posture is no longer all-or-nothing and lxc_network_policy_support \ - can drop the ingress bits" - ); - } - - #[test] - fn a_directly_built_request_with_proxy_credentials_is_refused() { - let runner = runner_for_guard_tests(); - let request = request_with_proxy_url("http://alice:hunter2@proxy.example.com:8080"); - let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); - - let response = runner.run_internal(&request, &mut logger); - - assert!( - response - .error_message - .contains("must not carry credentials"), - "the runner must refuse a credential-bearing proxy URL even when the parser \ - never saw the request, got: {}", - response.error_message - ); - } - - // The rejection is built from the redacted URL so the guard cannot become - // the leak it exists to prevent -- the message travels to logs and to the - // caller. - #[test] - fn the_runner_refusal_does_not_echo_the_password() { - let runner = runner_for_guard_tests(); - let request = request_with_proxy_url("http://alice:hunter2@proxy.example.com:8080"); - let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); - - let response = runner.run_internal(&request, &mut logger); - - assert!( - !response.error_message.contains("hunter2"), - "the password leaked into the refusal: {}", - response.error_message - ); - assert!( - !response.error_message.contains("alice:hunter2"), - "the userinfo leaked into the refusal: {}", - response.error_message - ); - assert!( - !logger.get_buffer().contains("hunter2"), - "the password leaked into the log buffer" - ); - } - - // Anti-vacuity: without this, a guard that refused every proxy would pass - // both tests above while breaking every legitimate proxy configuration. - // The run cannot succeed here (there is no live container), so the - // assertion is that it does not fail *for this reason*. - #[test] - fn a_credential_free_proxy_url_is_not_refused_by_the_credential_guard() { - let runner = runner_for_guard_tests(); - let request = request_with_proxy_url("http://proxy.example.com:8080"); - let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); - - let response = runner.run_internal(&request, &mut logger); - - assert!( - !response - .error_message - .contains("must not carry credentials"), - "a proxy URL without userinfo must clear the credential guard, got: {}", - response.error_message - ); - } - - // The guard runs ahead of container creation and firewall programming, so a - // rejected request leaves nothing to clean up. A container name in the log - // would mean the runner had already started announcing work it must not do. - #[test] - fn the_credential_refusal_happens_before_any_container_work() { - let runner = runner_for_guard_tests(); - let request = request_with_proxy_url("http://alice:hunter2@proxy.example.com:8080"); - let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); - - let _ = runner.run_internal(&request, &mut logger); - - let log = logger.get_buffer(); - assert!( - !log.contains("Container name:"), - "the guard must return before the runner starts container work, log was: {log}" - ); - assert!( - !log.contains("Creating LXC container"), - "the guard must return before container creation, log was: {log}" - ); - } - - #[test] - fn network_readiness_timeout_returns_the_fail_closed_error_response() { - let runner = runner_for_network_readiness_tests(false); - let (response, _) = fail_network_readiness(&runner, false); - - assert!( - response - .error_message - .contains("Container network did not initialize within 1s"), - "the fail-closed readiness timeout message changed unexpectedly: {}", - response.error_message - ); - assert_eq!( - response.standard_err, response.error_message, - "error responses should carry the message in stderr as well" - ); - } - - #[test] - fn network_readiness_timeout_preserves_a_reused_container_when_destroy_on_exit_is_false() { - let runner = runner_for_network_readiness_tests(false); - let (_, destroyed) = fail_network_readiness(&runner, false); - - assert!( - !destroyed, - "a reused container should be preserved on readiness timeout when destroyOnExit=false" - ); - } - - #[test] - fn network_readiness_timeout_destroys_newly_created_container_even_when_destroy_on_exit_is_false( - ) { - let runner = runner_for_network_readiness_tests(false); - let (_, destroyed) = fail_network_readiness(&runner, true); - - assert!( - destroyed, - "a newly created container must be destroyed on readiness timeout even when destroyOnExit=false" - ); - } - - #[test] - fn network_readiness_timeout_destroys_a_reused_container_when_destroy_on_exit_is_true() { - let runner = runner_for_network_readiness_tests(true); - let (_, destroyed) = fail_network_readiness(&runner, false); - - assert!( - destroyed, - "a reused container must be destroyed on readiness timeout when destroyOnExit=true" - ); - } -} - -/// The generated hosts commands, executed rather than pattern-matched. -/// -/// Every hosts test in `tests` above asserts on the command *string*. No -/// string assertion can separate a command that preserves `/etc/hosts` from -/// one that empties it -- both contain `> /etc/hosts`, and the truncation -/// defect these tests exist to pin was invisible to all six of them. Running -/// the command under a real `/bin/sh` is what makes the difference -/// observable. -#[cfg(all(test, unix))] -mod hosts_command_execution { - use super::*; - use std::path::{Path, PathBuf}; - - /// What a container image ships before anything pins a proxy. - const ORIGINAL: &str = "127.0.0.1 localhost\n::1 ip6-localhost\n10.0.0.9 build.internal\n"; - - const PIN_LINE: &str = "10.0.0.5 proxy.example.com"; - - /// A private directory that removes itself, so a failing test cannot leave - /// a hosts fixture behind for the next run to find. - struct Scratch { - dir: PathBuf, - } - - impl Scratch { - fn new(tag: &str) -> Self { - let dir = std::env::temp_dir().join(format!( - "mxc-hosts-{}-{}-{}", - tag, - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("the system clock should be after the unix epoch") - .as_nanos() - )); - std::fs::create_dir_all(&dir).expect("the scratch directory should be creatable"); - Self { dir } - } - - fn hosts(&self) -> PathBuf { - self.dir.join("hosts") - } - - fn write_hosts(&self, contents: &str) { - std::fs::write(self.hosts(), contents).expect("the fixture should be writable"); - } - - fn read_hosts(&self) -> String { - std::fs::read_to_string(self.hosts()).expect("the fixture should be readable") - } - - /// A `PATH` carrying a `grep` that fails with `status`, so the read - /// can be broken without breaking the shell around it: `printf` and - /// `[` are builtins and survive the override. - fn path_with_failing_grep(&self, status: i32) -> String { - use std::os::unix::fs::PermissionsExt; - - let bin = self.dir.join("bin"); - std::fs::create_dir_all(&bin).expect("the shim directory should be creatable"); - let grep = bin.join("grep"); - std::fs::write(&grep, format!("#!/bin/sh\nexit {status}\n")) - .expect("the shim should be writable"); - std::fs::set_permissions(&grep, std::fs::Permissions::from_mode(0o755)) - .expect("the shim should be executable"); - - format!( - "{}:{}", - bin.display(), - std::env::var("PATH").unwrap_or_default() - ) - } - - /// A `PATH` with no `grep` on it at all, which is how a BusyBox image - /// missing the applet fails: the shell cannot find the binary and - /// reports 127. - fn path_without_grep(&self) -> String { - let empty = self.dir.join("empty"); - std::fs::create_dir_all(&empty).expect("the empty directory should be creatable"); - empty.display().to_string() - } - } - - impl Drop for Scratch { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.dir); - } - } - - /// Point a generated command at a scratch file, so what runs is the - /// generated text with only the path substituted. That the real target is - /// `/etc/hosts` is not left unpinned by the substitution; the string tests - /// above pin it. - fn retarget(command: &str, hosts: &Path) -> String { - command.replace( - "/etc/hosts", - hosts.to_str().expect("the scratch path should be utf-8"), - ) - } - - fn run(command: &str, path: Option<&str>) -> i32 { - let mut shell = std::process::Command::new("/bin/sh"); - shell.arg("-c").arg(command); - if let Some(path) = path { - shell.env("PATH", path); - } - shell - .output() - .expect("/bin/sh should be executable") - .status - .code() - .expect("the shell should exit rather than be signalled") - } - - fn pin(hosts: &Path) -> String { - retarget(&LxcScriptRunner::build_hosts_pin_command(PIN_LINE), hosts) - } - - fn unpin(hosts: &Path) -> String { - retarget(&LxcScriptRunner::build_hosts_unpin_command(), hosts) - } - - #[test] - fn pinning_adds_the_mapping_and_keeps_every_line_the_image_shipped() { - let scratch = Scratch::new("keeps"); - scratch.write_hosts(ORIGINAL); - - let code = run(&pin(&scratch.hosts()), None); - let after = scratch.read_hosts(); - - assert_eq!(code, 0, "pinning a readable file should succeed"); - for line in ORIGINAL.lines() { - assert!( - after.contains(line), - "the pin dropped {line:?}; file is now:\n{after}" - ); - } - assert!( - after.contains(&format!("{PIN_LINE} {HOSTS_PIN_MARKER}")), - "the pin never landed; file is now:\n{after}" - ); - } - - // The first match in a hosts file wins, so a pin left over from a previous - // run on a reused container would shadow the one this run authorized. - #[test] - fn re_pinning_replaces_the_previous_entry_instead_of_stacking_on_it() { - let scratch = Scratch::new("repin"); - scratch.write_hosts(ORIGINAL); - - assert_eq!(run(&pin(&scratch.hosts()), None), 0); - assert_eq!(run(&pin(&scratch.hosts()), None), 0); - let after = scratch.read_hosts(); - - assert_eq!( - after.matches(HOSTS_PIN_MARKER).count(), - 1, - "a second pin should replace the first, not stack; file is now:\n{after}" - ); - assert!( - after.contains("10.0.0.9 build.internal"), - "re-pinning dropped an unrelated entry; file is now:\n{after}" - ); - } - - // The defect this module was written for. `> /etc/hosts` truncates the - // instant it is opened, so a read that failed has to stop the command - // before the redirect -- not merely produce nothing to write back. - #[test] - fn a_failed_read_leaves_the_file_byte_for_byte_as_it_was() { - let scratch = Scratch::new("failread"); - scratch.write_hosts(ORIGINAL); - let path = scratch.path_with_failing_grep(2); - - let code = run(&pin(&scratch.hosts()), Some(&path)); - - assert_eq!( - scratch.read_hosts(), - ORIGINAL, - "a failed read truncated the file it could not read" - ); - assert_ne!( - code, 0, - "a failed read must fail the command, not report a pin it never made" - ); - } - - // A missing `grep` is status 127, not 2, and is the likelier failure on a - // stripped image -- the same class, reached by a different route. - #[test] - fn a_missing_grep_leaves_the_file_byte_for_byte_as_it_was() { - let scratch = Scratch::new("nogrep"); - scratch.write_hosts(ORIGINAL); - let path = scratch.path_without_grep(); - - let code = run(&pin(&scratch.hosts()), Some(&path)); - - assert_eq!( - scratch.read_hosts(), - ORIGINAL, - "a missing grep truncated the file" - ); - assert_ne!(code, 0, "a missing grep must fail the command"); - } - - // Unpinning writes back only what it read, so a failed read there empties - // the file outright rather than reducing it to one line. - #[test] - fn a_failed_read_while_unpinning_leaves_the_file_byte_for_byte_as_it_was() { - let scratch = Scratch::new("failunpin"); - scratch.write_hosts(ORIGINAL); - let path = scratch.path_with_failing_grep(2); - - let code = run(&unpin(&scratch.hosts()), Some(&path)); - - assert_eq!( - scratch.read_hosts(), - ORIGINAL, - "a failed read emptied the file it could not read" - ); - assert_ne!(code, 0, "a failed read must fail the unpin"); - } - - // Status 1 means grep selected nothing, which is an outcome and not a - // failure: an empty file, or a re-pin where every line carried the marker. - // Treating it as an error would make the guard reject the ordinary case. - #[test] - fn a_file_of_nothing_but_previous_pins_is_rewritten_rather_than_refused() { - let scratch = Scratch::new("allmarked"); - scratch.write_hosts(&format!("10.0.0.4 proxy.example.com {HOSTS_PIN_MARKER}\n")); - - let code = run(&pin(&scratch.hosts()), None); - let after = scratch.read_hosts(); - - assert_eq!( - code, 0, - "a file of only stale pins should still be pinnable" - ); - assert_eq!( - after.trim(), - format!("{PIN_LINE} {HOSTS_PIN_MARKER}"), - "the stale pin should be gone and the new one present" - ); - } - - // An image that ships no hosts file has no content to protect, and grep - // cannot tell "absent" from "unreadable" -- both are status 2. The - // existence check is what keeps the guard from refusing to pin here. - #[test] - fn an_image_with_no_hosts_file_is_pinned_rather_than_refused() { - let scratch = Scratch::new("nofile"); - - let code = run(&pin(&scratch.hosts()), None); - - assert_eq!( - code, 0, - "a missing hosts file should be created, not refused" - ); - assert_eq!( - scratch.read_hosts().trim(), - format!("{PIN_LINE} {HOSTS_PIN_MARKER}") - ); - } - - #[test] - fn unpinning_removes_the_pin_and_keeps_everything_else() { - let scratch = Scratch::new("unpin"); - scratch.write_hosts(ORIGINAL); - assert_eq!(run(&pin(&scratch.hosts()), None), 0); - - let code = run(&unpin(&scratch.hosts()), None); - let after = scratch.read_hosts(); - - assert_eq!(code, 0, "unpinning a readable file should succeed"); - assert!( - !after.contains(HOSTS_PIN_MARKER), - "the pin survived the unpin; file is now:\n{after}" - ); - for line in ORIGINAL.lines() { - assert!( - after.contains(line), - "the unpin dropped {line:?}; file is now:\n{after}" - ); - } - } - // A dangling symlink was the worst shape the guard did not cover: `-e` is - // false, so no read happened, and the redirect then *created* the target. - // On a writable host bind mount that is a write outside the container, at - // a path the workload chose. - #[test] - fn pinning_refuses_a_dangling_symlink_instead_of_creating_its_target() { - let scratch = Scratch::new("dangling"); - let target = scratch.dir.join("attacker-named"); - std::os::unix::fs::symlink(&target, scratch.hosts()) - .expect("the scratch symlink should be creatable"); - - let code = run(&pin(&scratch.hosts()), None); - - assert_ne!(code, 0, "writing through a symlink should be refused"); - assert!( - !target.exists(), - "the refused pin still created {}", - target.display() - ); - } - - // The non-dangling case is the same write to somewhere the workload chose, - // it just does not announce itself by leaving a broken link behind. - #[test] - fn pinning_refuses_a_symlink_rather_than_writing_through_it() { - let scratch = Scratch::new("symlink"); - let target = scratch.dir.join("elsewhere"); - std::fs::write(&target, ORIGINAL).expect("the target should be writable"); - std::os::unix::fs::symlink(&target, scratch.hosts()) - .expect("the scratch symlink should be creatable"); - - let code = run(&pin(&scratch.hosts()), None); - - assert_ne!(code, 0, "writing through a symlink should be refused"); - assert_eq!( - std::fs::read_to_string(&target).expect("the target should still be readable"), - ORIGINAL, - "the refused pin still rewrote the symlink target" - ); - } - - // Unpinning takes the same prologue, so it has to refuse on the same terms - // -- and it is the more destructive of the two, since it writes back only - // what it read. - #[test] - fn unpinning_refuses_a_symlink_rather_than_emptying_its_target() { - let scratch = Scratch::new("unpinsymlink"); - let target = scratch.dir.join("elsewhere"); - std::fs::write(&target, ORIGINAL).expect("the target should be writable"); - std::os::unix::fs::symlink(&target, scratch.hosts()) - .expect("the scratch symlink should be creatable"); - - let code = run(&unpin(&scratch.hosts()), None); - - assert_ne!(code, 0, "writing through a symlink should be refused"); - assert_eq!( - std::fs::read_to_string(&target).expect("the target should still be readable"), - ORIGINAL, - "the refused unpin still emptied the symlink target" - ); - } - - // The refusal has to be legible in the container's stderr, or an operator - // sees only a non-zero exit from a destroyed container. - #[test] - fn the_symlink_refusal_says_why() { - let scratch = Scratch::new("symlinkmsg"); - let target = scratch.dir.join("elsewhere"); - std::os::unix::fs::symlink(&target, scratch.hosts()) - .expect("the scratch symlink should be creatable"); - - let output = std::process::Command::new("/bin/sh") - .arg("-c") - .arg(pin(&scratch.hosts())) - .output() - .expect("/bin/sh should be executable"); - - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("symbolic link"), - "the refusal named no reason; stderr was:\n{stderr}" - ); - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `LxcScriptRunner` — executes scripts inside LXC containers. +//! +//! Implements the `ScriptRunner` trait for LXC-based containment on Linux. + +use std::fmt::Write; +use std::thread; +use std::time::{Duration, Instant}; + +use wxc_common::logger::Logger; +use wxc_common::models::{ExecutionRequest, LifecycleConfig, LxcConfig, ScriptResponse}; +use wxc_common::script_runner::ScriptRunner; +use wxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; + +use crate::filesystem_mounts; +use crate::lxc_bindings::{LxcContainer, NetInterfaceConfig}; +use crate::network_ingress::IngressManager; +use crate::network_iptables::{installs_firewall, needs_network, NetworkIptablesManager}; +use crate::signal_cleanup; + +/// Comment marker on every `/etc/hosts` line this runner writes, so a later +/// run can strip its own previous entries without disturbing the +/// distribution's. +const HOSTS_PIN_MARKER: &str = "#mxc-proxy-pin"; + +/// Ceiling for the two `/etc/hosts` rewrites, which are a handful of shell +/// builtins and must never inherit the script's own timeout budget. +const HOSTS_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); + +/// Script runner that executes commands inside an LXC container. +pub struct LxcScriptRunner { + config: LxcConfig, + container_id: String, + destroy_on_exit: bool, + cleanup_policy: bool, +} + +impl LxcScriptRunner { + pub fn new(config: &LxcConfig, container_id: &str, lifecycle: &LifecycleConfig) -> Self { + Self { + config: config.clone(), + container_id: container_id.to_string(), + destroy_on_exit: lifecycle.destroy_on_exit, + cleanup_policy: !lifecycle.preserve_policy, + } + } + + /// Generate a container name if one wasn't provided. + fn resolve_container_name(&self) -> String { + if self.container_id.is_empty() { + format!("mxc-{}", uuid_simple()) + } else { + self.container_id.clone() + } + } + + /// Whether the pin hook can rename this container's interface before start. + /// + /// A nonzero exit from the hook aborts the start, so a shape it would + /// reject keeps the post-start path rather than becoming a new refusal. + fn pinnable_before_start(net: &NetInterfaceConfig) -> bool { + net.count == 1 && net.sole_kind.as_deref() == Some("veth") + } + + /// Halt a container after a failure that happened once it was already + /// running, retaining its filtering if it could not be halted. + /// + /// A run that will not destroy the container -- `destroy_on_exit` off on + /// one it did not create -- otherwise returns while the container keeps + /// running, and the managers then drop their rules on the way out. The + /// result is a live container with its egress filtering removed, reported + /// to the caller as a failure. Preserving the policy when the halt fails + /// keeps the rules in place for whatever is still transmitting. + fn halt_after_failed_start( + &self, + container: &LxcContainer, + container_created: bool, + fw_manager: &mut NetworkIptablesManager, + ingress: Option<&mut IngressManager>, + ) { + let halted = if self.destroy_on_exit || container_created { + container.destroy() + } else { + container.stop() + }; + if halted.is_err() { + fw_manager.set_preserve_policy(true); + if let Some(ingress) = ingress { + ingress.set_preserve_policy(true); + } + } + } + + /// Wait for the container's network stack to initialize. + /// Polls `lxc-info` until the container has an IP address or the timeout is reached. + fn wait_for_network(container_name: &str, timeout: Duration, logger: &mut Logger) -> bool { + let start = Instant::now(); + let poll_interval = Duration::from_millis(500); + + let _ = writeln!(logger, "Waiting for container network to initialize..."); + + while start.elapsed() < timeout { + let output = std::process::Command::new("lxc-info") + .arg("-n") + .arg(container_name) + .arg("-iH") + .output(); + + if let Ok(out) = output { + let stdout = String::from_utf8_lossy(&out.stdout); + let ip = stdout.trim(); + if !ip.is_empty() { + let _ = writeln!( + logger, + "Container network ready (IP: {}, waited {:.1}s)", + ip, + start.elapsed().as_secs_f64() + ); + return true; + } + } + + thread::sleep(poll_interval); + } + + let _ = writeln!( + logger, + "Warning: container network not ready after {:.1}s", + timeout.as_secs_f64() + ); + false + } + + fn enforce_network_readiness( + &self, + container_name: &str, + container_created: bool, + timeout: Duration, + logger: &mut Logger, + readiness_probe: P, + mut destroy_container: D, + ) -> Option + where + P: FnOnce(&str, Duration, &mut Logger) -> bool, + D: FnMut(), + { + if readiness_probe(container_name, timeout, logger) { + return None; + } + + if self.destroy_on_exit || container_created { + destroy_container(); + } + + Some(ScriptResponse::error(&format!( + "Container network did not initialize within {:.0}s; \ + check that lxc-net/dnsmasq is running and able to assign an IP.", + timeout.as_secs_f64() + ))) + } + + /// Core execution logic. + fn run_internal(&self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { + // tighten aliases of the same host object to the strictest intent (deny > ro > rw) + let normalized; + let request = match wxc_common::filesystem_object::normalize_object_conflicts( + &request.policy, + logger, + ) { + Ok(Some(policy)) => { + normalized = ExecutionRequest { + policy, + ..request.clone() + }; + &normalized + } + Ok(None) => request, + Err(msg) => return ScriptResponse::error(&msg), + }; + // reject any policy path the invoking user cannot access + if let Err(msg) = wxc_common::filesystem_access::check_delegation(&request.policy) { + return ScriptResponse::error(&msg); + } + + // Validate required LXC fields + if self.config.distribution.is_empty() || self.config.release.is_empty() { + return ScriptResponse::error( + "LXC distribution and release are required \ + (e.g., \"distribution\": \"alpine\", \"release\": \"3.23\")", + ); + } + + // Refuse an environment entry that cannot become a variable. The exec + // path drops such an entry and runs the script anyway, so without this + // the caller gets a container whose environment silently differs from + // the one they configured. Placed with the guards above so a rejected + // request never creates a container. + if let Some(entry) = crate::lxc_bindings::malformed_env_entry(&request.env) { + return ScriptResponse::error(&format!( + "LXC: env entry '{}' is not a valid environment entry; each one must be \ + KEY=VALUE with a non-empty KEY. Refusing rather than dropping it and \ + running the script in an environment that was not configured.", + entry + )); + } + + let container_name = self.resolve_container_name(); + // Credentials here would be visible to other local users. + if let Some(url) = request + .policy + .network_proxy + .address + .as_ref() + .map(|address| address.to_url()) + { + if wxc_common::proxy_env::proxy_url_has_credentials(&url) { + return ScriptResponse::error(&format!( + "LXC: network.proxy.url must not carry credentials ('{}'). Use a proxy \ + that does not require inline credentials, or supply them to the proxy \ + itself rather than through the URL.", + wxc_common::proxy_env::redact_proxy_url(&url) + )); + } + } + let uses_directional_schema = + wxc_common::supports_directional_network(&request.schema_version); + if self.destroy_on_exit { + signal_cleanup::set_active(&container_name); + } + let _ = writeln!(logger, "Container name: {}", container_name); + let _ = writeln!( + logger, + "Distribution: {}:{}", + self.config.distribution, self.config.release + ); + + // Apply experimental features when flag is set + if request.experimental_enabled { + if let Some(ref test) = request.experimental.test { + let _ = writeln!( + logger, + "Experimental feature 'test' applied: {}", + test.message + ); + } + } + + // Create container handle + let container = LxcContainer::new(&container_name, None); + let mut container_created = false; + + // Create the container if it doesn't exist. A probe that could not run + // is not evidence of absence, so it aborts rather than creating a + // second container over a first one we simply failed to see. + let defined = match container.is_defined() { + Ok(defined) => defined, + Err(e) => { + return ScriptResponse::error(&format!( + "Failed to determine whether the container exists: {}", + e + )); + } + }; + if !defined { + let _ = writeln!(logger, "Creating LXC container..."); + if let Err(e) = container.create(&self.config.distribution, &self.config.release) { + return ScriptResponse::error(&format!("Failed to create container: {}", e)); + } + let _ = writeln!(logger, "Container created successfully."); + container_created = true; + } else { + let _ = writeln!(logger, "Container already exists, reusing."); + } + + // Configure filesystem mounts + if let Err(e) = + filesystem_mounts::configure_filesystem_mounts(&container, &request.policy, logger) + { + if self.destroy_on_exit || container_created { + let _ = container.destroy(); + } + return ScriptResponse::error(&format!("Failed to configure filesystem: {}", e)); + } + + // Ensure the container is running so that the veth interface exists. An + // unreadable probe aborts: starting a container that is already running + // is not harmless here, and neither is proceeding as though it were up. + let running = match container.is_running() { + Ok(running) => running, + Err(e) => { + if self.destroy_on_exit || container_created { + let _ = container.destroy(); + } + return ScriptResponse::error(&format!( + "Failed to determine whether the container is running: {}", + e + )); + } + }; + let mut fw_manager = NetworkIptablesManager::new(&container_name); + fw_manager.set_directional_schema(uses_directional_schema); + + // Read once, so the refusal below and the pin decision cannot disagree + // about the same container. + let net_config = if installs_firewall(&request.policy, uses_directional_schema) { + match container.configured_net_interfaces() { + Ok(net) => Some(net), + Err(e) => { + let _ = writeln!(logger, "Could not read the network config: {}", e); + None + } + } + } else { + None + }; + + // The FORWARD hook matches a single veth, so traffic on any other + // interface never reaches the chain and the policy goes unenforced. + // A container with no usable veth already fails when the rules are + // applied, because nothing calls `allow_missing_veth_interface` on this + // path; one with several interfaces instead resolves the first and + // silently bypasses the rest, so it is refused here. A read that failed + // keeps the pre-existing behavior rather than turning an unreadable + // config into a new way to fail. + if let Some(net) = net_config.as_ref() { + if net.count > 1 { + if self.destroy_on_exit || container_created { + let _ = container.destroy(); + } + return ScriptResponse::error(&format!( + "Container {:?} has {} configured network interfaces; a firewall-enforced \ + network policy can only be applied to a container with a single interface, \ + because traffic on the others would bypass it", + container_name, net.count, + )); + } + } + + // Install egress before the container starts, so nothing inside it + // transmits during an interval MXC already reports as deny-all. + // iptables accepts a rule naming an interface that does not exist yet, + // so the name is pinned here and the interface catches up at start. + let mut egress_installed = false; + if !running && installs_firewall(&request.policy, uses_directional_schema) { + let pinnable = net_config + .as_ref() + .map(Self::pinnable_before_start) + .unwrap_or(false); + if pinnable { + let veth = NetworkIptablesManager::deterministic_veth_name(&container_name); + if let Err(e) = container.ensure_veth_pin_hook(&veth) { + if self.destroy_on_exit || container_created { + let _ = container.destroy(); + } + return ScriptResponse::error(&format!( + "Failed to pin the container's network interface: {}", + e + )); + } + let _ = writeln!(logger, "Pinned veth interface before start: {}", veth); + fw_manager.set_veth_interface(&veth); + if self.destroy_on_exit { + signal_cleanup::set_active_veth(&veth); + } + match fw_manager.apply_firewall_rules(&request.policy, logger) { + Ok(true) => egress_installed = true, + Ok(false) => { + if self.destroy_on_exit || container_created { + let _ = container.destroy(); + } + return ScriptResponse::error("Failed to apply network firewall rules."); + } + Err(e) => { + if self.destroy_on_exit || container_created { + let _ = container.destroy(); + } + return ScriptResponse::error(&format!("Network policy error: {}", e)); + } + } + } + } + + if !running { + let _ = writeln!(logger, "Starting LXC container..."); + if let Err(e) = container.start() { + if self.destroy_on_exit || container_created { + let _ = container.destroy(); + } + return ScriptResponse::error(&format!("Failed to start container: {}", e)); + } + let _ = writeln!(logger, "Container started successfully."); + } else { + let _ = writeln!(logger, "Container already running."); + } + + if needs_network(&request.policy, uses_directional_schema) { + // Fail closed: proceeding without an IP silently breaks DNS and produces + // flaky failures. Alpine DHCP leases can arrive at ~9s, so allow 30s. + let timeout = Duration::from_secs(30); + if let Some(response) = self.enforce_network_readiness( + &container_name, + container_created, + timeout, + logger, + Self::wait_for_network, + || { + let _ = container.destroy(); + }, + ) { + return response; + } + } + + // Shapes the pre-start path could not pin: an already running + // container, several interfaces, or one the hook cannot rename. + if !egress_installed { + // Resolve the container's veth interface for scoped rules. The pin hook + // persists in a container's config, so a container this runner did not + // pin may still have been renamed by an earlier state-aware start; the + // container itself is the only thing that knows which. + let pinned = NetworkIptablesManager::deterministic_veth_name(&container_name); + let pin_hook_present = match container.has_veth_pin_hook(&pinned) { + Ok(present) => present, + Err(e) => { + // Guessing here picks between two names, one of which filters + // nothing. Report it and resolve nothing rather than scope the + // rules to a name that may already be stale. + let _ = writeln!(logger, "Could not read the veth pin hook: {}", e); + self.halt_after_failed_start( + &container, + container_created, + &mut fw_manager, + None, + ); + return ScriptResponse::error( + "Failed to resolve the container's network interface.", + ); + } + }; + if let Some(veth) = + NetworkIptablesManager::live_veth_interface(&container_name, pin_hook_present) + { + let _ = writeln!(logger, "Resolved veth interface: {}", veth); + fw_manager.set_veth_interface(&veth); + if self.destroy_on_exit { + // Tell the watchdog about the veth so signal-time cleanup + // can also remove the FORWARD hook, not just the chain. + signal_cleanup::set_active_veth(&veth); + } + } + + match fw_manager.apply_firewall_rules(&request.policy, logger) { + Ok(true) => {} + Ok(false) => { + self.halt_after_failed_start( + &container, + container_created, + &mut fw_manager, + None, + ); + return ScriptResponse::error("Failed to apply network firewall rules."); + } + Err(e) => { + self.halt_after_failed_start( + &container, + container_created, + &mut fw_manager, + None, + ); + return ScriptResponse::error(&format!("Network policy error: {}", e)); + } + } + } + + // Until the container is up, a failure above must still tear down what + // the pre-start install put in place. + fw_manager.set_preserve_policy(!self.cleanup_policy); + + // Configure inbound (ingress) network rules inside the container's own + // netns. This is a separate, orthogonal chain from the egress rules + // above: it enforces `allowLocalNetwork` (inbound default-deny) via the + // container's own iptables INPUT chain, reached with `nsenter`. + // + // LXC enters that netns through the init PID, so a run that installs a + // firewall cannot proceed without one. + let use_firewall = installs_firewall(&request.policy, uses_directional_schema); + + // Kept in scope for post-execution cleanup. + let mut ingress_manager: Option = match container.init_pid() { + Some(pid) => { + let _ = writeln!(logger, "Container init PID: {}", pid); + let mut mgr = IngressManager::new(&container_name, pid, uses_directional_schema); + let pinned_veth = NetworkIptablesManager::deterministic_veth_name(container.name()); + // A container that cannot be asked answers no, which sends the + // lookup to the name liblxc recorded rather than to the pinned one. + let pin_hook_present = container.has_veth_pin_hook(&pinned_veth).unwrap_or(false); + if let Some(gateway) = + NetworkIptablesManager::live_veth_interface(container.name(), pin_hook_present) + .as_deref() + .and_then(NetworkIptablesManager::host_gateway_for_veth) + { + mgr.set_host_gateway(&gateway); + } + match mgr.apply_firewall_rules(&request.policy, logger) { + Ok(true) => {} + Ok(false) => { + self.halt_after_failed_start( + &container, + container_created, + &mut fw_manager, + None, + ); + return ScriptResponse::error( + "Failed to apply inbound network firewall rules.", + ); + } + Err(e) => { + self.halt_after_failed_start( + &container, + container_created, + &mut fw_manager, + None, + ); + return ScriptResponse::error(&format!( + "Inbound network policy error: {}", + e + )); + } + } + // Every non-success arm above returns, so the apply call + // succeeded and the inbound chain now exists. Only now may the + // policy be marked for preservation: the flag also suppresses + // `Drop`, and a partial chain from a failed install must still be + // torn down. + mgr.set_preserve_policy(!self.cleanup_policy); + Some(mgr) + } + None if use_firewall => { + // Enforcing inbound requires entering the container's + // namespace, so running anyway would silently disable the + // inbound deny (a fail-open). This guard is specific to the LXC + // ingress path, which addresses its namespace by init PID; + // other backends reach their firewall handling through their + // own runners and never construct an `IngressManager`. + self.halt_after_failed_start(&container, container_created, &mut fw_manager, None); + return ScriptResponse::error( + "Failed to discover the container init PID; cannot enter the container \ + network namespace to enforce the inbound firewall. Aborting rather than \ + running with inbound enforcement silently disabled.", + ); + } + + // No firewall requested and no netns PID: nothing to enforce + // inbound, so no ingress chain is installed. + None => None, + }; + + let mut pinned = false; + + // A proxied chain opens no port 53, so without this the container has + // no resolver to find its proxy with. + if let Some(pin) = fw_manager.proxy_host_pin() { + let command = Self::build_hosts_pin_command(&pin.hosts_line()); + let _ = writeln!( + logger, + "Pinning proxy host {} to {} in the container's /etc/hosts.", + pin.hostname(), + pin.ip() + ); + let pin_outcome = + container.attach_run(&command, "/", &[], true, Some(HOSTS_COMMAND_TIMEOUT), None); + let pin_error = match pin_outcome { + Ok((0, _, _)) => None, + + // `attach_run` streams the child's output and returns empty + // strings, so the exit code is the whole report. + Ok((code, _, _)) => Some(Self::hosts_command_failure("writing", code)), + Err(e) => Some(e.to_string()), + }; + if let Some(reason) = pin_error { + self.halt_after_failed_start( + &container, + container_created, + &mut fw_manager, + ingress_manager.as_mut(), + ); + return ScriptResponse::error(&format!( + "Failed to pin the network proxy host inside the container: {}. \ + The proxy would be unreachable, so the script was not run.", + reason + )); + } + pinned = true; + } else if !container_created { + // A run that pins rewrites /etc/hosts from a filtered copy, so it + // disposes of an earlier pin on its way past. A run that pins + // nothing has no such side effect, and a container this run did not + // create can still be carrying one -- left by a run interrupted + // after pinning, or by a removal that failed and only warned. Left + // in place it keeps resolving a hostname to an address that only + // some earlier policy authorized. + let unpin = Self::build_hosts_unpin_command(); + let stale_pin_error = match container.attach_run( + &unpin, + "/", + &[], + true, + Some(HOSTS_COMMAND_TIMEOUT), + None, + ) { + Ok((0, _, _)) => None, + Ok((code, _, _)) => Some(Self::hosts_command_failure("clearing", code)), + Err(e) => Some(e.to_string()), + }; + + // Unlike the post-run removal, this one runs *before* the script, so + // a failure still changes what the script would resolve. Refuse the + // run rather than execute against a mapping this policy never made. + if let Some(reason) = stale_pin_error { + if self.destroy_on_exit { + let _ = container.destroy(); + } else { + let _ = container.stop(); + } + return ScriptResponse::error(&format!( + "Failed to clear a stale network proxy pin from the container's \ + /etc/hosts: {}. The script was not run, because it could have resolved \ + the pinned hostname to an address this policy did not authorize.", + reason + )); + } + } + + // Execute the script using lxc-attach (container is already running). + // `script_timeout == 0` means "no timeout" per the SDK contract. + let timeout = if request.script_timeout == 0 { + None + } else { + Some(Duration::from_millis(u64::from(request.script_timeout))) + }; + let _ = writeln!(logger, "Executing script inside container..."); + // Stamped so a timeout can reap what the script started. The one-shot + // container is normally destroyed on exit, which reaps everything, but + // a run configured to leave it behind has the same persistence problem + // the state-aware path does. + let marker = crate::lxc_bindings::mint_exec_marker(); + + let mut exec_env = request.env.clone(); + // Scrub every inherited proxy variable and, when the policy carries a + // proxy, point HTTP(S)_PROXY at it. + wxc_common::proxy_env::apply_proxy_env(&mut exec_env, &request.policy.network_proxy); + + // Always clear, including for an empty env: otherwise `lxc-attach` + // falls back to keep-env mode and inherits the MXC host process + // environment, proxy variables and credentials included. + let result = container.attach_run( + &request.script_code, + &request.working_directory, + &exec_env, + true, + timeout, + Some(&marker), + ); + + let response = match result { + Ok((exit_code, stdout, stderr)) => ScriptResponse { + exit_code, + standard_out: stdout, + standard_err: stderr, + error_message: String::new(), + ..Default::default() + }, + Err(e) => ScriptResponse::error(&format!("Execution failed: {}", e)), + }; + + // The pin names an address only this run's chain authorized, so it + // must not outlive that chain. + if pinned && self.cleanup_policy { + let unpin = Self::build_hosts_unpin_command(); + let unpin_error = match container.attach_run( + &unpin, + "/", + &[], + true, + Some(HOSTS_COMMAND_TIMEOUT), + None, + ) { + Ok((0, _, _)) => None, + Ok((code, _, _)) => Some(Self::hosts_command_failure("clearing", code)), + Err(e) => Some(e.to_string()), + }; + + // The script has already run, so a failure here cannot change its + // result and must not replace it. + if let Some(reason) = unpin_error { + let _ = writeln!( + logger, + "Warning: failed to clear the proxy host pin: {}", + reason + ); + } + } + + // Cleanup: remove network rules + if fw_manager.rules_applied() && self.cleanup_policy { + let _ = fw_manager.remove_firewall_rules(logger); + } + if let Some(mgr) = &mut ingress_manager { + if mgr.rules_applied() && self.cleanup_policy { + let _ = mgr.remove_firewall_rules(logger); + } + } + + // Cleanup: destroy container if configured + if self.destroy_on_exit { + let _ = writeln!(logger, "Destroying container..."); + if let Err(e) = container.destroy() { + let _ = writeln!(logger, "Warning: failed to destroy container: {}", e); + } + } + + response + } + + /// Build the shell command that installs `hosts_line` into the + /// container's `/etc/hosts`. + /// + /// The first match wins in a hosts file, so an entry left by an earlier + /// run on a container reused across runs (`destroy_on_exit = false`) would + /// shadow the pin this run authorized. Marking each written line is what + /// lets a later run recognize its own without disturbing the ones the + /// distribution shipped. + /// + /// LXC may bind-mount `/etc/hosts`, so the file is rewritten in place: + /// replacing the inode would leave the container reading the old one. The + /// toolset is held to `grep` and `printf` because the image may be BusyBox + /// rather than coreutils. + /// + /// The kept lines are staged in a shell variable because a scratch file + /// has a name in the container's own filesystem and this command runs + /// privileged through `lxc-attach`. A previous workload on a reused + /// container can pre-create any predictable name as a symlink, and `>` + /// follows symlinks, so staging in a file aims a privileged truncating + /// write at whatever the link names -- another container file, or a host + /// path exposed through a writable bind mount. A variable has no name to + /// hijack. + /// + /// Staging must also finish before the redirect opens `/etc/hosts`, + /// because `>` truncates on open. Past that point nothing reads the + /// filesystem again: what remains works from text already in memory, so + /// no read failure can strand a file that has already been emptied. + /// + /// Single-quoting `hosts_line` is safe by construction: `ProxyHostPin` + /// builds it from a parsed [`std::net::IpAddr`] and a validated + /// hostname, neither of which can carry a quote or a newline. The one + /// space it does contain is the hosts-file separator, and single quotes + /// render that inert. + fn build_hosts_pin_command(hosts_line: &str) -> String { + // `$(...)` strips trailing newlines, so the kept text is re-emitted + // with an explicit one and the guard keeps an empty result from + // becoming a blank first line. The group's exit status is the final + // printf's, so a grep that matches nothing and exits 1 does not fail + // the command. + format!( + "{}{{ if [ -n \"$kept\" ]; then printf '%s\\n' \"$kept\"; fi; \ + printf '%s {marker}\\n' '{hosts_line}'; }} > /etc/hosts", + Self::hosts_read_prologue(), + marker = HOSTS_PIN_MARKER, + hosts_line = hosts_line + ) + } + + /// Read the existing `/etc/hosts` into `$kept`, or abort before anything + /// opens the file for writing. + /// + /// `> /etc/hosts` truncates the moment it is opened, so every reason the + /// read could fail has to be settled before that point. Were the status + /// left unexamined, the failure would run in the worst possible + /// direction: `$kept` would be empty whether `grep` is absent, + /// `/etc/hosts` is unreadable, or the binary is killed, and the closing + /// `printf` would still exit 0 -- a successful pin reported over a hosts + /// file emptied of every entry the image shipped. + /// + /// Only 0 (lines kept) and 1 (nothing kept) are outcomes rather than + /// failures. Status 1 is legitimate and common: an empty file, or a re-pin + /// where every existing line carries the marker. Anything above 1 is a + /// failed read, which also catches the `127` a missing `grep` produces. A + /// missing file is separated out ahead of the read because `grep` reports + /// both "absent" and "unreadable" as status 2, and an image that ships no + /// `/etc/hosts` has no content to protect. + /// + /// Two gaps survive this guard, and neither is closable while the command + /// is restricted to `grep` and `printf` for BusyBox: + /// + /// * A successful read still loses NUL bytes, because a shell variable + /// cannot hold them. A hosts file containing one is rewritten truncated + /// at that byte with status 0, and no check here observes it. A NUL in + /// `/etc/hosts` is malformed to begin with, and the alternatives -- a + /// scratch file, `sed`, `awk` -- each cost either a filesystem name an + /// attacker can hijack or a tool BusyBox may not ship. + /// + /// * A symlink swapped in between the `-h` test and the redirect is still + /// followed. Closing that race needs an open-once-and-rewrite primitive + /// -- `openat` with `O_NOFOLLOW` inside the container's mount namespace + /// -- which is a Rust-side change rather than a shell one. The `-h` test + /// covers the unraced shape: a workload reused across runs can simply + /// leave `/etc/hosts` as a symlink. A dangling one is the worst of + /// those, because it fails `-e` as well, and a redirect onto a dangling + /// link creates the link's target -- a write to an attacker-named path, + /// which on a writable host bind mount lands outside the container. + fn hosts_read_prologue() -> String { + format!( + "if [ -h /etc/hosts ]; then \ + printf 'mxc: refusing to rewrite /etc/hosts: it is a symbolic link\\n' >&2; \ + exit 4; \ + fi; \ + kept=''; \ + if [ -e /etc/hosts ]; then \ + kept=$(grep -v '{marker}' /etc/hosts 2>/dev/null); \ + status=$?; \ + if [ \"$status\" -gt 1 ]; then \ + printf 'mxc: refusing to rewrite /etc/hosts: reading it exited %s\\n' \ + \"$status\" >&2; \ + exit \"$status\"; \ + fi; \ + fi; ", + marker = HOSTS_PIN_MARKER + ) + } + + /// Strip every pin this runner has ever written from `/etc/hosts`. + /// + /// Only a run that pins something removes the previous pin on its way + /// past, so a run that pins nothing needs its own removal. Left in place + /// on a container kept alive across runs, a stale pin outlives the policy + /// that authorized it: the new policy resolves the hostname fresh to build + /// its rules, so a deny would be written against one address while the + /// container still reaches the other. + fn build_hosts_unpin_command() -> String { + format!( + "{}{{ if [ -n \"$kept\" ]; then printf '%s\\n' \"$kept\"; fi; }} > /etc/hosts", + Self::hosts_read_prologue() + ) + } + + /// Why a `/etc/hosts` command that ran to completion is being treated as a + /// failure, given the verb for what it was doing and the code it exited + /// with. + /// + /// The exit code is the entire report, and that is a property of how these + /// commands are run rather than a choice about brevity. `attach_run` + /// streams the child's output to the caller as it arrives and hands back + /// empty strings for both streams, so a reason that appended the returned + /// stderr would read `exited with 1: ` every single time -- a colon + /// offering an explanation, followed by nothing. Taking only the code + /// leaves the message true, and leaves the actual diagnostics where the + /// child already wrote them. + fn hosts_command_failure(verb: &str, exit_code: i32) -> String { + format!("{} /etc/hosts exited with {}", verb, exit_code) + } +} + +fn lxc_network_policy_support() -> NetworkPolicySupport { + NetworkPolicySupport::EGRESS_DEFAULT + | NetworkPolicySupport::EGRESS_RULES + | NetworkPolicySupport::INGRESS_DEFAULT + | NetworkPolicySupport::HOST_LOOPBACK +} + +impl ScriptRunner for LxcScriptRunner { + fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { + validate_network_policy_support(request, lxc_network_policy_support())?; + // Under 'capabilities' no iptables rules are installed and nothing + // redirects traffic into the proxy. + if request.policy.network_proxy.is_enabled() + && !installs_firewall( + &request.policy, + wxc_common::supports_directional_network(&request.schema_version), + ) + { + return Err(ScriptResponse::error( + "LXC: network.proxy requires network.enforcementMode='firewall' or 'both'. \ + This policy enables a proxy under 'capabilities', where no iptables \ + rules are installed, so the proxy environment would be injected while \ + direct egress stayed unrestricted -- any client that ignores HTTP_PROXY \ + would bypass the proxy entirely. Refusing to apply rather than reporting \ + success for an enforcement that did not happen.", + )); + } + Ok(()) + } + + fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { + // Run with panic catching for safety + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.run_internal(request, logger) + })) { + Ok(r) => r, + Err(_) => ScriptResponse::error("Unknown error during LXC script execution."), + } + } +} + +/// Generate a simple 8-character hex ID (no uuid crate dependency needed). +fn uuid_simple() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let t = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("{:08x}", (t & 0xFFFF_FFFF) as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + use wxc_common::logger::Mode; + + #[test] + fn a_single_veth_can_be_pinned_before_start() { + let net = NetInterfaceConfig { + count: 1, + sole_kind: Some("veth".to_string()), + }; + assert!(LxcScriptRunner::pinnable_before_start(&net)); + } + + #[test] + fn a_sole_interface_that_is_not_veth_is_left_to_the_post_start_path() { + let net = NetInterfaceConfig { + count: 1, + sole_kind: Some("macvlan".to_string()), + }; + assert!(!LxcScriptRunner::pinnable_before_start(&net)); + } + + #[test] + fn a_container_with_no_interfaces_is_left_to_the_post_start_path() { + let net = NetInterfaceConfig { + count: 0, + sole_kind: None, + }; + assert!(!LxcScriptRunner::pinnable_before_start(&net)); + } + + #[test] + fn a_container_with_several_interfaces_is_left_to_the_post_start_path() { + let net = NetInterfaceConfig { + count: 2, + sole_kind: None, + }; + assert!(!LxcScriptRunner::pinnable_before_start(&net)); + } + + #[test] + fn uuid_simple_is_8_chars() { + let id = uuid_simple(); + assert_eq!(id.len(), 8); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn resolve_container_name_uses_config() { + let config = LxcConfig::default(); + let lifecycle = LifecycleConfig::default(); + let runner = LxcScriptRunner::new(&config, "my-test", &lifecycle); + assert_eq!(runner.resolve_container_name(), "my-test"); + } + + #[test] + fn resolve_container_name_generates_when_empty() { + let config = LxcConfig::default(); + let lifecycle = LifecycleConfig::default(); + let runner = LxcScriptRunner::new(&config, "", &lifecycle); + let name = runner.resolve_container_name(); + assert!(name.starts_with("mxc-")); + } + + // The pin is worthless if the mapping it was built from is not the one + // that lands in the file. + #[test] + fn the_hosts_pin_command_writes_the_requested_mapping() { + let command = LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"); + + assert!( + command.contains("'10.0.0.5 proxy.example.com'"), + "the command must carry the mapping verbatim, got: {command}" + ); + assert!( + command.contains("/etc/hosts"), + "the command must target /etc/hosts, got: {command}" + ); + } + + // A container reused across runs would otherwise accumulate entries, and + // the first match in a hosts file wins -- so a stale line would shadow the + // pin this run just authorized. + #[test] + fn the_hosts_pin_command_strips_its_own_previous_entries_first() { + let command = LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"); + + assert!( + command.contains(&format!("grep -v '{}'", HOSTS_PIN_MARKER)), + "the command must remove prior pins before writing; got: {command}" + ); + assert!( + command.matches(HOSTS_PIN_MARKER).count() >= 2, + "the written line must carry the marker that the strip looks for; got: {command}" + ); + } + + #[test] + fn a_failed_hosts_command_reports_a_reason_it_can_actually_supply() { + // `attach_run` streams the child's output live and returns empty + // strings for both streams, so the earlier form of this message + // appended an empty stderr and always read "exited with 1: " -- a + // colon offering an explanation, with nothing after it. Both verbs are + // checked because the pin and unpin paths build this independently. + for verb in ["writing", "clearing"] { + let reason = LxcScriptRunner::hosts_command_failure(verb, 1); + + assert!( + reason.contains('1'), + "the reason must name the exit code; got: {reason}" + ); + assert!( + !reason.trim_end().ends_with(':'), + "the reason must not trail a separator promising more; got: {reason:?}" + ); + assert_eq!( + reason.trim_end(), + reason, + "the reason must not trail whitespace where a stderr used to go; got: {reason:?}" + ); + } + } + + #[test] + fn a_failed_hosts_command_distinguishes_the_step_that_failed() { + // Pinning and unpinning fail for different reasons and are fixed + // differently, so a message that named neither would send whoever + // reads it to the wrong half of the path. + let pinning = LxcScriptRunner::hosts_command_failure("writing", 2); + let clearing = LxcScriptRunner::hosts_command_failure("clearing", 2); + + assert_ne!( + pinning, clearing, + "the two steps must not produce the same reason; got: {pinning:?}" + ); + } + + #[test] + fn the_hosts_unpin_command_removes_the_marker_without_writing_a_new_one() { + let command = LxcScriptRunner::build_hosts_unpin_command(); + + assert!( + command.contains(&format!("grep -v '{}'", HOSTS_PIN_MARKER)), + "the command must filter out every marked line; got: {command}" + ); + // Banning `printf` outright would fail here: re-emitting the *kept* + // lines needs one, on a command that writes no marked line. The + // marker count states the invariant directly -- the marker's one + // appearance is inside the filter, so no marked line can be written. + assert_eq!( + command.matches(HOSTS_PIN_MARKER).count(), + 1, + "the marker should appear only in the filter; got: {command}" + ); + } + + #[test] + fn the_hosts_unpin_command_rewrites_the_file_in_place_rather_than_replacing_it() { + // Same bind-mount reasoning as the pin: replacing the inode would leave + // the container still reading the file that carries the stale pin. + let command = LxcScriptRunner::build_hosts_unpin_command(); + + assert!( + command.contains("> /etc/hosts"), + "the command must rewrite the existing file; got: {command}" + ); + assert!( + !command.contains("mv "), + "the command must not replace the inode; got: {command}" + ); + } + + // LXC may bind-mount /etc/hosts. Replacing the inode with `mv` would leave + // the container reading the file it had before. + #[test] + fn the_hosts_pin_command_rewrites_the_file_in_place_rather_than_replacing_it() { + let command = LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"); + + assert!( + command.contains("> /etc/hosts"), + "the command must redirect into the existing file, got: {command}" + ); + assert!( + !command.contains("mv "), + "the command must not replace the inode, got: {command}" + ); + } + + // Everything the pin needs must exist in a minimal image; a container + // built on BusyBox has no coreutils to fall back on. + #[test] + fn the_hosts_pin_command_uses_only_busybox_available_tools() { + let command = LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"); + + for forbidden in ["sed ", "awk ", "tee ", "sponge "] { + assert!( + !command.contains(forbidden), + "the command must not depend on {forbidden:?}, got: {command}" + ); + } + } + + // `/tmp` is the container's, and this command runs privileged through + // `lxc-attach`. On a container reused across runs a previous workload can + // pre-create any predictable name there as a symlink, and `>` follows + // symlinks -- so a scratch file would let it aim a privileged truncating + // write at another container file or at a host path exposed through a + // writable bind mount. Neither command may stage anything in a directory + // the container can write. + #[test] + fn the_hosts_commands_stage_nothing_in_a_container_writable_directory() { + let commands = [ + LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"), + LxcScriptRunner::build_hosts_unpin_command(), + ]; + + for command in commands { + for scratch in ["/tmp/", "/var/tmp/", "/dev/shm/", "/run/"] { + assert!( + !command.contains(scratch), + "the command must not stage under {scratch:?}, got: {command}" + ); + } + assert_eq!( + command.matches("> /etc/hosts").count(), + 1, + "/etc/hosts must be the only redirect target; got: {command}" + ); + } + } + + // Staging in a variable is only an improvement if the content is complete + // before the target is truncated. `>` truncates as the redirect opens, so + // any command that still had to *produce* content after that point would + // leave the container with an empty /etc/hosts if it failed. + #[test] + fn the_hosts_commands_build_their_content_before_truncating_the_target() { + let commands = [ + LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"), + LxcScriptRunner::build_hosts_unpin_command(), + ]; + + for command in commands { + let capture = command.find("kept=$(").unwrap_or_else(|| { + panic!("the command must stage into a variable; got: {command}") + }); + let redirect = command + .find("> /etc/hosts") + .unwrap_or_else(|| panic!("the command must target /etc/hosts; got: {command}")); + + assert!( + capture < redirect, + "the content must be captured before /etc/hosts is truncated; got: {command}" + ); + assert!( + !command[redirect..].contains("grep"), + "no file-reading command may run after the target is truncated; got: {command}" + ); + } + } + + // The parser rejects a credential-bearing proxy URL, but `ExecutionRequest` + // and `ProxyAddress::from_url` are public: a caller can build a request the + // parser never saw and hand it straight to this runner. These tests take + // that path deliberately -- no parser anywhere in them -- because a guard + // that only exists on the parse path does not protect the process spawn. + use wxc_common::models::{ + NetworkEgressPolicy, NetworkIngressPolicy, ProxyAddress, ProxyConfig, + }; + + fn request_with_proxy_url(url: &str) -> ExecutionRequest { + let mut request = ExecutionRequest::default(); + request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::from_url( + url, + "proxy.example.com".to_string(), + 8080, + )), + builtin_test_server: false, + }; + request + } + + fn runner_for_guard_tests() -> LxcScriptRunner { + let config = LxcConfig { + distribution: "alpine".to_string(), + release: "3.23".to_string(), + }; + LxcScriptRunner::new(&config, "mxc-guard-test", &LifecycleConfig::default()) + } + + fn runner_for_network_readiness_tests(destroy_on_exit: bool) -> LxcScriptRunner { + let config = LxcConfig { + distribution: "alpine".to_string(), + release: "3.23".to_string(), + }; + let lifecycle = LifecycleConfig { + destroy_on_exit, + ..LifecycleConfig::default() + }; + LxcScriptRunner::new(&config, "mxc-network-test", &lifecycle) + } + + fn fail_network_readiness( + runner: &LxcScriptRunner, + container_created: bool, + ) -> (ScriptResponse, bool) { + let mut logger = Logger::new(Mode::Buffer); + let mut destroyed = false; + let response = runner + .enforce_network_readiness( + "mxc-network-test", + container_created, + Duration::from_secs(1), + &mut logger, + |_name, _timeout, _logger| false, + || destroyed = true, + ) + .expect("a failing readiness probe should return an error response"); + (response, destroyed) + } + + /// What the 0.8 parser produces for a config stating only `network.egress`: + /// the egress section as written, plus an ingress section filled in from + /// its defaults. + fn egress_only_directional_request() -> ExecutionRequest { + let mut request = ExecutionRequest::default(); + request.policy.network_mode_specified = true; + request.policy.network_egress = Some(NetworkEgressPolicy::default()); + request.policy.network_ingress = Some(NetworkIngressPolicy::default()); + request + } + + #[test] + fn an_egress_only_directional_config_passes_validation() { + let runner = runner_for_guard_tests(); + + assert!( + runner + .validate_runner(&egress_only_directional_request()) + .is_ok(), + "a 0.8 config stating only network.egress must reach the backend; rejecting it \ + here would make every directional egress policy unusable on LXC" + ); + } + + // Pins the directional claim as all-or-nothing: dropping to the two egress + // bits alone would reject the config above. + #[test] + fn claiming_only_the_egress_bits_would_reject_an_egress_only_config() { + let egress_bits_only = + NetworkPolicySupport::EGRESS_DEFAULT | NetworkPolicySupport::EGRESS_RULES; + + assert!( + validate_network_policy_support(&egress_only_directional_request(), egress_bits_only) + .is_err(), + "expected the two-bit claim to reject an egress-only config; if this now passes, \ + the directional posture is no longer all-or-nothing and lxc_network_policy_support \ + can drop the ingress bits" + ); + } + + #[test] + fn a_directly_built_request_with_proxy_credentials_is_refused() { + let runner = runner_for_guard_tests(); + let request = request_with_proxy_url("http://alice:hunter2@proxy.example.com:8080"); + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + let response = runner.run_internal(&request, &mut logger); + + assert!( + response + .error_message + .contains("must not carry credentials"), + "the runner must refuse a credential-bearing proxy URL even when the parser \ + never saw the request, got: {}", + response.error_message + ); + } + + // The rejection is built from the redacted URL so the guard cannot become + // the leak it exists to prevent -- the message travels to logs and to the + // caller. + #[test] + fn the_runner_refusal_does_not_echo_the_password() { + let runner = runner_for_guard_tests(); + let request = request_with_proxy_url("http://alice:hunter2@proxy.example.com:8080"); + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + let response = runner.run_internal(&request, &mut logger); + + assert!( + !response.error_message.contains("hunter2"), + "the password leaked into the refusal: {}", + response.error_message + ); + assert!( + !response.error_message.contains("alice:hunter2"), + "the userinfo leaked into the refusal: {}", + response.error_message + ); + assert!( + !logger.get_buffer().contains("hunter2"), + "the password leaked into the log buffer" + ); + } + + // Anti-vacuity: without this, a guard that refused every proxy would pass + // both tests above while breaking every legitimate proxy configuration. + // The run cannot succeed here (there is no live container), so the + // assertion is that it does not fail *for this reason*. + #[test] + fn a_credential_free_proxy_url_is_not_refused_by_the_credential_guard() { + let runner = runner_for_guard_tests(); + let request = request_with_proxy_url("http://proxy.example.com:8080"); + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + let response = runner.run_internal(&request, &mut logger); + + assert!( + !response + .error_message + .contains("must not carry credentials"), + "a proxy URL without userinfo must clear the credential guard, got: {}", + response.error_message + ); + } + + // The guard runs ahead of container creation and firewall programming, so a + // rejected request leaves nothing to clean up. A container name in the log + // would mean the runner had already started announcing work it must not do. + #[test] + fn the_credential_refusal_happens_before_any_container_work() { + let runner = runner_for_guard_tests(); + let request = request_with_proxy_url("http://alice:hunter2@proxy.example.com:8080"); + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + let _ = runner.run_internal(&request, &mut logger); + + let log = logger.get_buffer(); + assert!( + !log.contains("Container name:"), + "the guard must return before the runner starts container work, log was: {log}" + ); + assert!( + !log.contains("Creating LXC container"), + "the guard must return before container creation, log was: {log}" + ); + } + + // The 0.7 schema lets a policy ask for a proxy under 'capabilities', where + // no firewall is installed and nothing redirects traffic into the proxy. + #[test] + fn a_proxy_under_capabilities_is_refused_before_any_container_work() { + let mut runner = runner_for_guard_tests(); + let mut request = request_with_proxy_url("http://proxy.example.com:8080"); + request.script_code = "echo hello".to_string(); + request.schema_version = "0.7.0".to_string(); + request.policy.network_enforcement_mode = + wxc_common::models::NetworkEnforcementMode::Capabilities; + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + let response = ScriptRunner::run(&mut runner, &request, &mut logger); + + assert!( + response.error_message.contains("enforcementMode"), + "a proxy under 'capabilities' must be refused, got: {}", + response.error_message + ); + let log = logger.get_buffer(); + assert!( + !log.contains("Container name:"), + "the refusal must land before the runner starts container work, log was: {log}" + ); + } + + // A dry run answers "would this configuration be accepted?", and the caller + // acts on that answer without ever starting a container. Answering yes for + // a policy the real run refuses sends the caller away believing a proxy + // will be enforced. + #[test] + fn a_proxy_under_capabilities_is_refused_on_a_dry_run() { + let mut runner = runner_for_guard_tests(); + let mut request = request_with_proxy_url("http://proxy.example.com:8080"); + request.script_code = "echo hello".to_string(); + request.schema_version = "0.7.0".to_string(); + request.policy.network_enforcement_mode = + wxc_common::models::NetworkEnforcementMode::Capabilities; + request.dry_run = true; + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + let response = ScriptRunner::run(&mut runner, &request, &mut logger); + + assert_ne!( + response.exit_code, 0, + "a dry run must not report success for a policy the real run refuses" + ); + assert!( + response.error_message.contains("enforcementMode"), + "a dry run must give the same refusal as the real run, got: {}", + response.error_message + ); + } + + // An environment entry the runner cannot turn into a variable is a typo in + // the configuration file. Dropping it runs the script in an environment + // the caller did not ask for, and nothing in the output says so. + fn request_with_env(entries: &[&str]) -> ExecutionRequest { + ExecutionRequest { + env: entries.iter().map(|entry| entry.to_string()).collect(), + ..ExecutionRequest::default() + } + } + + fn env_refusal_for(entries: &[&str]) -> String { + let runner = runner_for_guard_tests(); + let request = request_with_env(entries); + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + runner.run_internal(&request, &mut logger).error_message + } + + #[test] + fn an_env_entry_without_an_equals_sign_is_refused() { + let message = env_refusal_for(&["BADENTRY"]); + + assert!( + message.contains("BADENTRY"), + "an entry that is not KEY=VALUE cannot become a variable and must be refused \ + by name, got: {message}" + ); + } + + #[test] + fn an_env_entry_with_an_empty_key_is_refused() { + let message = env_refusal_for(&["=orphaned"]); + + assert!( + message.contains("env"), + "an entry with no name on the left of '=' names no variable and must be \ + refused, got: {message}" + ); + } + + #[test] + fn one_malformed_env_entry_among_good_ones_refuses_the_exec() { + let message = env_refusal_for(&["GOOD=1", "BADENTRY", "ALSO_GOOD=2"]); + + assert!( + message.contains("BADENTRY"), + "setting the two well-formed variables and dropping the third would run the \ + script in an environment nobody configured, got: {message}" + ); + } + + // Anti-vacuity: without this, a guard that refused every environment would + // pass the tests above while breaking every legitimate request. The run + // cannot succeed here (there is no live container), so the assertion is + // that it does not fail *for this reason*. + #[test] + fn a_well_formed_env_entry_is_not_refused_by_the_env_guard() { + let message = env_refusal_for(&["PATH=/usr/bin", "EMPTY=", "HAS_EQUALS=a=b"]); + + assert!( + !message.contains("is not a valid environment entry"), + "KEY=VALUE, an empty value, and an embedded '=' are all well formed and must \ + clear the guard, got: {message}" + ); + } + + #[test] + fn the_env_refusal_happens_before_any_container_work() { + let runner = runner_for_guard_tests(); + let request = request_with_env(&["BADENTRY"]); + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + let _ = runner.run_internal(&request, &mut logger); + + let log = logger.get_buffer(); + assert!( + !log.contains("Creating LXC container"), + "the guard must return before container creation, log was: {log}" + ); + } + + #[test] + fn network_readiness_timeout_returns_the_fail_closed_error_response() { + let runner = runner_for_network_readiness_tests(false); + let (response, _) = fail_network_readiness(&runner, false); + + assert!( + response + .error_message + .contains("Container network did not initialize within 1s"), + "the fail-closed readiness timeout message changed unexpectedly: {}", + response.error_message + ); + assert_eq!( + response.standard_err, response.error_message, + "error responses should carry the message in stderr as well" + ); + } + + #[test] + fn network_readiness_timeout_preserves_a_reused_container_when_destroy_on_exit_is_false() { + let runner = runner_for_network_readiness_tests(false); + let (_, destroyed) = fail_network_readiness(&runner, false); + + assert!( + !destroyed, + "a reused container should be preserved on readiness timeout when destroyOnExit=false" + ); + } + + #[test] + fn network_readiness_timeout_destroys_newly_created_container_even_when_destroy_on_exit_is_false( + ) { + let runner = runner_for_network_readiness_tests(false); + let (_, destroyed) = fail_network_readiness(&runner, true); + + assert!( + destroyed, + "a newly created container must be destroyed on readiness timeout even when destroyOnExit=false" + ); + } + + #[test] + fn network_readiness_timeout_destroys_a_reused_container_when_destroy_on_exit_is_true() { + let runner = runner_for_network_readiness_tests(true); + let (_, destroyed) = fail_network_readiness(&runner, false); + + assert!( + destroyed, + "a reused container must be destroyed on readiness timeout when destroyOnExit=true" + ); + } +} + +/// The generated hosts commands, executed rather than pattern-matched. +/// +/// Every hosts test in `tests` above asserts on the command *string*. No +/// string assertion can separate a command that preserves `/etc/hosts` from +/// one that empties it -- both contain `> /etc/hosts`, and the truncation +/// defect these tests exist to pin was invisible to all six of them. Running +/// the command under a real `/bin/sh` is what makes the difference +/// observable. +#[cfg(all(test, unix))] +mod hosts_command_execution { + use super::*; + use std::path::{Path, PathBuf}; + + /// What a container image ships before anything pins a proxy. + const ORIGINAL: &str = "127.0.0.1 localhost\n::1 ip6-localhost\n10.0.0.9 build.internal\n"; + + const PIN_LINE: &str = "10.0.0.5 proxy.example.com"; + + /// A private directory that removes itself, so a failing test cannot leave + /// a hosts fixture behind for the next run to find. + struct Scratch { + dir: PathBuf, + } + + impl Scratch { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "mxc-hosts-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("the system clock should be after the unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("the scratch directory should be creatable"); + Self { dir } + } + + fn hosts(&self) -> PathBuf { + self.dir.join("hosts") + } + + fn write_hosts(&self, contents: &str) { + std::fs::write(self.hosts(), contents).expect("the fixture should be writable"); + } + + fn read_hosts(&self) -> String { + std::fs::read_to_string(self.hosts()).expect("the fixture should be readable") + } + + /// A `PATH` carrying a `grep` that fails with `status`, so the read + /// can be broken without breaking the shell around it: `printf` and + /// `[` are builtins and survive the override. + fn path_with_failing_grep(&self, status: i32) -> String { + use std::os::unix::fs::PermissionsExt; + + let bin = self.dir.join("bin"); + std::fs::create_dir_all(&bin).expect("the shim directory should be creatable"); + let grep = bin.join("grep"); + std::fs::write(&grep, format!("#!/bin/sh\nexit {status}\n")) + .expect("the shim should be writable"); + std::fs::set_permissions(&grep, std::fs::Permissions::from_mode(0o755)) + .expect("the shim should be executable"); + + format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ) + } + + /// A `PATH` with no `grep` on it at all, which is how a BusyBox image + /// missing the applet fails: the shell cannot find the binary and + /// reports 127. + fn path_without_grep(&self) -> String { + let empty = self.dir.join("empty"); + std::fs::create_dir_all(&empty).expect("the empty directory should be creatable"); + empty.display().to_string() + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + /// Point a generated command at a scratch file, so what runs is the + /// generated text with only the path substituted. That the real target is + /// `/etc/hosts` is not left unpinned by the substitution; the string tests + /// above pin it. + fn retarget(command: &str, hosts: &Path) -> String { + command.replace( + "/etc/hosts", + hosts.to_str().expect("the scratch path should be utf-8"), + ) + } + + fn run(command: &str, path: Option<&str>) -> i32 { + let mut shell = std::process::Command::new("/bin/sh"); + shell.arg("-c").arg(command); + if let Some(path) = path { + shell.env("PATH", path); + } + shell + .output() + .expect("/bin/sh should be executable") + .status + .code() + .expect("the shell should exit rather than be signalled") + } + + fn pin(hosts: &Path) -> String { + retarget(&LxcScriptRunner::build_hosts_pin_command(PIN_LINE), hosts) + } + + fn unpin(hosts: &Path) -> String { + retarget(&LxcScriptRunner::build_hosts_unpin_command(), hosts) + } + + #[test] + fn pinning_adds_the_mapping_and_keeps_every_line_the_image_shipped() { + let scratch = Scratch::new("keeps"); + scratch.write_hosts(ORIGINAL); + + let code = run(&pin(&scratch.hosts()), None); + let after = scratch.read_hosts(); + + assert_eq!(code, 0, "pinning a readable file should succeed"); + for line in ORIGINAL.lines() { + assert!( + after.contains(line), + "the pin dropped {line:?}; file is now:\n{after}" + ); + } + assert!( + after.contains(&format!("{PIN_LINE} {HOSTS_PIN_MARKER}")), + "the pin never landed; file is now:\n{after}" + ); + } + + // The first match in a hosts file wins, so a pin left over from a previous + // run on a reused container would shadow the one this run authorized. + #[test] + fn re_pinning_replaces_the_previous_entry_instead_of_stacking_on_it() { + let scratch = Scratch::new("repin"); + scratch.write_hosts(ORIGINAL); + + assert_eq!(run(&pin(&scratch.hosts()), None), 0); + assert_eq!(run(&pin(&scratch.hosts()), None), 0); + let after = scratch.read_hosts(); + + assert_eq!( + after.matches(HOSTS_PIN_MARKER).count(), + 1, + "a second pin should replace the first, not stack; file is now:\n{after}" + ); + assert!( + after.contains("10.0.0.9 build.internal"), + "re-pinning dropped an unrelated entry; file is now:\n{after}" + ); + } + + // The defect this module was written for. `> /etc/hosts` truncates the + // instant it is opened, so a read that failed has to stop the command + // before the redirect -- not merely produce nothing to write back. + #[test] + fn a_failed_read_leaves_the_file_byte_for_byte_as_it_was() { + let scratch = Scratch::new("failread"); + scratch.write_hosts(ORIGINAL); + let path = scratch.path_with_failing_grep(2); + + let code = run(&pin(&scratch.hosts()), Some(&path)); + + assert_eq!( + scratch.read_hosts(), + ORIGINAL, + "a failed read truncated the file it could not read" + ); + assert_ne!( + code, 0, + "a failed read must fail the command, not report a pin it never made" + ); + } + + // A missing `grep` is status 127, not 2, and is the likelier failure on a + // stripped image -- the same class, reached by a different route. + #[test] + fn a_missing_grep_leaves_the_file_byte_for_byte_as_it_was() { + let scratch = Scratch::new("nogrep"); + scratch.write_hosts(ORIGINAL); + let path = scratch.path_without_grep(); + + let code = run(&pin(&scratch.hosts()), Some(&path)); + + assert_eq!( + scratch.read_hosts(), + ORIGINAL, + "a missing grep truncated the file" + ); + assert_ne!(code, 0, "a missing grep must fail the command"); + } + + // Unpinning writes back only what it read, so a failed read there empties + // the file outright rather than reducing it to one line. + #[test] + fn a_failed_read_while_unpinning_leaves_the_file_byte_for_byte_as_it_was() { + let scratch = Scratch::new("failunpin"); + scratch.write_hosts(ORIGINAL); + let path = scratch.path_with_failing_grep(2); + + let code = run(&unpin(&scratch.hosts()), Some(&path)); + + assert_eq!( + scratch.read_hosts(), + ORIGINAL, + "a failed read emptied the file it could not read" + ); + assert_ne!(code, 0, "a failed read must fail the unpin"); + } + + // Status 1 means grep selected nothing, which is an outcome and not a + // failure: an empty file, or a re-pin where every line carried the marker. + // Treating it as an error would make the guard reject the ordinary case. + #[test] + fn a_file_of_nothing_but_previous_pins_is_rewritten_rather_than_refused() { + let scratch = Scratch::new("allmarked"); + scratch.write_hosts(&format!("10.0.0.4 proxy.example.com {HOSTS_PIN_MARKER}\n")); + + let code = run(&pin(&scratch.hosts()), None); + let after = scratch.read_hosts(); + + assert_eq!( + code, 0, + "a file of only stale pins should still be pinnable" + ); + assert_eq!( + after.trim(), + format!("{PIN_LINE} {HOSTS_PIN_MARKER}"), + "the stale pin should be gone and the new one present" + ); + } + + // An image that ships no hosts file has no content to protect, and grep + // cannot tell "absent" from "unreadable" -- both are status 2. The + // existence check is what keeps the guard from refusing to pin here. + #[test] + fn an_image_with_no_hosts_file_is_pinned_rather_than_refused() { + let scratch = Scratch::new("nofile"); + + let code = run(&pin(&scratch.hosts()), None); + + assert_eq!( + code, 0, + "a missing hosts file should be created, not refused" + ); + assert_eq!( + scratch.read_hosts().trim(), + format!("{PIN_LINE} {HOSTS_PIN_MARKER}") + ); + } + + #[test] + fn unpinning_removes_the_pin_and_keeps_everything_else() { + let scratch = Scratch::new("unpin"); + scratch.write_hosts(ORIGINAL); + assert_eq!(run(&pin(&scratch.hosts()), None), 0); + + let code = run(&unpin(&scratch.hosts()), None); + let after = scratch.read_hosts(); + + assert_eq!(code, 0, "unpinning a readable file should succeed"); + assert!( + !after.contains(HOSTS_PIN_MARKER), + "the pin survived the unpin; file is now:\n{after}" + ); + for line in ORIGINAL.lines() { + assert!( + after.contains(line), + "the unpin dropped {line:?}; file is now:\n{after}" + ); + } + } + // A dangling symlink was the worst shape the guard did not cover: `-e` is + // false, so no read happened, and the redirect then *created* the target. + // On a writable host bind mount that is a write outside the container, at + // a path the workload chose. + #[test] + fn pinning_refuses_a_dangling_symlink_instead_of_creating_its_target() { + let scratch = Scratch::new("dangling"); + let target = scratch.dir.join("attacker-named"); + std::os::unix::fs::symlink(&target, scratch.hosts()) + .expect("the scratch symlink should be creatable"); + + let code = run(&pin(&scratch.hosts()), None); + + assert_ne!(code, 0, "writing through a symlink should be refused"); + assert!( + !target.exists(), + "the refused pin still created {}", + target.display() + ); + } + + // The non-dangling case is the same write to somewhere the workload chose, + // it just does not announce itself by leaving a broken link behind. + #[test] + fn pinning_refuses_a_symlink_rather_than_writing_through_it() { + let scratch = Scratch::new("symlink"); + let target = scratch.dir.join("elsewhere"); + std::fs::write(&target, ORIGINAL).expect("the target should be writable"); + std::os::unix::fs::symlink(&target, scratch.hosts()) + .expect("the scratch symlink should be creatable"); + + let code = run(&pin(&scratch.hosts()), None); + + assert_ne!(code, 0, "writing through a symlink should be refused"); + assert_eq!( + std::fs::read_to_string(&target).expect("the target should still be readable"), + ORIGINAL, + "the refused pin still rewrote the symlink target" + ); + } + + // Unpinning takes the same prologue, so it has to refuse on the same terms + // -- and it is the more destructive of the two, since it writes back only + // what it read. + #[test] + fn unpinning_refuses_a_symlink_rather_than_emptying_its_target() { + let scratch = Scratch::new("unpinsymlink"); + let target = scratch.dir.join("elsewhere"); + std::fs::write(&target, ORIGINAL).expect("the target should be writable"); + std::os::unix::fs::symlink(&target, scratch.hosts()) + .expect("the scratch symlink should be creatable"); + + let code = run(&unpin(&scratch.hosts()), None); + + assert_ne!(code, 0, "writing through a symlink should be refused"); + assert_eq!( + std::fs::read_to_string(&target).expect("the target should still be readable"), + ORIGINAL, + "the refused unpin still emptied the symlink target" + ); + } + + // The refusal has to be legible in the container's stderr, or an operator + // sees only a non-zero exit from a destroyed container. + #[test] + fn the_symlink_refusal_says_why() { + let scratch = Scratch::new("symlinkmsg"); + let target = scratch.dir.join("elsewhere"); + std::os::unix::fs::symlink(&target, scratch.hosts()) + .expect("the scratch symlink should be creatable"); + + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(pin(&scratch.hosts())) + .output() + .expect("/bin/sh should be executable"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("symbolic link"), + "the refusal named no reason; stderr was:\n{stderr}" + ); + } +} diff --git a/src/backends/lxc/common/src/network_ingress.rs b/src/backends/lxc/common/src/network_ingress.rs index 7f24ef12f..6bac364aa 100644 --- a/src/backends/lxc/common/src/network_ingress.rs +++ b/src/backends/lxc/common/src/network_ingress.rs @@ -164,7 +164,7 @@ pub struct IngressManager { /// `/proc//net/if_inet6`, which names the same namespace by PID /// without entering it. A caller that cannot supply a PID must not /// construct an `IngressManager` at all. See `lxc_runner`, which aborts the - /// run when a firewall mode is requested but no init PID can be found. + /// run when no init PID can be found. netns_pid: u32, /// Per-resource ownership. What we actually created or hooked, tracked /// separately per family, so teardown attempts only the operations this run @@ -180,6 +180,15 @@ pub struct IngressManager { v6_chain_created: bool, v4_hooked: bool, v6_hooked: bool, + /// The host's own address on the bridge the container's veth joins, when + /// the caller could determine it. From inside the container namespace this + /// address *is* the host. It is what the container-to-host half of + /// `ingress.hostLoopback` has to close. + host_gateway: Option, + /// Whether this run installed the container-to-host drop. Tracked apart + /// from the chain flags because the drop lives in the namespace's built-in + /// `OUTPUT` chain rather than in a chain this run created. + host_loopback_dropped: bool, /// Whether the caller asked for a successfully installed policy to outlive /// this run (the lifecycle's `preservePolicy`). Consulted by [`Drop`] and /// not only by the runner's explicit teardown call, because `Drop` fires on @@ -425,6 +434,8 @@ impl IngressManager { v6_chain_created: false, v4_hooked: false, v6_hooked: false, + host_gateway: None, + host_loopback_dropped: false, preserve_policy: false, uses_directional_schema, } @@ -460,7 +471,53 @@ impl IngressManager { /// from the per-resource flags rather than stored, so it can never disagree /// with what teardown will actually remove. pub fn rules_applied(&self) -> bool { - self.v4_chain_created || self.v6_chain_created || self.v4_hooked || self.v6_hooked + self.v4_chain_created + || self.v6_chain_created + || self.v4_hooked + || self.v6_hooked + || self.host_loopback_dropped + } + + /// Tell the manager where the host sits on the container's bridge. + /// + /// The caller resolves this on the host, before the guest has configured + /// anything, because the container's own routing table is empty for several + /// seconds after LXC reports the container running. + pub fn set_host_gateway(&mut self, address: &str) { + self.host_gateway = Some(address.to_string()); + } + + /// Whether the policy closes the host-loopback path. + /// + /// An absent 0.8 ingress section still denies: the contract's default + /// stance for `hostLoopback` is `deny`, and a caller who wrote no ingress + /// section did not ask to open it. + fn denies_host_loopback(policy: &ContainerPolicy, uses_directional_schema: bool) -> bool { + if !uses_directional_schema { + return false; + } + Self::stated_ingress(policy, uses_directional_schema) + .is_some_and(|ingress| ingress.host_loopback == NetworkAction::Deny) + } + + /// The rule closing the container-to-host path, dropped into the + /// namespace's own `OUTPUT` chain. + /// + /// Inserted at the head: `OUTPUT` is first-match, and a later accept + /// covering the gateway would otherwise reopen the path. + fn build_host_loopback_drop_args(gateway: &str) -> Vec { + ["-I", "OUTPUT", "-d", gateway, "-j", "DROP"] + .iter() + .map(|s| (*s).to_string()) + .collect() + } + + /// The same rule as a deletion. + fn build_host_loopback_undrop_args(gateway: &str) -> Vec { + ["-D", "OUTPUT", "-d", gateway, "-j", "DROP"] + .iter() + .map(|s| (*s).to_string()) + .collect() } /// Record that this run created `family`'s chain. @@ -526,7 +583,7 @@ impl IngressManager { /// 0.7 states this in `allowLocalNetwork`; 0.8 splits it across /// `network.ingress.default` and `network.ingress.hostLoopback`, but /// LXC's single inbound chain refuses either `allow` value alike. - fn permissive_inbound_field( + pub(crate) fn permissive_inbound_field( policy: &ContainerPolicy, uses_directional_schema: bool, ) -> Option<&'static str> { @@ -654,9 +711,49 @@ impl IngressManager { self.install_family(IpFamily::V6, &ipv6_rules, &mut runner, logger)?; } + // IPv4 only, matching the one route LXC's default bridge gives the + // container to the host. `lxc-net` ships an IPv4 address and leaves + // `LXC_IPV6_ADDR` unset, leaving no IPv6 path to close. + if Self::denies_host_loopback(policy, uses_directional_schema) { + self.install_host_loopback_drop(&mut runner, logger)?; + } + Ok(true) } + /// Close the container-to-host half of `ingress.hostLoopback: deny`. + /// + /// Fails the start when the host address is unknown. The alternative is a + /// container running with the path open under a policy that says it is + /// shut, which is the unenforceable-policy case this file already refuses + /// for inbound IPv6. + fn install_host_loopback_drop( + &mut self, + runner: &mut dyn CommandRunner, + logger: &mut Logger, + ) -> Result<(), String> { + let Some(gateway) = self.host_gateway.clone() else { + return Err(format!( + "network.ingress.hostLoopback='deny' has to block the container-to-host \ + path, and the host's address on the container's bridge could not be \ + determined for chain '{}'. Refusing to start with an unenforceable \ + host-loopback policy.", + self.chain_name + )); + }; + + let args = Self::build_host_loopback_drop_args(&gateway); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + self.run(runner, IpFamily::V4.binary(), &arg_refs, logger) + .map_err(RunError::into_message)?; + self.host_loopback_dropped = true; + + logger.log_line(&format!( + "Host-loopback deny: container traffic to the host at {gateway} is dropped." + )); + Ok(()) + } + /// Install one family's chain: reset any leftover to a known-empty baseline, /// create the chain fresh, append the body rules, then hook into INPUT last /// — marking ownership immediately after each specific command succeeds. @@ -727,9 +824,9 @@ impl IngressManager { /// Instead we *force* a known state: remove every INPUT reference (there /// may be more than one), then flush and delete any existing chain. This /// reuses the same over-approximating teardown planner and executor as - /// `force_cleanup` ("assume everything might exist"), so install-time reset - /// and ownership teardown share one shape. A genuinely absent rule or chain - /// is a no-op; any real failure aborts install fail-closed. + /// ownership teardown ("assume everything might exist"), so install-time + /// reset and ownership teardown share one shape. A genuinely absent rule or + /// chain is a no-op; any real failure aborts install fail-closed. fn reset_family( &mut self, family: IpFamily, @@ -923,7 +1020,49 @@ impl IngressManager { )); let steps = self.owned_teardown_steps(); - self.execute_teardown(&steps, runner, logger) + let chain_result = self.execute_teardown(&steps, runner, logger); + let host_loopback_result = self.remove_host_loopback_drop(runner, logger); + + match (chain_result, host_loopback_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(chain), Ok(())) => Err(chain), + (Ok(()), Err(loopback)) => Err(loopback), + (Err(chain), Err(loopback)) => Err(format!("{chain}; {loopback}")), + } + } + + /// Remove the container-to-host drop this run installed. + /// + /// Keeps ownership on a real failure so [`Drop`] retries, and treats + /// iptables' own missing-rule message as done. + fn remove_host_loopback_drop( + &mut self, + runner: &mut dyn CommandRunner, + logger: &mut Logger, + ) -> Result<(), String> { + if !self.host_loopback_dropped { + return Ok(()); + } + let Some(gateway) = self.host_gateway.clone() else { + self.host_loopback_dropped = false; + return Ok(()); + }; + + let args = Self::build_host_loopback_undrop_args(&gateway); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + match self.run(runner, IpFamily::V4.binary(), &arg_refs, logger) { + Ok(()) => { + self.host_loopback_dropped = false; + Ok(()) + } + Err(RunError::Exit { ref stderr, .. }) + if StepKind::Unhook.stderr_means_absent(stderr) => + { + self.host_loopback_dropped = false; + Ok(()) + } + Err(e) => Err(e.into_message()), + } } /// Execute an ordered list of teardown [`TeardownStep`]s, clearing each @@ -1062,49 +1201,6 @@ impl IngressManager { } } - /// Best-effort cleanup of iptables state when the owning [`IngressManager`] - /// instance isn't reachable (e.g. signal-time cleanup from the watchdog - /// thread). We do not know which resources a prior run installed, so this - /// assumes all of them and lets teardown over-approximate: a genuinely - /// absent chain or hook is treated as an already-removed no-op. The caller - /// supplies the container's init PID, which it only has while the netns - /// still exists — once the container is gone there is nothing to remove, so - /// the caller simply does not call this. The result is ignored: this path is - /// best-effort by nature. - pub fn force_cleanup(container_name: &str, netns_pid: u32, logger: &mut Logger) { - let mut runner = NsenterRunner; - Self::force_cleanup_with(container_name, netns_pid, &mut runner, logger); - } - - /// The body of [`Self::force_cleanup`] against an injectable - /// [`CommandRunner`] — the real path passes [`NsenterRunner`]; tests pass a - /// runner that captures the planned argv without spawning. Constructs the - /// over-approximating manager and runs teardown; the result is ignored - /// because this path is best-effort by nature. - fn force_cleanup_with( - container_name: &str, - netns_pid: u32, - runner: &mut dyn CommandRunner, - logger: &mut Logger, - ) { - let mut mgr = Self::for_full_reset(container_name, netns_pid); - let _ = mgr.remove_firewall_rules_with(runner, logger); - } - - /// A manager that assumes every resource might exist, for over-approximating - /// cleanup where we do not know what a dead run installed. Used by - /// [`Self::force_cleanup`]. - fn for_full_reset(container_name: &str, netns_pid: u32) -> Self { - // Teardown removes whatever is there; no rule is built, so the schema - // this manager reports is never read. - let mut mgr = Self::new(container_name, netns_pid, false); - mgr.v4_chain_created = true; - mgr.v6_chain_created = true; - mgr.v4_hooked = true; - mgr.v6_hooked = true; - mgr - } - /// Build the full argv for running `binary args...` inside this container's /// network namespace: `["nsenter", "-t", , "-n", binary, args...]`. /// @@ -2069,7 +2165,9 @@ mod tests { ); } - /// A firewall-mode policy with `allowLocalNetwork: true`. + /// A policy with `allowLocalNetwork: true` under an enforcement mode that + /// installs a firewall, so the refusal is reached rather than skipped by + /// the no-firewall gate. fn permissive_firewall_policy() -> ContainerPolicy { ContainerPolicy { allow_local_network: true, @@ -2182,11 +2280,10 @@ mod tests { assert_eq!(tail, want, "the wrapped command args must be preserved"); } - /// The teardown path (used by `remove_firewall_rules`, `Drop`, and - /// `force_cleanup`) must also route every command through `nsenter`, and - /// only against the manager's own chain. Assert the actual planned argv - /// rather than just the pure helper: a command that skips `nsenter` would - /// execute against the host's tables. + /// The teardown path (used by `remove_firewall_rules` and `Drop`) must also + /// route every command through `nsenter`, and only against the manager's own + /// chain. Assert the actual planned argv rather than just the pure helper: a + /// command that skips `nsenter` would execute against the host's tables. #[test] fn teardown_commands_are_nsenter_prefixed_and_chain_scoped() { let pid = 4242u32; @@ -2279,84 +2376,6 @@ mod tests { mgr.v4_hooked = false; } - /// `force_cleanup` must actually run — over the injectable runner — so a - /// regression inside it fails this test, and it must plan to remove *all* - /// resources (it cannot know what a dead run installed). Assert the real - /// commands `force_cleanup_with` issues: every one nsenter-scoped to the - /// container netns, naming only our chain, unhook before flush before - /// delete, for both families. A no-leftover script (everything reports - /// already-absent) lets cleanup complete without error. - #[test] - fn force_cleanup_removes_all_resources_via_nsenter() { - let pid = 9001u32; - let container = "force-cleanup-container"; - let chain = IngressManager::new(container, pid, false) - .chain_name() - .to_string(); - let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); - - let mut runner = FakeRunner { - calls: Vec::new(), - respond: |argv: &[String]| match verb(argv) { - "-D" => Err(absent_rule()), - _ => Err(absent_chain()), - }, - }; - - IngressManager::force_cleanup_with(container, pid, &mut runner, &mut logger); - - // Six commands: unhook + flush + delete, per family. - assert_eq!( - runner.calls.len(), - 6, - "force_cleanup must issue all six teardown commands, got {:?}", - runner.calls - ); - - // Every command is nsenter-scoped and names only our chain. - for argv in &runner.calls { - assert_eq!( - &argv[..4], - &[ - "nsenter".to_string(), - "-t".to_string(), - pid.to_string(), - "-n".to_string(), - ], - "force_cleanup command must be nsenter-scoped to the netns: {argv:?}" - ); - assert!( - argv[4] == "iptables" || argv[4] == "ip6tables", - "force_cleanup command must target a packet-filter binary: {argv:?}" - ); - assert!( - argv.iter().any(|a| a == &chain), - "force_cleanup command must name our chain '{chain}': {argv:?}" - ); - } - - // Both families are covered. - for binary in ["iptables", "ip6tables"] { - assert!( - runner.calls.iter().any(|a| a[4] == binary), - "force_cleanup must cover {binary}" - ); - } - - // Per family: unhook (-D) before flush (-F) before delete (-X). - for binary in ["iptables", "ip6tables"] { - let idx = |v: &str| { - runner - .calls - .iter() - .position(|a| a[4] == binary && verb(a) == v) - .unwrap_or_else(|| panic!("{binary} {v} command missing")) - }; - assert!(idx("-D") < idx("-F"), "{binary}: unhook must precede flush"); - assert!(idx("-F") < idx("-X"), "{binary}: flush must precede delete"); - } - } - /// The pure builder must return the chain *body* and the `INPUT` *hook* as /// separate values, so install cannot confuse one for the other and needs no /// `-I`-filtering. The body carries neither the `-N` creation nor the `-I` diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index e781f8638..49bbc2e8f 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -170,6 +170,26 @@ impl FirewallRuleArgs { /// container name, so a chain already present under our name belongs to an /// earlier or concurrent run and is not ours to remove. /// +/// What ` -S FORWARD` was able to tell us. +/// +/// The two failure cases are not the same question. A tool that is not +/// installed cannot be holding a hook, and answering "present" for it invents a +/// residual that teardown can never remove: every attempt fails, ownership is +/// retained, and stop and deprovision report an error after doing everything +/// right. A tool that is installed but whose ruleset would not read is +/// genuinely unknown, and that one has to fail closed. +/// +/// The distinction matters on any IPv6-disabled host, where setup deliberately +/// permits a missing `ip6tables` and installs only v4 state. +enum ForwardProbe { + /// `-S FORWARD` succeeded; this is its output. + Dump(String), + /// The tool could not be spawned, so it holds no rules at all. + ToolAbsent, + /// The tool ran and refused, so its ruleset is unknown. + Unreadable, +} + /// Visible to the crate (with private fields) purely so `signal_cleanup` can /// carry the value from the runner thread to the watchdog thread. The watchdog /// never inspects it; it only hands it back to [`NetworkIptablesManager::force_cleanup`]. @@ -239,7 +259,7 @@ impl CreatedResources { /// Only reachable from the signal path, which is Linux-only; kept /// compiled on every target so Windows and macOS CI still type-check it. #[cfg_attr(not(target_os = "linux"), allow(dead_code))] - fn is_empty(&self) -> bool { + pub(crate) fn is_empty(&self) -> bool { self.v4.is_empty() && self.v6.is_empty() } @@ -558,6 +578,65 @@ impl NetworkIptablesManager { None } + /// The container's host-side veth as it exists on the host right now. + /// + /// [`discover_veth_interface`](Self::discover_veth_interface) reports the + /// name liblxc recorded when it created the interface, which stops being + /// the live name once MXC's pin hook has renamed it. That hook is written + /// into the container's own config and outlives the process that added it, + /// so any later start of that container renames the interface again -- + /// including a start by a caller that never installed the hook and would + /// otherwise scope its rules to a name no interface answers to, leaving the + /// container unfiltered. + /// + /// `pin_hook_present` is that container's own answer to whether it carries + /// the hook, from + /// [`has_veth_pin_hook`](crate::lxc_bindings::LxcContainer::has_veth_pin_hook). + /// The question has to be asked of the container: an interface answering to + /// the pinned name proves only that the name is taken, not that it is taken + /// by this container, and a host that cannot be asked would answer "no" and + /// send teardown back to the stale name. + pub fn live_veth_interface(container_name: &str, pin_hook_present: bool) -> Option { + if pin_hook_present { + return Some(Self::deterministic_veth_name(container_name)); + } + Self::discover_veth_interface(container_name) + } + + /// The host's own IPv4 address on the bridge this veth is attached to. + /// + /// Read from the bridge on the host rather than from inside the container. + /// That makes it available as soon as the veth exists. The container's + /// routing table is empty for several seconds after LXC reports it + /// running, which makes the container's own default route useless at + /// policy-install time. + /// + /// `None` when the veth is not bridged, or the bridge carries no IPv4 + /// address. Both mean there is no bridge address for the container to + /// reach the host at. + pub fn host_gateway_for_veth(veth: &str) -> Option { + let master = std::fs::read_link(format!("/sys/class/net/{veth}/master")).ok()?; + let bridge = master.file_name()?.to_str()?.to_string(); + + let output = Command::new("ip") + .args(["-4", "-o", "addr", "show", "dev", &bridge]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + // Each line reads ": inet
/ ...". + let text = String::from_utf8_lossy(&output.stdout); + let mut tokens = text.split_whitespace(); + while let Some(token) = tokens.next() { + if token == "inet" { + return tokens.next()?.split('/').next().map(str::to_string); + } + } + None + } + /// Set the veth interface name for the container. pub fn set_veth_interface(&mut self, iface: &str) { self.veth_interface = Some(iface.to_string()); @@ -1008,6 +1087,44 @@ impl NetworkIptablesManager { } } + /// The first destination entry that names no destination at all, as the + /// configuration field it came from and the entry itself. + /// + /// Only syntax is judged. `10.0.0.0/33` matches nothing on any host at any + /// moment, which makes it a typo in the configuration file. Whether a + /// well-formed hostname currently resolves is a question about this + /// moment's DNS, and it stays with `resolve_host` and the unresolved-host + /// warning. + fn malformed_destination(policy: &ContainerPolicy) -> Option<(&'static str, String)> { + let lists = [ + ("network.allowedHosts", &policy.allowed_hosts), + ("network.blockedHosts", &policy.blocked_hosts), + ]; + for (field, hosts) in lists { + for host in hosts { + if Self::is_malformed_destination(host) { + return Some((field, host.clone())); + } + } + } + None + } + + /// Whether `host` can never name a destination, whatever the network does. + fn is_malformed_destination(host: &str) -> bool { + if host.trim().is_empty() { + return true; + } + + let rewritten = Self::ipv4_mapped_destination(host); + let host = rewritten.as_deref().unwrap_or(host); + + // A slash means the entry was written as a CIDR, so it either parses + // as one or it is a typo. Without a slash the entry is a hostname, and + // whether it names anything is a DNS question rather than a syntax one. + host.contains('/') && Self::destination_family(host).is_none() + } + fn rule_action_arg(action: &RuleAction) -> &'static str { match action { RuleAction::Allow => "ACCEPT", @@ -1617,13 +1734,10 @@ impl NetworkIptablesManager { /// the whole of this backend's deny-precedence guarantee. See /// [`Self::lower_legacy_hosts`] for why that order is load-bearing. /// - /// An entry that resolves to nothing programs no rule, and the two - /// directions are not symmetric. An unwritten deny leaves reachable a - /// destination the operator named as unreachable, so it is a hard error - /// wherever something else in the chain would accept that destination. - /// An unwritten allow only withholds traffic that was meant to be - /// permitted, which costs availability and can never widen what the - /// container reaches, so it is always a warning. + /// An entry that resolves to nothing programs no rule. Neither direction + /// can be honored that way, and both refuse. A deny is the exception only + /// where the default already blocks what the entry named -- that default + /// enforces it with no rule of its own. fn build_policy_rules_logged( chain_name: &str, policy: &ContainerPolicy, @@ -1634,6 +1748,7 @@ impl NetworkIptablesManager { let mut args = FirewallRuleArgs::default(); let mut unresolved_denies: Vec<&str> = Vec::new(); let mut catch_all_allows: Vec<&str> = Vec::new(); + let mut programmed_lines: Vec = Vec::new(); let entries = Self::lower_egress(policy, uses_directional_schema); for entry in &entries { let host = entry.destination.as_str(); @@ -1649,9 +1764,15 @@ impl NetworkIptablesManager { host )); } - if matches!(action, RuleAction::Deny) { - unresolved_denies.push(host); + if matches!(action, RuleAction::Allow) { + return Err(format!( + "allowed host '{}' resolved to no address, so no rule can be \ + programmed to reach it; refusing to apply a policy that does \ + not grant a destination the configuration names", + host + )); } + unresolved_denies.push(host); logger.log_line(&format!("Warning: could not resolve host '{}'", host)); } else if matches!(action, RuleAction::Allow) @@ -1673,17 +1794,20 @@ impl NetworkIptablesManager { &action, entry.matching, ); - // Log each destination rule that will be programmed, derived from - // the built args rather than from `destinations`, so that removing - // destination-rule emission also removes these lines. This is the - // observable surface the end-to-end scripts assert on to prove a - // rule for a specific destination was actually generated while the - // chain is live (a warning-only or chain-only run would not). + // Hold each line until the whole policy survives. A later entry can + // still refuse the run, and the caller submits nothing when it does, + // so a line emitted here would name a rule that never reached the + // chain. Derived from the built args rather than from + // `destinations`, so that removing destination-rule emission also + // removes these lines. This is the observable surface the + // end-to-end scripts assert on to prove a rule for a specific + // destination was actually generated while the chain is live (a + // warning-only or chain-only run would not). for rule in &rule_args.ipv4 { - logger.log_line(&format!("Programmed iptables rule: {}", rule.join(" "))); + programmed_lines.push(format!("Programmed iptables rule: {}", rule.join(" "))); } for rule in &rule_args.ipv6 { - logger.log_line(&format!("Programmed ip6tables rule: {}", rule.join(" "))); + programmed_lines.push(format!("Programmed ip6tables rule: {}", rule.join(" "))); } args.extend(rule_args); } @@ -1725,6 +1849,9 @@ impl NetworkIptablesManager { .join(", ") )); } + for line in &programmed_lines { + logger.log_line(line); + } Ok(args) } @@ -1733,11 +1860,371 @@ impl NetworkIptablesManager { Self::run_firewall_command("iptables", args, logger) } + /// FNV-1a over the container name, used to derive a fixed-width token for + /// names with a tight length budget. + /// + /// Not a security primitive: FNV-1a is invertible. It exists to remove the + /// accidental collisions that truncation produces, not to make the token + /// unforgeable. + fn name_hash(name: &str) -> u64 { + const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + + let mut hash = FNV_OFFSET; + for byte in name.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash + } + + /// Encode the full 64-bit name hash as a fixed 11-character base36 token + /// (`0-9a-z`). 36^11 ≈ 2^56.9, so the hash is reduced modulo 36^11 rather + /// than truncated to 32 bits: this preserves ~56.9 bits of the hash while + /// fitting the tight `IFNAMSIZ` budget of the veth interface name. Base36 + /// is valid in Linux interface names. Zero-padded so the token is always + /// exactly 11 characters, keeping the derived name fixed-width. + fn hash_token(name: &str) -> String { + const ALPHABET: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + // 36^11 = 131_621_703_842_267_136 ≈ 2^56.9, fits in u64. + const MODULUS: u64 = 36u64.pow(11); + + let mut value = Self::name_hash(name) % MODULUS; + let mut buf = [b'0'; 11]; + for slot in buf.iter_mut().rev() { + *slot = ALPHABET[(value % 36) as usize]; + value /= 36; + } + // `value` is now 0: 11 base36 digits cover the full [0, 36^11) range. + String::from_utf8(buf.to_vec()).expect("base36 alphabet is valid ASCII") + } + + /// Derive the deterministic host-side veth interface name for a container. + /// + /// The firewall must be installed *before* the container starts, but the + /// veth pair liblxc creates by default has a random name that is only known + /// once the container is running. A `lxc.hook.start-host` hook renames the + /// host end to this name after liblxc creates it but before the container's + /// init runs, which lets the FORWARD hook reference the interface by name + /// ahead of time — iptables accepts a not-yet-existing interface — so there + /// is no window in which a started container has unfiltered network. That + /// hook key carries no interface index, so enforcement does not depend on + /// which `lxc.net.` the container numbers its interface. + /// + /// The name must fit the kernel `IFNAMSIZ` limit of 15 characters and be + /// unique per container, so a `mxcv` prefix (4) is followed by an + /// 11-character base36 hash token (15 chars total). + pub fn deterministic_veth_name(container_name: &str) -> String { + format!("mxcv{}", Self::hash_token(container_name)) + } + + /// Whether this manager currently owns any chain or FORWARD hook. + /// + /// The state-aware start path uses this to decide whether a failed apply + /// left anything of ours behind that must be torn down through *this* + /// manager rather than a fresh one. + pub fn owns_resources(&self) -> bool { + !self.created.is_empty() + } + + /// The set of chains and hooks this manager created, for handing to + /// signal-time cleanup. + pub(crate) fn created(&self) -> CreatedResources { + self.created + } + /// Run an ip6tables command and return success/failure. fn run_ip6tables(args: &[&str], logger: &mut Logger) -> Result { Self::run_firewall_command("ip6tables", args, logger) } + /// Read ` -S FORWARD`, distinguishing a missing tool from a ruleset + /// that would not read. + /// + /// Only [`NotFound`](std::io::ErrorKind::NotFound) means the tool is absent. + /// A spawn can also fail because the executable is there but unusable -- + /// `PermissionDenied`, an exhausted descriptor table, or a transient + /// resource shortage -- and those say nothing about what is installed. + /// Reading them as "absent" would report an empty FORWARD chain on a host + /// whose hooks are still in place, so they fail closed as `Unreadable`. + fn probe_forward_chain(tool: &str) -> ForwardProbe { + match Command::new(tool).args(["-S", "FORWARD"]).output() { + Ok(o) if o.status.success() => { + ForwardProbe::Dump(String::from_utf8_lossy(&o.stdout).into_owned()) + } + Ok(_) => ForwardProbe::Unreadable, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => ForwardProbe::ToolAbsent, + Err(_) => ForwardProbe::Unreadable, + } + } + + /// Whether `chain` currently exists in `tool`'s filter table. + /// + /// `-S ` fails the same way for a chain that is absent and for a + /// ruleset that cannot be read at all, so the exit status alone does not + /// answer the question. FORWARD always exists, so it separates the two: if + /// FORWARD reads, the tool works and this chain is genuinely gone. If it + /// does not, the answer is unknown, and reporting "absent" there would let + /// authoritative teardown declare a clean host while the chain survives. + /// + /// A host without the tool at all reports absent, which is the right answer + /// for teardown: a chain that cannot be addressed cannot be removed, and + /// there is nothing to remove. A tool that exists but could not be run is a + /// different case entirely -- nothing was learned, so it is an error rather + /// than a clean bill of health. + fn chain_exists(tool: &str, chain: &str) -> Result { + match Command::new(tool).args(["-S", chain]).output() { + Ok(o) if o.status.success() => Ok(true), + Ok(o) => match Self::probe_forward_chain(tool) { + ForwardProbe::Dump(_) | ForwardProbe::ToolAbsent => Ok(false), + ForwardProbe::Unreadable => Err(format!( + "{tool} could not read chain {chain} and could not read FORWARD either, so \ + whether the chain exists is unknown: {}", + String::from_utf8_lossy(&o.stderr).trim() + )), + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(format!( + "{tool} is installed but could not be run, so whether chain {chain} exists is \ + unknown: {e}" + )), + } + } + + /// Build an ownership record from what is actually installed right now, + /// rather than from what this process created. + /// + /// Teardown is gated on a [`CreatedResources`] record so that a process + /// removes only what it installed. `stop` and `deprovision` cannot satisfy + /// that gate: they run in a different process from the `start` that created + /// the chain, so their record is empty and a record-gated teardown would do + /// nothing. They are nonetheless the documented remedy for a stranded chain + /// -- `apply_network_policy` tells the caller to "stop or deprovision the + /// sandbox to clear it", and the start path `mem::forget`s its manager + /// precisely because stop and deprovision are what remove the chain later. + /// A no-op teardown would strand the chain and leave the sandbox unable to + /// start again, since the next start fails on the chain it finds. + /// + /// Observing live state keeps that authoritative teardown honest. An + /// all-true record would assume every resource exists, then fail `-X` + /// against chains that never did, log those failures, and retain residual + /// ownership that schedules a pointless retry in `Drop`. Probing reports + /// exactly what is present, so an already-clean container issues no + /// commands at all and a half-installed one removes only its own half. + /// Classify the FORWARD rules this backend installs, from a live dump. + /// + /// Every delete in [`teardown_family_forward`](Self::teardown_family_forward) + /// is gated on the matching ownership bit, so a bit left false on the + /// authoritative path leaves a rule installed that nothing will ever + /// remove. Rules are recognized by the matches they carry rather than by + /// their text, because `iptables -S` does not echo back what was submitted: + /// a rule built as `--state ESTABLISHED,RELATED` prints as + /// `RELATED,ESTABLISHED`, so comparing against a rebuilt spec would miss + /// every return rule. + /// + /// The two hook forms are told apart by `--physdev-in` alone, so they are + /// still recognized when the interface is unknown. The return rules jump to + /// ACCEPT rather than to the chain, so the interface is the only thing that + /// identifies them as ours; without it they stay unclaimed. + fn observe_forward_rules(dump: &str, chain: &str, iface: Option<&str>) -> FamilyResources { + let mut found = FamilyResources::default(); + for line in dump.lines() { + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.first() != Some(&"-A") || tokens.get(1) != Some(&"FORWARD") { + continue; + } + let targets = |name: &str| tokens.windows(2).any(|w| w[0] == "-j" && w[1] == name); + let matches_iface = + |opt: &str, val: &str| tokens.windows(2).any(|w| w[0] == opt && w[1] == val); + // The return rules this backend installs are the only ones it may + // claim. An interface-scoped ACCEPT that carries no connection-state + // match belongs to someone else, and claiming it would have teardown + // submit our fuller specification against it -- a delete that cannot + // match, reported forever as a residual nothing can clear. + let accepts_established = || { + tokens.windows(2).any(|w| { + w[0] == "--state" && { + let mut states: Vec<&str> = w[1].split(',').collect(); + states.sort_unstable(); + states == ["ESTABLISHED", "RELATED"] + } + }) + }; + // The same trap one qualifier further out. A foreign rule can carry + // the interface, the state match, and a narrowing this backend never + // writes -- `-p tcp`, an address, a port, a negation -- and the + // delete rebuilt from our own specification would not name it, so it + // could not match. Anything outside the vocabulary these rules are + // built from is therefore somebody else's rule. + let only_our_vocabulary = || { + tokens.iter().all(|t| { + if *t == "!" { + return false; + } + if !t.starts_with('-') { + return true; + } + matches!(*t, "-A" | "-o" | "-m" | "--physdev-out" | "--state" | "-j") + }) + }; + + if targets(chain) { + if tokens.contains(&"--physdev-in") { + found.physdev_hook = true; + } else { + found.hook = true; + } + } + if let Some(i) = iface { + if targets("ACCEPT") && accepts_established() && only_our_vocabulary() { + if matches_iface("--physdev-out", i) { + found.physdev_return = true; + } else if matches_iface("-o", i) { + found.return_rule = true; + } + } + } + } + found + } + + fn observe_existing( + chain_name: &str, + veth_interface: Option<&str>, + ) -> Result { + let family = |tool: &str| -> Result { + let mut observed = match Self::probe_forward_chain(tool) { + ForwardProbe::Dump(dump) => { + Self::observe_forward_rules(&dump, chain_name, veth_interface) + } + // A tool that is not installed holds no rules, so it holds no + // hook. See `hook_present` for why that is an answer and not a + // guess. + ForwardProbe::ToolAbsent => FamilyResources::default(), + // Fail closed on an unreadable FORWARD, for the reason + // `hook_present` gives: claiming no hook would let the chain be + // flushed while a jump into it still stands, and an emptied + // chain that is still hooked returns to its caller instead of + // reaching its own closing DROP, which unfilters a live + // container. Both hook forms are claimed, not just the plain + // one, because either alone gates the flush: clearing the plain + // hook would otherwise open that gate while an unobserved + // physdev hook still referenced the chain. A delete for a rule + // that never existed fails and holds the resource as residual, + // which reports the stop as failed and retries -- the losing + // side of a trade whose other side is an unfiltered container. + ForwardProbe::Unreadable => FamilyResources { + hook: true, + physdev_hook: true, + ..FamilyResources::default() + }, + }; + observed.chain = Self::chain_exists(tool, chain_name)?; + Ok(observed) + }; + Ok(CreatedResources { + v4: family("iptables")?, + v6: family("ip6tables")?, + }) + } + + /// Whether a FORWARD jump to `chain` should be treated as installed, given + /// what [`probe_forward_chain`](Self::probe_forward_chain) could learn. + /// + /// An unreadable FORWARD answers `true`, the same verdict + /// [`remove_forward_hooks`](Self::remove_forward_hooks) gives it. Answering + /// `false` would clear the `created.*_hook` gate in + /// [`teardown_created`](Self::teardown_created), skip hook removal + /// entirely, and let `teardown_chain` flush a chain whose jump is still + /// installed -- and an emptied chain that is still hooked returns to its + /// caller instead of reaching its own closing DROP, which unfilters a live + /// container. Being wrong this way strands a chain for a later pass to + /// reclaim; being wrong the other way removes the filtering. + /// + /// A tool that is not installed is the one case where `false` is not a + /// guess but the answer: it holds no rules, so it holds no hook. Reporting + /// `true` there would record a residual on every IPv6-disabled host, and + /// teardown would then fail forever against an `ip6tables` that does not + /// exist -- turning a clean stop into a reported error. + /// + /// Pure so that fail-open-versus-fail-closed decision can be unit-tested + /// without iptables or a privileged host. + fn hook_present(forward: &ForwardProbe, chain: &str) -> bool { + match forward { + ForwardProbe::Dump(dump) => !Self::forward_hook_deletions(dump, chain).is_empty(), + ForwardProbe::ToolAbsent => false, + ForwardProbe::Unreadable => true, + } + } + + /// Parse ` -S FORWARD` output into a `-D` argument list for every + /// rule that jumps to `chain`. + /// + /// Each `-A FORWARD ... -j ` line becomes the same rule spec with the + /// leading `-A` swapped for `-D`, so the delete matches the exact rule that + /// was appended regardless of the `-i`/`-o` interface qualifiers it carries. + /// Split out from process execution so the matching is testable without + /// iptables, and family-agnostic because both tables print the same syntax. + fn forward_hook_deletions(forward_dump: &str, chain: &str) -> Vec> { + let mut deletions = Vec::new(); + for line in forward_dump.lines() { + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.first() != Some(&"-A") || tokens.get(1) != Some(&"FORWARD") { + continue; + } + let jumps_to_chain = tokens.windows(2).any(|w| w[0] == "-j" && w[1] == chain); + if !jumps_to_chain { + continue; + } + let mut deletion: Vec = tokens.iter().map(|t| t.to_string()).collect(); + deletion[0] = "-D".to_string(); + deletions.push(deletion); + } + deletions + } + + /// Delete every rule in `tool`'s FORWARD chain that jumps to `chain_name`, + /// whatever interface each was scoped to. + /// + /// Reads the live ruleset with ` -S FORWARD` and issues a matching + /// `-D` for each jump, so the hook is removed even when the veth is unknown + /// or was never discovered. Deleting by the remembered `-i ` instead + /// leaves the jump behind on any teardown that never learned the veth -- + /// signal-time `force_cleanup` is exactly that case. Because the chain then + /// stays referenced, the `-X` fails too and the whole chain leaks, in a + /// state nothing later can reclaim. + /// + /// Returns whether FORWARD is now free of jumps to this chain. That verdict + /// gates the `-F` that follows, because flushing is the dangerous half of + /// teardown: an emptied user chain returns to its caller instead of reaching + /// its own closing DROP, so a chain that is still hooked but no longer + /// filtering is a fail-open. Deleting is the safe half -- `-X` on a still + /// referenced chain simply fails and the chain stays, owned and filtering, + /// for a later teardown to retry. + /// + /// An unreadable FORWARD answers `false`: the question was not answered, so + /// it is read as still hooked. Being wrong that way costs a retry; being + /// wrong the other way unfilters a live container. A tool that is not + /// installed answers `true`, for the same reason + /// [`hook_present`](Self::hook_present) reports no hook for it -- it holds + /// no rules, so there is no hook left to remove, and answering `false` + /// would schedule a retry that can never succeed. + fn remove_forward_hooks(tool: &str, chain_name: &str, logger: &mut Logger) -> bool { + let dump = match Self::probe_forward_chain(tool) { + ForwardProbe::Dump(dump) => dump, + ForwardProbe::ToolAbsent => return true, + ForwardProbe::Unreadable => return false, + }; + for deletion in Self::forward_hook_deletions(&dump, chain_name) { + let args: Vec<&str> = deletion.iter().map(String::as_str).collect(); + let _ = Self::run_firewall_command(tool, &args, logger); + } + // Re-read rather than trust the deletes. A `-D` can fail for a rule + // another process is also tearing down, and the exit code does not say + // whether the rule is gone -- only whether this call removed it. + !Self::hook_present(&Self::probe_forward_chain(tool), chain_name) + } + /// Classify whether `ip6tables` is usable, given whether the read-only /// probe succeeded and whether the host currently has active IPv6. Pure so /// the fail-open-vs-fail-closed decision can be unit-tested without a @@ -1789,6 +2276,11 @@ impl NetworkIptablesManager { /// egress-capable, so a line is treated as evidence of active IPv6 only /// when its device is something other than `lo`. /// + /// A successful read carrying no egress-capable line is `Unknown` rather + /// than `Inactive`: the file's existence already proves the stack is + /// loaded, and an address can be configured at any point during the run. + /// Only a kernel that never created the file is a confirmed negative. + /// /// The error handling is deliberate: /// - A `NotFound` error **while `/proc/net` exists** means the kernel /// never created the file (IPv6 disabled at boot via `ipv6.disable=1`, @@ -1827,7 +2319,11 @@ impl NetworkIptablesManager { if has_egress_capable_interface { HostIpv6State::Active } else { - HostIpv6State::Inactive + // The file is there, so the kernel carries IPv6 and an + // address can arrive while the container runs. Reporting + // that as a confirmed negative installs IPv4-only rules + // against a stack that may start carrying egress. + HostIpv6State::Unknown } } // A missing `/proc/net/if_inet6` is only evidence that IPv6 is off @@ -2041,6 +2537,19 @@ impl NetworkIptablesManager { .to_string(), ); } + if policy.network_mode_specified { + return Err( + "network.enforcementMode='capabilities' names a posture LXC cannot \ + enforce. This backend filters with iptables and has no capability \ + mechanism for the network, so under 'capabilities' no rule is \ + installed in either direction and the container runs with the \ + network its host gives it -- not the one this configuration states. \ + Set network.enforcementMode to 'firewall' or 'both', or remove the \ + network policy. Refusing to apply rather than reporting success for \ + an enforcement that did not happen." + .to_string(), + ); + } logger.log_line("Network policy requests no firewall; skipping iptables."); return Ok(true); } @@ -2156,8 +2665,8 @@ impl NetworkIptablesManager { Err(e) => { let residual = Self::teardown_created( &self.chain_name, - self.veth_interface.as_deref(), &created, + self.veth_interface.as_deref(), logger, ); Err((e, residual)) @@ -2181,6 +2690,20 @@ impl NetworkIptablesManager { self.chain_name )); + // A destination that does not parse is a typo in the configuration + // file, not a lookup that failed: it matches nothing on any host at any + // moment. Refusing here, ahead of the ip6tables probe and the first + // chain, means a mistyped entry never programs a partial policy and + // leaves nothing to roll back. + if let Some((field, entry)) = Self::malformed_destination(policy) { + return Err(format!( + "{} entry '{}' is not a valid destination, so no rule can be programmed for \ + it; refusing to apply a policy that would silently omit it. Use an IP \ + address, a CIDR block such as 10.0.0.0/24, or a hostname.", + field, entry + )); + } + // A blocked destination stays reachable through the proxy, which MXC // does not configure, so programming the rest would report success for // a control that is not in effect. @@ -2193,6 +2716,17 @@ impl NetworkIptablesManager { ); } + // Proxy mode carries the proxy accepts and its closing drop and nothing + // else. An allow list supplied beside it is never programmed, and the + // caller named destinations that do not take effect. + if !proxy_endpoints.is_empty() && !policy.allowed_hosts.is_empty() { + return Err( + "network.proxy cannot be combined with allowedHosts: proxy mode permits the \ + proxy endpoint and nothing else, so the allow list would not be programmed" + .to_string(), + ); + } + // Probe ip6tables once. Skip the v6 chain when the kernel has no // active IPv6 (nothing to filter), but fail closed when IPv6 is live // and ip6tables is missing or broken rather than silently leaving @@ -2240,18 +2774,13 @@ impl NetworkIptablesManager { // // The allow list is not programmed either: an entry naming // anything but the proxy contradicts the model, and one naming the - // proxy is already covered. A block list never reaches here. + // proxy is already covered. Supplying either list is refused ahead + // of this block. let proxy_rules = Self::build_proxy_chain_rule_args(&self.chain_name, proxy_endpoints); Self::run_iptables_rule_args(&proxy_rules, logger)?; for rule in &proxy_rules { logger.log_line(&format!("Programmed iptables rule: {}", rule.join(" "))); } - if !policy.allowed_hosts.is_empty() { - logger.log_line( - "Warning: network.proxy is configured, so allowedHosts is not programmed; \ - the container may reach the proxy and nothing else.", - ); - } if ipv6_enabled { logger.log_line( "IPv6 egress is denied outright while a proxy is configured: the proxy \ @@ -2601,35 +3130,82 @@ impl NetworkIptablesManager { /// returning, so signal-time cleanup retries exactly the leftovers. fn teardown_created( chain_name: &str, - veth_interface: Option<&str>, created: &CreatedResources, + veth_interface: Option<&str>, logger: &mut Logger, ) -> CreatedResources { let mut residual = *created; - // Remove from FORWARD only for families this attempt hooked, and only - // the hook forms it actually installed. Both specs come from the same - // builders used at insertion, because iptables deletes by full rule - // specification: a spec that differs by even one match -- `-o` instead - // of `-i`, or the interface rule standing in for the physdev one -- - // finds nothing and leaks the hook. - if let Some(iface) = veth_interface { - Self::teardown_family_forward( - Self::run_iptables_rule_args, - chain_name, - iface, - &created.v4, - &mut residual.v4, - logger, - ); - Self::teardown_family_forward( - Self::run_ip6tables_rule_args, - chain_name, - iface, - &created.v6, - &mut residual.v6, - logger, - ); + // Remove from FORWARD only for families this attempt hooked. + // + // Which route gets there depends on whether the veth is known, because + // iptables deletes a rule by its full specification and every rule this + // backend installs into FORWARD is scoped to the interface. + // + // With the veth the remembered specs are replayed. That is the exact + // delete, and it is the only one that reaches the return-path rules: + // they jump to ACCEPT rather than to our chain, so nothing that + // searches FORWARD for jumps into the chain can see them. + // + // Without it -- signal-time `force_cleanup`, or a veth that was never + // discovered -- there is nothing to replay, and the interface-scoped + // delete removed nothing at all. The hooks survived, `teardown_chain` + // below then correctly refused to flush or delete a still-referenced + // chain, and the result was a leaked chain plus hooks that no later + // pass could reclaim, because every later pass hit the same missing + // veth. Enumerating the live FORWARD chain for jumps to this chain + // needs no interface and clears both hook forms at once. + // + // It cannot reach the return rules. They are marked residual, and + // whether a later pass can reclaim them depends on the interface being + // known by then: `observe_existing` claims a return rule only when it + // can match the interface the rule names, because nothing else marks + // that rule as ours. This path is the one that has no interface, so on + // it the two return rules are a known strand rather than a deferred + // retry. Enumerating is still the better of the two, because it + // reclaims the chain and both hooks rather than leaking all four. + match veth_interface { + Some(iface) => { + Self::teardown_family_forward( + Self::run_iptables_rule_args, + chain_name, + iface, + &created.v4, + &mut residual.v4, + logger, + ); + Self::teardown_family_forward( + Self::run_ip6tables_rule_args, + chain_name, + iface, + &created.v6, + &mut residual.v6, + logger, + ); + // A delete that reported success removed one matching rule. + // iptables holds duplicates, and a jump may carry qualifiers + // this backend never writes, so that exit code is evidence + // about one rule rather than about the chain -- and the flush + // below is gated on the chain. Read FORWARD back and let a + // surviving jump hold the gate shut. It can only hold it, not + // open it, so a failed delete still counts. + Self::hold_flush_if_hooks_survive("iptables", chain_name, iface, &mut residual.v4); + Self::hold_flush_if_hooks_survive("ip6tables", chain_name, iface, &mut residual.v6); + } + None => { + if created.v4.hooks_remain() + && Self::remove_forward_hooks("iptables", chain_name, logger) + { + residual.v4.hook = false; + residual.v4.physdev_hook = false; + } + if created.v6.hooks_remain() + && Self::remove_forward_hooks("ip6tables", chain_name, logger) + { + residual.v6.hook = false; + residual.v6.physdev_hook = false; + } + } } // Flush and delete only the chains this attempt created, and only once @@ -2663,6 +3239,10 @@ impl NetworkIptablesManager { /// Remove one family's FORWARD rules, clearing ownership only where the /// removal succeeded. + /// + /// Requires the veth, because iptables deletes by full rule specification. + /// A teardown that never learned it takes the enumerating path in + /// [`teardown_created`](Self::teardown_created) instead. fn teardown_family_forward( run: fn(&[Vec], &mut Logger) -> Result<(), String>, chain_name: &str, @@ -2719,6 +3299,50 @@ impl NetworkIptablesManager { } } + /// Re-read FORWARD and let a jump that is still there hold the flush gate + /// shut, whatever the deletes reported. + /// + /// Strictly inhibitory: it can only set a hook bit, never clear one. A + /// delete that reported success removed one matching rule, and iptables + /// holds duplicates -- the recovery path in + /// [`force_cleanup_authoritative`](Self::force_cleanup_authoritative) + /// reconstructs ownership for a container whose state was lost, which is + /// where a second jump is likeliest to have accumulated. A jump may also + /// carry qualifiers this backend never writes and still reach the chain. + /// So the exit code is evidence about one rule, and the flush is gated on + /// the chain. + /// + /// Letting it clear a bit instead would make it authoritative, and it is + /// not: it would then overrule a delete that genuinely failed on the word + /// of a probe that can be a moment stale, opening the gate this exists to + /// hold shut. + /// + /// Only the hook bits are touched. They are the ones that gate the flush. + /// The return-path rules jump to `ACCEPT`, reference no chain, and gate + /// nothing. + fn hold_flush_if_hooks_survive( + tool: &str, + chain_name: &str, + iface: &str, + residual: &mut FamilyResources, + ) { + match Self::probe_forward_chain(tool) { + ForwardProbe::Dump(dump) => { + let live = Self::observe_forward_rules(&dump, chain_name, Some(iface)); + residual.hook |= live.hook; + residual.physdev_hook |= live.physdev_hook; + } + // A tool that is not installed holds no FORWARD chain, so nothing + // in it jumps here and there is nothing to add. + ForwardProbe::ToolAbsent => {} + // Unreadable establishes nothing, so it may not open the gate. + ForwardProbe::Unreadable => { + residual.hook = true; + residual.physdev_hook = true; + } + } + } + /// Remove all iptables/ip6tables rules created by this manager. pub fn remove_firewall_rules(&mut self, logger: &mut Logger) -> Result<(), String> { if !self.rules_applied { @@ -2732,15 +3356,26 @@ impl NetworkIptablesManager { let residual = Self::teardown_created( &self.chain_name, - self.veth_interface.as_deref(), &self.created, + self.veth_interface.as_deref(), logger, ); // A removal command can fail, and what survived is still ours. Clearing // the gate here regardless would strand it: Drop would then skip the // retry that is the last chance to remove it. - self.retain_residual_ownership(residual); + if self.retain_residual_ownership(residual) { + // Something survived. Answering `Ok` here told every caller the + // chain was gone while it was still filtering traffic -- stop and + // deprovision both reported success over a chain that outlived + // them and that blocks every later start. Ownership stays set, so + // Drop still gets its retry. + return Err(format!( + "failed to remove every iptables resource for chain {}; it is still installed, \ + and ownership is retained so the drop path can retry", + self.chain_name + )); + } Ok(()) } @@ -2781,6 +3416,73 @@ impl NetworkIptablesManager { mgr.created = created; let _ = mgr.remove_firewall_rules(logger); } + + /// Remove whatever firewall state currently exists for `container_name`, + /// regardless of which process installed it. + /// + /// This is the counterpart to [`force_cleanup`](Self::force_cleanup), and + /// the difference between them is authority, not thoroughness. + /// `force_cleanup` removes only what its caller created, because its + /// callers -- signal rollback, and a start that lost the chain to a + /// concurrent start -- may be looking at a chain somebody else owns and + /// still needs. Deleting it there would leave a live container unfiltered, + /// which is why the empty-record guard exists. + /// + /// `stop` and `deprovision` are in the opposite position. Each has already + /// stopped or destroyed the container before calling this, and propagates a + /// failure rather than continuing, so by the time teardown runs no process + /// is filtered by this chain. Keeping it would protect nothing and would + /// block the next start, which fails on a chain it did not create. They are + /// also what `apply_network_policy` names as the remedy when it finds a + /// stranded chain, so they must actually clear one. + /// + /// The residual race is a `start` of the same sandbox interleaved with a + /// `stop` or `deprovision` of it, which can delete the chain that start + /// just installed. That is not a new exposure: the same interleaving has + /// the stop or destroy tearing the container itself out from under that + /// start. Concurrent lifecycle calls on one sandbox are a caller error, and + /// the alternative -- never clearing a chain whose creator has exited -- + /// strands every state-aware sandbox that ever installed one. + /// + /// Linux-only in practice, like `force_cleanup`; kept compiled everywhere + /// so Windows and macOS CI still type-check it. + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub(crate) fn force_cleanup_authoritative( + container_name: &str, + veth_interface: Option<&str>, + logger: &mut Logger, + ) -> Result<(), String> { + let mut mgr = Self::new(container_name); + // Ask the host what is installed instead of asserting it, so a + // container with nothing left issues no commands and logs no failures. + let observed = Self::observe_existing(&mgr.chain_name, veth_interface)?; + if observed.is_empty() { + return Ok(()); + } + if let Some(v) = veth_interface { + mgr.set_veth_interface(v); + } + // Bypass the rules_applied gate: the manager that set it belonged to + // the start process and is long gone. + mgr.rules_applied = true; + mgr.created = observed; + let attempt = mgr.remove_firewall_rules(logger); + + // The manager's `Drop` retries whatever survived the first pass, so the + // honest answer is what the host reports once that has run -- not what + // the first attempt returned. Reporting the first attempt would raise a + // false alarm every time the retry succeeded. + let chain_name = mgr.chain_name.clone(); + drop(mgr); + match Self::observe_existing(&chain_name, veth_interface) { + Ok(o) if o.is_empty() => Ok(()), + Ok(_) => Err(attempt.err().unwrap_or_else(|| { + format!("iptables state for chain {chain_name} survived authoritative cleanup") + })), + // A probe that could not answer is not evidence of a clean host. + Err(e) => Err(attempt.err().unwrap_or(e)), + } + } } impl Drop for NetworkIptablesManager { @@ -2843,6 +3545,18 @@ mod proxy_spec; #[path = "network_iptables_ga_egress_spec.rs"] mod ga_egress_spec; +/// Black-box specification for the refusal of a malformed egress destination, +/// kept in its own file for the same reason as `veth_spec`. +#[cfg(test)] +#[path = "network_iptables_malformed_destination_spec.rs"] +mod malformed_destination_spec; + +/// Black-box specification for refusing a policy this backend cannot enforce, +/// kept in its own file for the same reason as `veth_spec`. +#[cfg(test)] +#[path = "network_iptables_error_standard_spec.rs"] +mod error_standard_spec; + #[cfg(test)] mod test_firewall { use std::cell::RefCell; @@ -2986,7 +3700,7 @@ mod tests { use super::*; use std::io::{Error, ErrorKind}; use wxc_common::logger::{Logger, Mode}; - use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode, ProxyAddress, ProxyConfig}; + use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode, NetworkPolicy, ProxyConfig}; /// Build a policy requesting the given enforcement mode, leaving every /// other field at its default. @@ -3291,6 +4005,323 @@ mod tests { ); } + /// A verbatim `iptables -S FORWARD` dump taken from a host running a + /// default-block sandbox. Written down rather than rebuilt from the + /// builders because the point of the exercise is that the kernel does not + /// echo back what was submitted -- note `RELATED,ESTABLISHED` here against + /// the `ESTABLISHED,RELATED` the rules were installed with. + const LIVE_FORWARD_DUMP: &str = "\ +-P FORWARD ACCEPT +-A FORWARD -m physdev --physdev-out mxcvjdmbp6vwk6m -m state --state RELATED,ESTABLISHED -j ACCEPT +-A FORWARD -o mxcvjdmbp6vwk6m -m state --state RELATED,ESTABLISHED -j ACCEPT +-A FORWARD -m physdev --physdev-in mxcvjdmbp6vwk6m -j MXC-mxc-d0e-oozdlgpmvay3kobe +-A FORWARD -i mxcvjdmbp6vwk6m -j MXC-mxc-d0e-oozdlgpmvay3kobe"; + + #[test] + fn every_installed_forward_rule_is_claimed() { + // Teardown deletes only what ownership claims, so a rule this misses is + // a rule that survives stop, deprovision, and every later retry. + let observed = NetworkIptablesManager::observe_forward_rules( + LIVE_FORWARD_DUMP, + "MXC-mxc-d0e-oozdlgpmvay3kobe", + Some("mxcvjdmbp6vwk6m"), + ); + + assert!(observed.hook, "the -i hook must be claimed"); + assert!( + observed.physdev_hook, + "the --physdev-in hook must be claimed" + ); + assert!(observed.return_rule, "the -o return rule must be claimed"); + assert!( + observed.physdev_return, + "the --physdev-out return rule must be claimed" + ); + } + + #[test] + fn a_rule_belonging_to_another_container_is_not_claimed() { + // Claiming a neighbour's rule would delete another sandbox's filtering. + let observed = NetworkIptablesManager::observe_forward_rules( + LIVE_FORWARD_DUMP, + "MXC-someone-else", + Some("mxcvsomeoneelse"), + ); + + assert!( + observed.is_empty(), + "no rule naming another chain or interface may be claimed, got: {observed:?}" + ); + } + + #[test] + fn return_rules_are_left_unclaimed_when_the_interface_is_unknown() { + // Nothing but the interface name marks a return rule as ours -- it + // jumps to ACCEPT, not to our chain. Claiming one on a guess would + // issue a delete for a rule this backend never installed. + let observed = NetworkIptablesManager::observe_forward_rules( + LIVE_FORWARD_DUMP, + "MXC-mxc-d0e-oozdlgpmvay3kobe", + None, + ); + + assert!( + observed.hook && observed.physdev_hook, + "both hook forms are identifiable without the interface" + ); + assert!( + !observed.return_rule && !observed.physdev_return, + "a return rule must not be claimed without the interface that names it" + ); + } + + #[test] + fn a_hook_in_another_table_is_not_read_as_a_forward_hook() { + // Only FORWARD is being torn down here; an identical jump installed in + // INPUT or OUTPUT belongs to the ingress path and is not ours to delete. + let dump = "\ +-A INPUT -i mxcvjdmbp6vwk6m -j MXC-mxc-d0e-oozdlgpmvay3kobe +-A OUTPUT -o mxcvjdmbp6vwk6m -m state --state RELATED,ESTABLISHED -j ACCEPT"; + + let observed = NetworkIptablesManager::observe_forward_rules( + dump, + "MXC-mxc-d0e-oozdlgpmvay3kobe", + Some("mxcvjdmbp6vwk6m"), + ); + + assert!( + observed.is_empty(), + "only FORWARD rules may be claimed, got: {observed:?}" + ); + } + + #[test] + fn the_two_hook_forms_are_claimed_independently() { + // Installing the physdev hook is attempted on either topology and can + // fail without failing the apply, so a chain may carry one form and not + // the other. Claiming a form that was never installed makes teardown + // issue a delete that fails, and a failed delete is held as a residual + // that reports the stop as failed. + let dump = "-A FORWARD -i mxcvjdmbp6vwk6m -j MXC-solo"; + + let observed = NetworkIptablesManager::observe_forward_rules( + dump, + "MXC-solo", + Some("mxcvjdmbp6vwk6m"), + ); + + assert!(observed.hook, "the hook that is present must be claimed"); + assert!( + !observed.physdev_hook, + "a hook form that is absent must not be claimed" + ); + + let physdev_only = "-A FORWARD -m physdev --physdev-in mxcvjdmbp6vwk6m -j MXC-solo"; + + let observed = NetworkIptablesManager::observe_forward_rules( + physdev_only, + "MXC-solo", + Some("mxcvjdmbp6vwk6m"), + ); + + assert!( + observed.physdev_hook, + "the physdev hook that is present must be claimed" + ); + assert!( + !observed.hook, + "a physdev hook must not also be claimed as a plain hook" + ); + } + + #[test] + fn the_two_return_forms_are_claimed_independently() { + // Same trade as the hooks: one form present must not be read as both, + // or teardown deletes a rule that was never installed. + let plain_only = + "-A FORWARD -o mxcvjdmbp6vwk6m -m state --state RELATED,ESTABLISHED -j ACCEPT"; + + let observed = NetworkIptablesManager::observe_forward_rules( + plain_only, + "MXC-solo", + Some("mxcvjdmbp6vwk6m"), + ); + + assert!(observed.return_rule, "the -o return rule must be claimed"); + assert!( + !observed.physdev_return, + "a plain return rule must not also be claimed as a physdev one" + ); + + let physdev_only = "-A FORWARD -m physdev --physdev-out mxcvjdmbp6vwk6m \ + -m state --state RELATED,ESTABLISHED -j ACCEPT"; + + let observed = NetworkIptablesManager::observe_forward_rules( + physdev_only, + "MXC-solo", + Some("mxcvjdmbp6vwk6m"), + ); + + assert!( + observed.physdev_return, + "the --physdev-out return rule must be claimed" + ); + assert!( + !observed.return_rule, + "a physdev return rule must not also be claimed as a plain one" + ); + } + + #[test] + fn a_stateful_accept_carrying_a_qualifier_we_never_write_is_not_ours() { + // The interface and the connection-state match together are still not + // enough. A foreign rule can carry both and narrow further, and the + // delete this backend rebuilds names no such narrowing -- so it cannot + // match, and the rule is held as a residual no later pass can clear. + for foreign in [ + "-A FORWARD -o mxcvjdmbp6vwk6m -p tcp -m state --state RELATED,ESTABLISHED -j ACCEPT", + "-A FORWARD -o mxcvjdmbp6vwk6m -d 10.0.0.0/8 \ + -m state --state RELATED,ESTABLISHED -j ACCEPT", + "-A FORWARD -m physdev --physdev-out mxcvjdmbp6vwk6m -p udp \ + -m state --state RELATED,ESTABLISHED -j ACCEPT", + ] { + let observed = NetworkIptablesManager::observe_forward_rules( + foreign, + "MXC-solo", + Some("mxcvjdmbp6vwk6m"), + ); + + assert!( + !observed.return_rule && !observed.physdev_return, + "a rule narrowed by something this backend never writes must not be claimed: \ + {foreign}" + ); + } + } + + #[test] + fn an_accept_without_the_connection_state_match_is_not_ours() { + // The interface name alone does not make a rule ours. A host rule + // accepting everything on the same interface would be claimed on the + // name and then deleted with our fuller specification -- a delete that + // cannot match, held as a residual no later pass can ever clear. + let dump = "-A FORWARD -o mxcvjdmbp6vwk6m -j ACCEPT"; + + let observed = NetworkIptablesManager::observe_forward_rules( + dump, + "MXC-solo", + Some("mxcvjdmbp6vwk6m"), + ); + + assert!( + !observed.return_rule && !observed.physdev_return, + "an ACCEPT carrying no connection-state match must not be claimed, got: {observed:?}" + ); + } + + #[test] + fn an_unreadable_forward_is_read_as_still_hooked() { + // A tool that ran and refused leaves the question unanswered -- another + // process holding the xtables lock, or no privilege to read. Reading + // that silence as "no hook" clears the gate in teardown_created, skips + // hook removal, and sends teardown on to flush a chain FORWARD may + // still jump to, unfiltering a live container. + assert!( + NetworkIptablesManager::hook_present(&ForwardProbe::Unreadable, "MXC-abc123"), + "an unanswered probe must not be read as an absent hook" + ); + + // Negative control: a FORWARD that really is free of our jump must + // still report not-hooked, so the assertion above cannot be satisfied + // by always answering true and stranding every chain. + assert!( + !NetworkIptablesManager::hook_present( + &ForwardProbe::Dump( + "-P FORWARD ACCEPT\n-A FORWARD -i veth0 -j SOMEONE-ELSE\n".to_string() + ), + "MXC-abc123" + ), + "a readable FORWARD without our jump must report not hooked" + ); + } + + #[test] + fn a_tool_that_is_not_installed_holds_no_hook() { + // Distinct from the unreadable case above, and the distinction is the + // whole point. On an IPv6-disabled host setup deliberately permits a + // missing ip6tables and installs only v4 state. Calling that absence + // "hooked" records a v6 residual that no teardown can ever clear: every + // removal attempt fails against a binary that is not there, ownership is + // retained, and a stop that did everything right reports an error. + assert!( + !NetworkIptablesManager::hook_present(&ForwardProbe::ToolAbsent, "MXC-abc123"), + "a tool that cannot be spawned holds no rules, so it holds no hook" + ); + } + + #[test] + fn a_missing_binary_is_absent_but_an_unrunnable_one_is_unknown() { + // `Command::output()` fails for more reasons than a missing executable, + // and only one of them means "there are no rules here". Permission + // denied, an exhausted descriptor table, or a transient resource + // shortage all leave the ruleset exactly as it was, so reading them as + // absent would let an authoritative teardown certify a host whose hooks + // are untouched. + assert!( + matches!( + NetworkIptablesManager::probe_forward_chain("mxc-no-such-firewall-tool"), + ForwardProbe::ToolAbsent + ), + "a binary that is not installed is genuinely absent" + ); + assert_eq!( + NetworkIptablesManager::chain_exists("mxc-no-such-firewall-tool", "MXC-abc123"), + Ok(false), + "a chain that cannot be addressed at all cannot exist" + ); + } + + #[cfg(unix)] + #[test] + fn a_binary_that_will_not_execute_is_never_read_as_absent() { + let dir = std::env::temp_dir().join(format!("mxc-unrunnable-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("test temp dir"); + // Present on disk, but not executable: the spawn fails with + // PermissionDenied rather than NotFound, which says nothing about what + // is installed in the filter table. + let tool = dir.join("iptables"); + std::fs::write(&tool, "#!/bin/sh\nexit 0\n").expect("seed a non-executable tool"); + let tool = tool.to_string_lossy().to_string(); + + assert!( + matches!( + NetworkIptablesManager::probe_forward_chain(&tool), + ForwardProbe::Unreadable + ), + "a tool that exists but will not run leaves FORWARD unknown, not empty" + ); + assert!( + NetworkIptablesManager::chain_exists(&tool, "MXC-abc123").is_err(), + "an unrunnable tool must not answer the chain question at all" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_readable_forward_carrying_our_jump_is_read_as_hooked() { + assert!( + NetworkIptablesManager::hook_present( + &ForwardProbe::Dump( + "-P FORWARD ACCEPT\n-A FORWARD -i veth0 -j MXC-abc123\n".to_string() + ), + "MXC-abc123" + ), + "a FORWARD dump containing our jump must report hooked" + ); + } + #[test] fn a_flush_is_withheld_while_the_chain_is_still_hooked() { // -F succeeds no matter who references the chain, and an emptied user @@ -3409,6 +4440,43 @@ mod tests { ); } + #[test] + fn a_removal_that_left_the_chain_installed_says_so() { + // The residual was retained but never reported. remove_firewall_rules + // answered Ok whether or not anything survived, so stop and + // deprovision told their callers the filtering was gone while the + // chain was still installed -- and still blocking the next start of + // that container name, which fails on a chain it did not create. + let fake = test_firewall::install(); + fake.fail_every_command("iptables: permission denied"); + + let mut manager = NetworkIptablesManager::new("stranded"); + manager.retain_residual_ownership(CreatedResources::for_test(true, false, false, false)); + + let mut logger = Logger::new(Mode::Buffer); + assert!( + manager.remove_firewall_rules(&mut logger).is_err(), + "a teardown that left the chain installed must not report success" + ); + } + + #[test] + fn a_removal_that_removed_everything_reports_success() { + // Negative control for the assertion above: the error has to come from + // the residual, not from every teardown. Without this, returning Err + // unconditionally would satisfy the test above. + let _fake = test_firewall::install(); + + let mut manager = NetworkIptablesManager::new("departed"); + manager.retain_residual_ownership(CreatedResources::for_test(true, false, false, false)); + + let mut logger = Logger::new(Mode::Buffer); + assert!( + manager.remove_firewall_rules(&mut logger).is_ok(), + "a teardown whose commands all succeeded must report success" + ); + } + #[test] fn a_second_apply_is_refused_while_the_first_still_owns_resources() { // Both arms of apply_firewall_rules replace self.created with the new @@ -4148,17 +5216,24 @@ mod tests { } #[test] - fn an_unresolvable_allow_does_not_arm_the_deny_precedence_failure() { - // An allow that resolved to nothing programs no ACCEPT, so it cannot - // preempt the closing DROP and must not be counted as one that did. + fn an_unresolvable_allow_is_refused_before_deny_precedence_is_considered() { + // An allow that resolved to nothing programs no ACCEPT and cannot + // preempt the closing DROP. The refusal now happens first, so the + // arming question this once asked can no longer arise. let policy = ContainerPolicy { default_network_policy: NetworkPolicy::Block, ..policy_with_hosts(&["allowed.invalid"], &[UNRESOLVABLE_HOST]) }; let mut logger = wxc_common::logger::Logger::new(wxc_common::logger::Mode::Buffer); - NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, false, &mut logger) - .expect("an allow that programs no rule cannot accept the unresolved deny"); + let result = + NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, false, &mut logger); + + let message = result.expect_err("an allow that programs no rule is refused"); + assert!( + message.contains("allowed.invalid"), + "the refusal must name the allow entry, not the deny; actual: {message:?}" + ); } #[test] @@ -4543,7 +5618,12 @@ mod tests { } #[test] - fn a_non_firewall_policy_is_a_successful_no_op() { + fn a_policy_that_requires_no_firewall_is_a_successful_no_op() { + // Default-allow, no host lists, no proxy: the one policy shape that asks + // for no filtering at all. The gate must report it as a clean no-op and + // install nothing -- observed through the fake, which records every + // command an apply would have issued. + let fake = test_firewall::install(); let mut manager = NetworkIptablesManager::new("skip-noop"); manager.set_veth_interface("veth-skip"); let policy = policy_requiring_no_firewall(); @@ -4554,12 +5634,17 @@ mod tests { assert_eq!( result, Ok(true), - "a policy that does not use firewall enforcement must be reported as a successful no-op" + "a policy that requires no firewall must be reported as a successful no-op" ); assert!( !manager.rules_applied(), "a no-op firewall skip must leave no rules marked as applied" ); + assert!( + fake.issued().is_empty(), + "a no-op must not issue a single command, got: {:?}", + fake.issued() + ); } // A 0.7 config decides the firewall the way it always has: by the mode it @@ -4626,12 +5711,11 @@ mod tests { assert!(needs_network(&policy, false)); } - // The JSON parser rejects proxy-under-capabilities, but it is not the only - // way in: `LxcScriptRunner::execute` and `mxc_engine::run` take an - // already-built `ExecutionRequest`. Skipping here would report success for - // an enforcement that never happened, while the runner still injects the - // proxy environment -- a container that advertises a proxy and restricts - // nothing. + // Nothing upstream refuses this any more: `LxcScriptRunner::execute` and + // `mxc_engine::run` take an already-built `ExecutionRequest`. Skipping here + // would report success for an enforcement that never happened, while the + // runner still injects the proxy environment -- a container that advertises + // a proxy and restricts nothing. #[test] fn a_proxy_under_a_non_firewall_mode_is_refused_rather_than_skipped() { // `Capabilities` is the only mode the firewall gate rejects, and it is @@ -4660,36 +5744,41 @@ mod tests { ); } - // `builtin_test_server` enables the proxy without an address, and it takes - // the same injection path, so the gate cannot key on the address alone. #[test] - fn the_builtin_test_server_proxy_is_gated_the_same_way() { - let mut policy = policy_with_enforcement_mode(NetworkEnforcementMode::Capabilities); - policy.network_proxy = ProxyConfig { - address: None, - builtin_test_server: true, + fn an_explicit_capabilities_mode_installs_the_same_rules_as_omitting_it() { + // `capabilities` is the struct default, so an explicit one and an + // omitted one must issue identical commands. Exercised on the + // directional schema, where the gate opens for every mode: on the + // legacy schema both sides install nothing and the comparison holds + // for a reason that has nothing to do with the mode. + let restriction = ContainerPolicy { + blocked_hosts: vec!["203.0.113.9".to_string()], + ..Default::default() + }; + let explicit = ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Capabilities, + ..restriction.clone() + }; + + let issued_for = |policy: &ContainerPolicy| -> Vec> { + let fake = test_firewall::install(); + let mut manager = NetworkIptablesManager::new("mode-parity"); + manager.set_veth_interface("veth-parity0"); + manager.set_directional_schema(true); + let mut logger = Logger::new(Mode::Buffer); + let _ = manager.apply_firewall_rules(policy, &mut logger); + fake.issued() }; - let mut manager = NetworkIptablesManager::new("builtin-gate"); - let mut logger = Logger::new(Mode::Buffer); + let omitted = issued_for(&restriction); assert!( - manager.apply_firewall_rules(&policy, &mut logger).is_err(), - "an address-free proxy is still a proxy and must not be silently unenforced" + !omitted.is_empty(), + "the comparison is only meaningful over a policy that installs rules" ); - } - - // The refusal must be narrow: without a proxy there is nothing to leave - // unenforced, so `capabilities` remains an ordinary supported mode. - #[test] - fn a_proxy_free_policy_still_skips_cleanly_under_capabilities() { - let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Capabilities); - let mut manager = NetworkIptablesManager::new("no-proxy-skip"); - let mut logger = Logger::new(Mode::Buffer); - assert_eq!( - manager.apply_firewall_rules(&policy, &mut logger), - Ok(true), - "capabilities mode without a proxy must stay a successful no-op" + issued_for(&explicit), + omitted, + "an explicit capabilities mode must issue the same commands as omitting it" ); } @@ -4935,8 +6024,9 @@ mod tests { NetworkIptablesManager::classify_host_ipv6_state(Ok(contents), PROC_NET_MOUNTED); assert_eq!( state, - HostIpv6State::Inactive, - "loopback-only `::1` on `lo` must classify as Inactive, not Active; got {state:?}" + HostIpv6State::Unknown, + "the file exists, so the stack is loaded and an address can arrive later; \ + loopback-only is not a confirmed negative; got {state:?}" ); assert_ne!( state, @@ -4946,26 +6036,28 @@ mod tests { } #[test] - fn empty_contents_are_inactive() { + fn empty_contents_are_not_a_confirmed_negative() { let state = NetworkIptablesManager::classify_host_ipv6_state(Ok(String::new()), PROC_NET_MOUNTED); assert_eq!( state, - HostIpv6State::Inactive, - "an empty `/proc/net/if_inet6` means no IPv6 addresses; got {state:?}" + HostIpv6State::Unknown, + "an empty `/proc/net/if_inet6` carries no address today, but the kernel \ + created the file and can add one; got {state:?}" ); } #[test] - fn whitespace_only_contents_are_inactive() { + fn whitespace_only_contents_are_not_a_confirmed_negative() { let state = NetworkIptablesManager::classify_host_ipv6_state( Ok("\n \n".to_string()), PROC_NET_MOUNTED, ); assert_eq!( state, - HostIpv6State::Inactive, - "blank lines carry no interface, so the state is Inactive; got {state:?}" + HostIpv6State::Unknown, + "blank lines carry no interface, but the file's existence still proves \ + the stack is loaded; got {state:?}" ); } diff --git a/src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rs b/src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rs index 36b5b6547..f37e88487 100644 --- a/src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rs +++ b/src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rs @@ -342,7 +342,7 @@ fn the_same_unresolvable_blocked_host_does_not_error_under_a_block_default() { } #[test] -fn an_unresolvable_allowed_host_never_errors_under_an_allow_default() { +fn an_unresolvable_allowed_host_is_refused_under_an_allow_default() { let host = "/20"; let policy = ContainerPolicy { allowed_hosts: vec![host.to_string()], @@ -353,34 +353,19 @@ fn an_unresolvable_allowed_host_never_errors_under_an_allow_default() { let result = NetworkIptablesManager::build_policy_rules_logged(CHAIN, &policy, false, &mut logger); - let args = expect_ok( - result, - "B4 reserves Err for an unresolvable BLOCK entry under an Allow \ - default; an unresolvable ALLOW entry must never error", - ); - assert!( - args.ipv4.is_empty() && args.ipv6.is_empty(), - "an unresolvable entry contributes no rules; actual ipv4: {:?}, \ - ipv6: {:?}", - args.ipv4, - args.ipv6 + let message = result.expect_err( + "a destination that resolves to no address cannot be programmed, and the \ + caller learns by the request failing rather than by a log line", ); - - let expected_warning = format!("Warning: could not resolve host '{host}'"); assert!( - logger - .get_buffer() - .lines() - .any(|line| line == expected_warning), - "expected the exact warning line {expected_warning:?}; actual \ - buffer: {:?}", - logger.get_buffer() + message.contains(host), + "the refusal must name the entry that was not honored; actual: {message:?}" ); } #[test] -fn an_unresolvable_allowed_host_never_errors_under_a_block_default() { +fn an_unresolvable_allowed_host_is_refused_under_a_block_default() { let host = "140.82.112.0/20/8"; let policy = ContainerPolicy { allowed_hosts: vec![host.to_string()], @@ -391,29 +376,14 @@ fn an_unresolvable_allowed_host_never_errors_under_a_block_default() { let result = NetworkIptablesManager::build_policy_rules_logged(CHAIN, &policy, false, &mut logger); - let args = expect_ok( - result, - "an unresolvable ALLOW entry must never error, regardless of the \ - default network policy (B4)", - ); - assert!( - args.ipv4.is_empty() && args.ipv6.is_empty(), - "an unresolvable entry contributes no rules; actual ipv4: {:?}, \ - ipv6: {:?}", - args.ipv4, - args.ipv6 + let message = result.expect_err( + "the default network policy does not change whether the named destination \ + could be programmed", ); - - let expected_warning = format!("Warning: could not resolve host '{host}'"); assert!( - logger - .get_buffer() - .lines() - .any(|line| line == expected_warning), - "expected the exact warning line {expected_warning:?}; actual \ - buffer: {:?}", - logger.get_buffer() + message.contains(host), + "the refusal must name the entry that was not honored; actual: {message:?}" ); } diff --git a/src/backends/lxc/common/src/network_iptables_error_standard_spec.rs b/src/backends/lxc/common/src/network_iptables_error_standard_spec.rs new file mode 100644 index 000000000..7c27a1975 --- /dev/null +++ b/src/backends/lxc/common/src/network_iptables_error_standard_spec.rs @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Black-box specification for the rule that LXC refuses whatever it cannot +//! enforce. +//! +//! A configuration file names what the container may reach. Where this backend +//! cannot program what was named, the caller learns by the request failing -- +//! never by a log line under a container that is already running under a policy +//! nobody wrote. +//! +//! Two forms are pinned here. A destination that resolves to no address cannot +//! become a rule. An allow list supplied beside a proxy is never programmed at +//! all, because proxy mode carries the proxy accepts and its closing drop and +//! nothing else. +//! +//! The deny direction is deliberately excluded. An unresolvable deny under a +//! blocking default is already denied by that default, so the policy the caller +//! wrote is fully enforced, and the existing hard error for the case where the +//! default would accept it is untouched. + +use super::*; +use wxc_common::logger::{Logger, Mode}; +use wxc_common::models::{ + ContainerPolicy, NetworkEnforcementMode, NetworkPolicy, ProxyAddress, ProxyConfig, +}; + +const CHAIN: &str = "MXC-ERROR-STANDARD"; + +/// A name reserved by RFC 2606 so that it can never resolve on any host. +/// +/// It is well formed, which is what separates it from a typo: the refusal under +/// test has to come from the lookup returning nothing, not from the string +/// failing to parse. +const NEVER_RESOLVES: &str = "no-such-host.invalid"; + +fn policy_with_allowed(host: &str, default: NetworkPolicy) -> ContainerPolicy { + ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Firewall, + allowed_hosts: vec![host.to_string()], + default_network_policy: default, + ..Default::default() + } +} + +fn proxy_policy(allowed: &[&str]) -> ContainerPolicy { + ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Firewall, + allowed_hosts: allowed.iter().map(|host| host.to_string()).collect(), + network_proxy: ProxyConfig { + address: Some(ProxyAddress::new("198.51.100.7".to_string(), 8080)), + builtin_test_server: false, + }, + ..Default::default() + } +} + +/// Drive a policy through the fake firewall and hand back the verdict and log. +fn apply(policy: &ContainerPolicy) -> (Result, String) { + let _fake = super::test_firewall::install(); + let mut manager = NetworkIptablesManager::new("error-standard-spec"); + manager.set_veth_interface("veth-errstd0"); + let mut logger = Logger::new(Mode::Buffer); + let result = manager.apply_firewall_rules(policy, &mut logger); + (result, logger.get_buffer().to_string()) +} + +// --------------------------------------------------------------------------- +// A destination that resolves to nothing +// --------------------------------------------------------------------------- + +#[test] +fn an_allow_that_resolves_to_no_address_refuses_the_apply() { + let policy = policy_with_allowed(NEVER_RESOLVES, NetworkPolicy::Block); + let (result, _) = apply(&policy); + + assert!( + result.is_err(), + "an allowed destination that resolves to no address cannot be \ + programmed, so the caller must learn by the request failing; actual: \ + {result:?}" + ); +} + +#[test] +fn an_allow_that_resolves_to_no_address_refuses_under_an_allow_default() { + let policy = policy_with_allowed(NEVER_RESOLVES, NetworkPolicy::Allow); + let (result, _) = apply(&policy); + + assert!( + result.is_err(), + "the default network policy does not change whether the named \ + destination could be programmed; actual: {result:?}" + ); +} + +#[test] +fn the_refusal_names_the_destination_that_could_not_be_programmed() { + let policy = policy_with_allowed(NEVER_RESOLVES, NetworkPolicy::Block); + let (result, _) = apply(&policy); + + let message = result.expect_err("the apply must fail"); + assert!( + message.contains(NEVER_RESOLVES), + "the caller has to be told which line of their configuration was not \ + honored; actual: {message:?}" + ); +} + +#[test] +fn build_policy_rules_refuses_an_unresolvable_allow_directly() { + let policy = policy_with_allowed(NEVER_RESOLVES, NetworkPolicy::Block); + let mut logger = Logger::new(Mode::Buffer); + + let result = + NetworkIptablesManager::build_policy_rules_logged(CHAIN, &policy, false, &mut logger); + + assert!( + result.is_err(), + "the refusal belongs where the rule is built, so no caller can reach \ + the unenforced policy by another route; actual: {result:?}" + ); +} + +// --------------------------------------------------------------------------- +// An allow list supplied beside a proxy +// --------------------------------------------------------------------------- + +#[test] +fn an_allow_list_beside_a_proxy_refuses_the_apply() { + let policy = proxy_policy(&["198.51.100.42/32"]); + let (result, _) = apply(&policy); + + assert!( + result.is_err(), + "proxy mode never programs the allow list, so a caller who supplied \ + one wrote configuration that is not honored; actual: {result:?}" + ); +} + +#[test] +fn the_proxy_refusal_says_the_allow_list_is_the_problem() { + let policy = proxy_policy(&["198.51.100.42/32"]); + let (result, _) = apply(&policy); + + let message = result.expect_err("the apply must fail"); + assert!( + message.contains("allowedHosts"), + "the caller has to be told which part of their configuration cannot \ + be honored beside a proxy; actual: {message:?}" + ); +} + +// --------------------------------------------------------------------------- +// Controls -- these must keep passing, or the change reached too far +// --------------------------------------------------------------------------- + +#[test] +fn a_proxy_with_no_allow_list_still_applies() { + let policy = proxy_policy(&[]); + let (result, _) = apply(&policy); + + assert!( + result.is_ok(), + "a proxy on its own is the supported posture and must be unaffected; \ + actual: {result:?}" + ); +} + +#[test] +fn a_resolvable_allow_still_applies() { + let policy = policy_with_allowed("198.51.100.42/32", NetworkPolicy::Block); + let (result, _) = apply(&policy); + + assert!( + result.is_ok(), + "a destination that programs cleanly must be unaffected; actual: \ + {result:?}" + ); +} + +#[test] +fn an_unresolvable_deny_under_a_blocking_default_still_applies() { + let policy = ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Firewall, + blocked_hosts: vec![NEVER_RESOLVES.to_string()], + default_network_policy: NetworkPolicy::Block, + ..Default::default() + }; + let (result, _) = apply(&policy); + + assert!( + result.is_ok(), + "the blocking default already denies what the entry named, so the \ + policy the caller wrote is fully enforced; actual: {result:?}" + ); +} + +#[test] +fn an_unresolvable_deny_under_an_accepting_default_still_refuses() { + let policy = ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Firewall, + blocked_hosts: vec![NEVER_RESOLVES.to_string()], + default_network_policy: NetworkPolicy::Allow, + ..Default::default() + }; + let (result, _) = apply(&policy); + + assert!( + result.is_err(), + "this refusal predates the change and must survive it; actual: \ + {result:?}" + ); +} + +// --------------------------------------------------------------------------- +// What the refusal says about rules it never programmed +// --------------------------------------------------------------------------- + +#[test] +fn a_refused_policy_does_not_report_an_earlier_rule_as_programmed() { + let policy = ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Firewall, + allowed_hosts: vec!["203.0.113.0/24".to_string(), NEVER_RESOLVES.to_string()], + default_network_policy: NetworkPolicy::Block, + ..Default::default() + }; + let (result, log) = apply(&policy); + + assert!( + result.is_err(), + "the second entry names a destination no rule can reach; actual: {result:?}" + ); + assert!( + !log.contains("Programmed iptables rule"), + "the refusal abandons every accumulated rule, so a caller reading this \ + log must not be told the earlier destination was programmed; log:\n{log}" + ); +} + +// --------------------------------------------------------------------------- +// A mode LXC has no mechanism for +// --------------------------------------------------------------------------- + +#[test] +fn a_legacy_capabilities_mode_that_states_a_network_posture_is_refused() { + let policy = ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Capabilities, + network_mode_specified: true, + default_network_policy: NetworkPolicy::Block, + ..Default::default() + }; + let (result, _) = apply(&policy); + + assert!( + result.is_err(), + "LXC filters with iptables and has no capability mechanism, so this \ + posture is stated and never enforced; actual: {result:?}" + ); +} + +#[test] +fn a_legacy_capabilities_mode_that_states_no_posture_still_applies() { + let policy = ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Capabilities, + network_mode_specified: false, + default_network_policy: NetworkPolicy::Block, + ..Default::default() + }; + let (result, _) = apply(&policy); + + assert!( + result.is_ok(), + "the caller named no network posture, so there is nothing LXC failed \ + to honor; actual: {result:?}" + ); +} + +// --------------------------------------------------------------------------- +// An IPv6 stack that is loaded but not yet addressed +// --------------------------------------------------------------------------- + +#[test] +fn a_loaded_ipv6_stack_with_no_address_is_not_reported_as_kernel_disabled() { + let loopback_only = "00000000000000000000000000000001 01 80 10 80 lo\n".to_string(); + + let state = NetworkIptablesManager::classify_host_ipv6_state(Ok(loopback_only), true); + + assert_ne!( + state, + HostIpv6State::Inactive, + "the file exists, so the kernel carries IPv6 and an address can arrive \ + while the container runs; calling that inactive installs IPv4-only \ + rules and leaves IPv6 egress unfiltered" + ); +} diff --git a/src/backends/lxc/common/src/network_iptables_malformed_destination_spec.rs b/src/backends/lxc/common/src/network_iptables_malformed_destination_spec.rs new file mode 100644 index 000000000..8b481706e --- /dev/null +++ b/src/backends/lxc/common/src/network_iptables_malformed_destination_spec.rs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Black-box specification for the refusal of a syntactically malformed egress +//! destination. +//! +//! A destination that cannot be parsed is a typo in the configuration file: it +//! resolves to nothing on every host, at every moment, forever. A well-formed +//! hostname whose lookup returned nothing is a different fact about a different +//! moment. Both are refused, because neither can be programmed into a rule. +//! These tests pin the refusals and pin that the two say different things, so a +//! caller can tell a mistyped entry from one their resolver could not answer. +//! +//! Written against the documented contract in +//! `docs/sandbox-policy/0.8.0/networking/networking.md`, which requires a +//! backend that cannot enforce a policy to reject it rather than accept it with +//! partial enforcement. + +use super::*; +use wxc_common::logger::{Logger, Mode}; +use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode, ProxyAddress, ProxyConfig}; + +/// A legacy-schema firewall policy carrying the given host lists. +/// +/// The legacy lists are the only egress surface that reaches this backend as +/// unparsed text; schema 0.8 peers arrive as an already-parsed `NetworkCidr` +/// and cannot carry a malformed destination. +fn legacy_policy(allowed: &[&str], blocked: &[&str]) -> ContainerPolicy { + ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Firewall, + allowed_hosts: allowed.iter().map(|host| host.to_string()).collect(), + blocked_hosts: blocked.iter().map(|host| host.to_string()).collect(), + ..Default::default() + } +} + +/// Apply `policy` through the fake firewall and hand back the verdict, every +/// command the apply issued, and everything it logged. +fn apply_and_collect(policy: &ContainerPolicy) -> (Result, Vec>, String) { + let fake = super::test_firewall::install(); + let mut manager = NetworkIptablesManager::new("malformed-spec"); + manager.set_veth_interface("veth-malformed0"); + let mut logger = Logger::new(Mode::Buffer); + let _ = fake.forget_issued(); + + let result = manager.apply_firewall_rules(policy, &mut logger); + (result, fake.issued(), logger.get_buffer().to_string()) +} + +/// Every destination string that is a permanent typo rather than a lookup that +/// happened to fail. +fn malformed_destinations() -> Vec<&'static str> { + vec![ + // Prefix out of range for the family. + "10.0.0.0/33", + "2001:db8::/129", + // `u8::from_str` accepts a leading sign, so iptables would silently + // canonicalize this to 10.0.0.0/24 and apply a policy nobody wrote. + "10.0.0.0/+24", + // Two prefixes. + "10.0.0.0/20/8", + // Missing network. + "/24", + // Missing prefix. + "10.0.0.0/", + // Network is not an address. + "not-an-address/24", + ] +} + +#[test] +fn a_malformed_allowed_host_refuses_the_apply() { + for destination in malformed_destinations() { + let policy = legacy_policy(&[destination], &[]); + let (result, _, _) = apply_and_collect(&policy); + assert!( + result.is_err(), + "allowedHosts entry {destination:?} is not a destination and must be refused, \ + but the apply succeeded" + ); + } +} + +#[test] +fn a_malformed_blocked_host_refuses_the_apply() { + for destination in malformed_destinations() { + let policy = legacy_policy(&[], &[destination]); + let (result, _, _) = apply_and_collect(&policy); + assert!( + result.is_err(), + "blockedHosts entry {destination:?} is not a destination and must be refused, \ + but the apply succeeded" + ); + } +} + +#[test] +fn a_blank_entry_refuses_the_apply() { + for destination in ["", " ", "\t"] { + let policy = legacy_policy(&[destination], &[]); + let (result, _, _) = apply_and_collect(&policy); + assert!( + result.is_err(), + "a blank allowedHosts entry {destination:?} names nothing and must be refused, \ + but the apply succeeded" + ); + } +} + +#[test] +fn the_refusal_names_the_field_and_the_offending_entry() { + let policy = legacy_policy(&["10.0.0.0/33"], &[]); + let (result, _, _) = apply_and_collect(&policy); + + let message = result.expect_err("a malformed destination must be refused"); + assert!( + message.contains("10.0.0.0/33"), + "the refusal must quote the entry the user has to go and fix, got: {message}" + ); + assert!( + message.contains("allowedHosts"), + "the refusal must name the field the entry came from, got: {message}" + ); +} + +#[test] +fn a_malformed_entry_refuses_before_any_chain_is_created() { + let policy = legacy_policy(&["10.0.0.0/33"], &[]); + let (result, issued, _) = apply_and_collect(&policy); + + assert!(result.is_err(), "a malformed destination must be refused"); + assert!( + issued.is_empty(), + "a typo is decidable without touching the network, so the refusal must land \ + before any chain is created and leave nothing to roll back; issued: {issued:?}" + ); +} + +#[test] +fn one_malformed_entry_among_good_ones_refuses_the_whole_policy() { + let policy = legacy_policy(&["10.1.0.0/16", "10.0.0.0/33", "192.168.0.0/24"], &[]); + let (result, issued, _) = apply_and_collect(&policy); + + assert!( + result.is_err(), + "a policy is refused whole: programming the two well-formed entries and warning \ + about the third would run the container under partial enforcement" + ); + assert!( + issued.is_empty(), + "no part of a refused policy may be programmed; issued: {issued:?}" + ); +} + +#[test] +fn a_malformed_entry_is_refused_in_proxy_mode_too() { + let mut policy = legacy_policy(&["10.0.0.0/33"], &[]); + policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 3128)), + builtin_test_server: false, + }; + + let (result, _, _) = apply_and_collect(&policy); + assert!( + result.is_err(), + "proxy mode never resolves allowedHosts, so a typo there is invisible to the \ + unresolved-host warning and must still be refused" + ); +} + +#[test] +fn a_hostname_that_did_not_resolve_is_also_refused_but_says_something_different() { + let policy = legacy_policy(&["no-such-host.invalid"], &[]); + let (result, _, _) = apply_and_collect(&policy); + + let message = result.expect_err( + "a destination that resolves to no address cannot be programmed, so the caller \ + learns by the request failing", + ); + assert!( + message.contains("no-such-host.invalid"), + "the refusal must name the entry that was not honored, got: {message}" + ); + assert!( + !message.contains("is not a valid destination"), + "a lookup that returned nothing is not a typo, and the two must not collapse \ + into one message, got: {message}" + ); +} + +#[test] +fn well_formed_destinations_still_apply() { + let policy = legacy_policy( + &["10.0.0.0/24", "192.168.1.1", "2001:db8::/32", "::1"], + &["172.16.0.0/12"], + ); + let (result, issued, _) = apply_and_collect(&policy); + + assert!( + result.is_ok(), + "every one of these parses as a destination and must still apply, got: {result:?}" + ); + assert!( + !issued.is_empty(), + "a well-formed policy still programs rules" + ); +} diff --git a/src/backends/lxc/common/src/network_iptables_proxy_spec.rs b/src/backends/lxc/common/src/network_iptables_proxy_spec.rs index 9ccc867cc..f9343ba68 100644 --- a/src/backends/lxc/common/src/network_iptables_proxy_spec.rs +++ b/src/backends/lxc/common/src/network_iptables_proxy_spec.rs @@ -288,25 +288,17 @@ fn proxy_mode_emits_no_base_exemptions() { // Under "the proxy and nothing else" an allowed host contradicts the model, // so programming it would widen the posture the proxy defines. #[test] -fn proxy_mode_does_not_program_the_allow_list() { +fn proxy_mode_refuses_an_allow_list() { let mut policy = policy_with_proxy("10.9.8.7", 3128); policy.allowed_hosts = vec!["10.1.1.1".to_string()]; - let (manager, issued, result) = apply_and_collect("proxy-nolists", &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - let rules = appended_rules(&issued, "iptables", manager.chain_name()); + let (_, _, result) = apply_and_collect("proxy-nolists", &policy); + let message = result.expect_err("a proxied chain never programs the allow list"); assert!( - !rules.is_empty(), - "the proxied chain must have been programmed at all; issued: {issued:?}" + message.contains("allowedHosts"), + "the caller must be told which part of their configuration cannot be honored \ + beside a proxy; actual: {message:?}" ); - - for rule in rules { - assert!( - !has_pair(rule, "-d", "10.1.1.1"), - "a proxied chain must ignore the allow list; actual: {rule:?}" - ); - } } // A blocked destination is still reachable by asking the permitted proxy to diff --git a/src/backends/lxc/common/src/network_iptables_veth_spec.rs b/src/backends/lxc/common/src/network_iptables_veth_spec.rs index 2b14eac83..8e13066e1 100644 --- a/src/backends/lxc/common/src/network_iptables_veth_spec.rs +++ b/src/backends/lxc/common/src/network_iptables_veth_spec.rs @@ -165,16 +165,18 @@ fn apply_tears_down_the_chain_it_created_when_it_fails_closed() { ); } -// A container that never asked for a firewall (`Capabilities` is the default -// enforcement mode) must not be punished for an interface the caller was -// never required to set. Any firewall command touching the host here would -// be an unrequested side effect on a container that opted out of firewalling -// entirely. +// A policy that requires no firewall at all -- default-allow, no host lists, +// no proxy -- must not be punished for an interface the caller was never +// required to set. Any firewall command touching the host here would be an +// unrequested side effect on a container that opted out of filtering. #[test] -fn capabilities_only_container_is_unaffected_by_a_missing_veth_interface() { +fn a_policy_requiring_no_firewall_is_unaffected_by_a_missing_veth_interface() { let fake = super::test_firewall::install(); let mut manager = NetworkIptablesManager::new("ctrl-capsonly"); - let policy = policy_requesting(NetworkEnforcementMode::Capabilities); + let policy = ContainerPolicy { + default_network_policy: wxc_common::models::NetworkPolicy::Allow, + ..Default::default() + }; let mut logger = Logger::new(Mode::Buffer); let _ = fake.forget_issued(); @@ -182,12 +184,12 @@ fn capabilities_only_container_is_unaffected_by_a_missing_veth_interface() { assert!( result.is_ok(), - "Capabilities mode must not fail just because the veth interface is unknown, got {:?}", + "a policy requiring no firewall must not fail just because the veth interface is unknown, got {:?}", result ); assert!( fake.issued().is_empty(), - "Capabilities-only enforcement must not issue any iptables commands, issued: {:?}", + "a policy requiring no firewall must not issue any iptables commands, issued: {:?}", fake.issued() ); } diff --git a/src/backends/lxc/common/src/signal_cleanup.rs b/src/backends/lxc/common/src/signal_cleanup.rs index a58f989ea..00b42b293 100644 --- a/src/backends/lxc/common/src/signal_cleanup.rs +++ b/src/backends/lxc/common/src/signal_cleanup.rs @@ -24,22 +24,157 @@ use nix::sys::signal::{SigSet, Signal}; #[cfg(target_os = "linux")] use crate::lxc_bindings::LxcContainer; -#[cfg(target_os = "linux")] -use crate::network_ingress::IngressManager; use crate::network_iptables::CreatedResources; #[cfg(target_os = "linux")] use crate::network_iptables::NetworkIptablesManager; #[cfg(target_os = "linux")] use wxc_common::logger::{Logger, Mode}; +/// How much of the active sandbox a fatal signal should roll back. +/// +/// The one-shot runner and the state-aware lifecycle both install a firewall +/// chain, but they own the container very differently, and rolling back the +/// wrong amount is damaging in both directions. Destroying a provisioned +/// state-aware container would discard a resource its owner expects to survive +/// the process; leaving a one-shot container behind would leak it forever. +#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)] +enum SignalRollback { + /// One-shot: this process created the container to run a single script, so + /// a signal takes the firewall and the container with it. + #[default] + DestroyContainer, + /// State-aware: the container is provisioned and deliberately outlives this + /// process, so a signal removes only the firewall this process installed. + /// The container is stopped, not destroyed, and left for a later `stop` or + /// `deprovision` to reclaim. + NetworkOnly, + /// State-aware `exec`: the container, its firewall, and its ingress rules + /// are all meant to survive. Only the processes this exec started inside + /// the container are rolled back, because killing this process kills the + /// host-side attach and nothing else -- the container is persistent, so its + /// descendants would otherwise run on into the next exec. + ReapExec, +} + +/// One action the watchdog takes on a fatal signal. +/// +/// The watchdog is Linux-only, so this is dead code elsewhere. It stays +/// compiled on every target rather than being `cfg`-gated so Windows and macOS +/// CI still type-check and test the ordering. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum RollbackStep { + /// Halt the container without discarding it. + StopContainer, + /// Remove the firewall chain and hooks this process created. + RemoveFirewall, + /// Discard the container entirely. + DestroyContainer, + /// Kill the processes one exec started inside the container, found by the + /// marker that exec was stamped with. + ReapExec, +} + +/// The steps a signal rollback runs, in the order they must run. +/// +/// Ordering is the whole content of this function, and it differs by rollback +/// kind for a reason. +/// +/// A state-aware start installs the chain *before* `lxc-start` so the container +/// is never up without it. The rollback has to preserve that invariant in +/// reverse: `lxc-start` may already have succeeded when the signal lands, so +/// removing the firewall first would leave a running container unfiltered with +/// no process left to notice. Stopping first cannot produce that state. +/// +/// The one-shot path runs the same invariant rather than the mirror of it. Its +/// container is going away entirely and `destroy` subsumes stopping it, which +/// once argued for removing the firewall first, while the name is still +/// unambiguous. But `lxc-destroy` can fail, and that order would then have +/// stripped the egress chain off a container that is still up. Destroy runs +/// first and the firewall is removed only once it has succeeded, which is what +/// the ordinary deprovision path already does. +/// +/// The inbound rules are different in kind. They live inside the container's +/// own network namespace, reachable only by entering it through the init PID, +/// so they cease to exist when the container does. Nothing removes them +/// explicitly: a successful destroy takes the namespace with it, leaving no +/// chain for a rollback to strip and no window in which the container is up +/// but unfiltered. +/// +/// A process that never created the chain must not remove one: the chain name +/// depends only on the container name, so the name may by now answer for a +/// different, live container. +/// +/// The exec rollback shares none of that. Nothing it touches was installed by +/// start, so it removes no firewall and stops no container -- it kills only the +/// processes carrying this exec's marker, which no other owner can be holding. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn rollback_plan(rollback: SignalRollback, owns_firewall: bool) -> Vec { + let mut plan = Vec::new(); + match rollback { + SignalRollback::ReapExec => { + plan.push(RollbackStep::ReapExec); + } + SignalRollback::NetworkOnly => { + plan.push(RollbackStep::StopContainer); + if owns_firewall { + plan.push(RollbackStep::RemoveFirewall); + } + } + SignalRollback::DestroyContainer => { + // No ingress step: those rules live inside the container's own + // netns, so the destroy takes them with it. Running one first would + // buy nothing when the destroy succeeds and cost real exposure when + // it fails -- a container left running with its inbound deny + // already stripped off. The firewall goes last, after the destroy + // has actually succeeded -- see `execute_rollback`. + plan.push(RollbackStep::DestroyContainer); + if owns_firewall { + plan.push(RollbackStep::RemoveFirewall); + } + } + } + plan +} + +/// Runs `plan`, asking `run_step` to perform each step and report whether it +/// succeeded. +/// +/// A failed `StopContainer` abandons the rest of the plan. The steps after it +/// exist to clean up a container that is no longer running, and the only one +/// that follows it is `RemoveFirewall` -- so continuing would strip the egress +/// chain off a container that is still up, which is precisely the state the +/// ordering above exists to prevent. Ordering alone does not achieve that; +/// `lxc-stop` can fail, and then the order it ran in no longer matters. +/// +/// A failed `DestroyContainer` abandons the rest for the same reason. The only +/// step that follows it is `RemoveFirewall`, and a destroy that failed may well +/// have left the container running, so continuing would unfilter it. +/// +/// Bailing out leaks the chain rather than exposing the container, which is the +/// same trade the ordinary stop path already makes deliberately: it propagates +/// the stop error and leaves the rules in place rather than unfilter a +/// still-running container (`state_aware.rs`, `stop`). +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn execute_rollback(plan: &[RollbackStep], run_step: &mut impl FnMut(RollbackStep) -> bool) { + for &step in plan { + let ok = run_step(step); + if !ok + && matches!( + step, + RollbackStep::StopContainer | RollbackStep::DestroyContainer + ) + { + return; + } + } +} + /// What the watchdog needs to roll back on a fatal signal: the container /// name (so we can `lxc-destroy` it), the host-side veth interface when /// known (so we can also remove the iptables FORWARD hook the runner -/// installed against it), the set of egress chains and hooks the runner has -/// actually created so far (so we remove only those), and the container's init -/// PID when known (so we can also remove the container-netns iptables INPUT -/// rules the inbound chain installed inside it, before the container is -/// destroyed). +/// installed against it), and the set of egress chains and hooks the runner +/// has actually created so far (so we remove only those). /// /// All live behind one mutex on purpose. The watchdog takes a single /// snapshot of the whole struct, so it can never pair one container's @@ -48,8 +183,26 @@ use wxc_common::logger::{Logger, Mode}; struct ActiveSandbox { name: Option, veth: Option, + /// Read only by the Linux watchdog, but written on every target. + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + rollback: SignalRollback, + /// Which per-family chains and FORWARD hooks this process created for + /// `name`, so the watchdog removes only what this process installed. + /// + /// The chain name is derived from the container name, so two processes + /// starting the same sandbox target the same chain and only one of them + /// creates it. Without this the watchdog would tear the chain down on a + /// signal no matter which process it interrupted, and interrupting the + /// loser would strip the firewall off the winner's running container. The + /// record is published the moment a creating command succeeds, so the + /// whole window in which the resource exists is covered and no wider. + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] created: CreatedResources, - netns_pid: Option, + /// The marker stamped on the exec currently running in `name`, when one is + /// running, so a signal can reap the processes it started inside the + /// container. + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + exec_marker: Option, } static ACTIVE_CONTAINER: OnceLock> = OnceLock::new(); @@ -72,38 +225,73 @@ pub fn set_active(name: &str) { let mut slot = lock_slot(); slot.name = Some(name.to_owned()); slot.veth = None; + slot.rollback = SignalRollback::DestroyContainer; slot.created = CreatedResources::default(); - slot.netns_pid = None; + slot.exec_marker = None; } -/// Records the host-side veth interface for the active container so the -/// watchdog can also remove the iptables FORWARD hook on a fatal signal. -/// No-op if no container is currently registered. -pub fn set_active_veth(veth: &str) { +/// Records `name` as the currently active container for the *state-aware* +/// lifecycle, where a fatal signal must remove the firewall this process +/// installed but must not destroy the container. +/// +/// The state-aware `start` phase installs the chain before the container runs, +/// so a signal in that window would otherwise leave the chain behind with no +/// one to remove it — the watchdog only acts on a registered name, and until +/// now only the one-shot runner ever registered one. Registering with +/// [`set_active`] instead would be worse than leaking: it would destroy a +/// container that was provisioned to outlive this process. +/// +/// Call [`clear_active`] once the start has succeeded. Leaving the +/// registration in place past that point would let a later signal strip the +/// firewall off a container that is up and running. +pub fn set_active_network_only(name: &str) { let mut slot = lock_slot(); - if slot.name.is_some() { - slot.veth = Some(veth.to_owned()); - } + slot.name = Some(name.to_owned()); + slot.veth = None; + slot.rollback = SignalRollback::NetworkOnly; + slot.created = CreatedResources::default(); + slot.exec_marker = None; } -/// Records the container's init PID for the active container so the watchdog -/// can remove the container-netns iptables INPUT (inbound) rules on a fatal -/// signal, before the container is destroyed. No-op if no container is -/// currently registered. -pub fn set_active_pid(pid: u32) { +/// Records the exec running in `name` under `marker`, so a fatal signal reaps +/// the processes it started inside the container. +/// +/// The exec phase registered nothing before this. `start` clears the +/// registration once it succeeds -- correctly, since a later signal must not +/// strip the firewall off a running container -- which left the whole exec +/// window uncovered. A signal there killed the host-side `lxc-attach` and +/// returned, while the script's descendants carried on inside a container that +/// is persistent by design and would hand them to the next exec. +/// +/// This does not resurrect the start-time registration. Nothing here stops the +/// container or touches its rules; they are all meant to survive the exec, and +/// the marker reaches only what this exec started. +/// +/// Call [`clear_active`] as soon as the attach returns. +pub fn set_active_exec(name: &str, marker: &str) { let mut slot = lock_slot(); - if slot.name.is_some() { - slot.netns_pid = Some(pid); - } + slot.name = Some(name.to_owned()); + slot.veth = None; + slot.rollback = SignalRollback::ReapExec; + slot.created = CreatedResources::default(); + slot.exec_marker = Some(marker.to_owned()); } -/// Records which iptables chains and FORWARD hooks the runner has created so -/// far, so signal-time cleanup removes exactly those and nothing else. +/// Records which iptables chains and FORWARD hooks this process created for +/// the active container, so the watchdog removes exactly those on a fatal +/// signal. +/// +/// Called by [`crate::network_iptables::NetworkIptablesManager`] the moment a +/// creating command succeeds — the instant the resource starts existing, and +/// not before. Registering a name is deliberately not enough on its own: a +/// process whose `-N` lost the race to a concurrent start of the same sandbox +/// owns nothing, and a signal must not make it delete the winner's chain and +/// leave the winner's container running unfiltered. /// -/// No-op when no container is registered. Backends that never call -/// [`set_active`] — Bubblewrap builds the same firewall manager but installs -/// no watchdog — therefore publish nothing, which keeps the watchdog from -/// acting on a lifecycle it does not manage. +/// Publication is whole-record rather than incremental: each call supersedes +/// the previous one, so callers pass the complete set they own. +/// +/// No-op if no container is currently registered. pub(crate) fn set_active_created(created: CreatedResources) { let mut slot = lock_slot(); if slot.name.is_some() { @@ -111,28 +299,37 @@ pub(crate) fn set_active_created(created: CreatedResources) { } } +/// Unregisters the active sandbox, so a later signal rolls nothing back. +/// +/// Used at the end of a successful state-aware start: the chain and the +/// container are both meant to persist from there on, and rolling either back +/// would be the bug rather than the fix. +/// +/// Also the mirror of [`set_active_created`] after a successful teardown. +/// Chain names depend only on the container name, so an ownership record left +/// published after a successful teardown would let a later signal run cleanup +/// against a name that by then may answer for a different, live container — +/// stripping its firewall while it runs. +pub fn clear_active() { + *lock_slot() = ActiveSandbox::default(); +} + +/// Records the host-side veth interface for the active container so the +/// watchdog can also remove the iptables FORWARD hook on a fatal signal. +/// No-op if no container is currently registered. +pub fn set_active_veth(veth: &str) { + let mut slot = lock_slot(); + if slot.name.is_some() { + slot.veth = Some(veth.to_owned()); + } +} + /// Reads back what the watchdog would act on. Test-only: production code has /// exactly one reader, and it is the watchdog itself. #[cfg(test)] -fn active_snapshot() -> ( - Option, - Option, - CreatedResources, - Option, -) { +fn active_snapshot() -> (Option, Option, CreatedResources) { let slot = lock_slot(); - ( - slot.name.clone(), - slot.veth.clone(), - slot.created, - slot.netns_pid, - ) -} - -/// Returns the slot to its process-start state so a test leaves nothing behind. -#[cfg(test)] -fn clear_active() { - *lock_slot() = ActiveSandbox::default(); + (slot.name.clone(), slot.veth.clone(), slot.created) } /// Block SIGHUP/SIGTERM/SIGINT in the calling thread and spawn a watchdog @@ -186,6 +383,20 @@ pub fn install() -> Result<(), String> { Ok(()) } +/// Whether a stop attempt left the container safe to unfilter. +/// +/// `lxc-stop -k` exits non-zero on a container that is not running, so a kill +/// that reported failure does not on its own mean the container survived. A +/// state that could not be read stays a failure, keeping the filtering in +/// place for a container that might still be transmitting. +/// +/// Only the Linux watchdog calls this, but it stays compiled on every target +/// so Windows and macOS CI still type-check and test the decision. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn stop_left_container_down(killed: bool, running_after: Option) -> bool { + killed || running_after == Some(false) +} + #[cfg(target_os = "linux")] fn run_watchdog(mask: SigSet) -> ! { loop { @@ -193,28 +404,41 @@ fn run_watchdog(mask: SigSet) -> ! { let Ok(sig) = mask.wait() else { continue }; let active = std::mem::take(&mut *lock_slot()); if let Some(name) = active.name { - // Remove iptables rules first so the FORWARD hook and chain - // don't outlive the container. The veth disappears once the - // container is destroyed below; cleaning up first avoids a - // dangling reference. Best-effort with a buffered logger so - // signal-time output doesn't interleave with whatever else - // might still be writing to the host's stdio. + // Best-effort, with a buffered logger so signal-time output doesn't + // interleave with whatever else might still be writing to the + // host's stdio. The order comes from `rollback_plan`, which is + // where the reasoning about it lives. let mut buf_logger = Logger::new(Mode::Buffer); - NetworkIptablesManager::force_cleanup( - &name, - active.veth.as_deref(), - active.created, - &mut buf_logger, - ); - // Also remove the container-netns inbound INPUT rules while the - // netns still exists (it vanishes when the container is destroyed - // below). Only possible when we know the init PID; without it the - // netns is unaddressable, so there is nothing we can enter to clean. - // Best-effort; a no-op if nothing was installed. - if let Some(pid) = active.netns_pid { - IngressManager::force_cleanup(&name, pid, &mut buf_logger); - } - let _ = LxcContainer::new(&name, None).destroy(); + let plan = rollback_plan(active.rollback, !active.created.is_empty()); + execute_rollback(&plan, &mut |step| match step { + RollbackStep::StopContainer => { + let container = LxcContainer::new(&name, None); + // A signal can land after the firewall is installed and + // before the start, where there is nothing to kill. + // Reading that as a failed stop strands the chain, which + // then blocks every later start of this name. + let killed = container.kill().is_ok(); + stop_left_container_down(killed, container.is_running().ok()) + } + RollbackStep::RemoveFirewall => { + NetworkIptablesManager::force_cleanup( + &name, + active.veth.as_deref(), + active.created, + &mut buf_logger, + ); + true + } + RollbackStep::DestroyContainer => LxcContainer::new(&name, None).destroy().is_ok(), + // Without a marker there is nothing to match on, and matching + // too widely would kill processes this exec never started. + RollbackStep::ReapExec => { + if let Some(marker) = active.exec_marker.as_deref() { + let _ = LxcContainer::new(&name, None).reap_marked_processes(marker); + } + true + } + }); } std::process::exit(128 + sig as i32); } @@ -224,6 +448,295 @@ fn run_watchdog(mask: SigSet) -> ! { mod tests { use super::*; + /// Records the steps `execute_rollback` actually ran, failing whichever + /// steps `failing` names. + fn run_plan(plan: &[RollbackStep], failing: &[RollbackStep]) -> Vec { + let mut ran = Vec::new(); + execute_rollback(plan, &mut |step| { + ran.push(step); + !failing.contains(&step) + }); + ran + } + + #[test] + fn an_exec_rollback_touches_nothing_but_the_exec() { + // The container, its firewall, and its ingress rules are all meant to + // survive an exec. A rollback that stopped the container or removed + // its rules would turn a cancelled command into a destroyed sandbox. + assert_eq!( + rollback_plan(SignalRollback::ReapExec, true), + vec![RollbackStep::ReapExec], + "cancelling an exec must not stop the container or remove its rules" + ); + } + + #[test] + fn an_exec_rollback_still_reaps_when_this_process_owns_the_firewall() { + // Ownership of the firewall is a start-phase fact and says nothing + // about an exec. The plan must not vary with it. + assert_eq!( + rollback_plan(SignalRollback::ReapExec, false), + rollback_plan(SignalRollback::ReapExec, true), + "firewall ownership must not change what an exec rollback does" + ); + } + + #[test] + fn a_stop_that_failed_does_not_unfilter_the_container_anyway() { + // Ordering the stop first is not enough on its own: `lxc-stop` can + // fail, and then removing the firewall next leaves a running container + // with no egress policy -- the exact state the ordering exists to + // prevent, reached by a different route. The rollback must abandon the + // rest of the plan instead. + // + // Leaking the chain is the correct trade here, and the ordinary stop + // path already makes it deliberately: it propagates the stop error and + // leaves the rules in place rather than unfilter a still-running + // container (`state_aware.rs`, `stop`). + let plan = rollback_plan(SignalRollback::NetworkOnly, true); + assert_eq!( + run_plan(&plan, &[RollbackStep::StopContainer]), + vec![RollbackStep::StopContainer], + "a failed stop must not be followed by removing the firewall" + ); + } + + #[test] + fn a_kill_that_failed_on_a_container_already_down_still_clears_the_way() { + // A signal can land between installing the firewall and starting the + // container. `lxc-stop -k` exits non-zero with nothing to kill, and + // treating that as a failed stop strands the chain -- which then blocks + // every later start of this name with "its chain already exists". + assert!( + stop_left_container_down(false, Some(false)), + "a container that is already down must not strand its chain" + ); + } + + #[test] + fn a_kill_that_failed_on_a_running_container_retains_the_firewall() { + // This is the case the ordering exists to protect: the container is + // still transmitting, so its egress rules must stay. + assert!( + !stop_left_container_down(false, Some(true)), + "a still-running container must keep its filtering" + ); + } + + #[test] + fn a_container_state_that_could_not_be_read_retains_the_firewall() { + // An unreadable state is indistinguishable from a running one, and + // guessing wrong here unfilters a live container. + assert!( + !stop_left_container_down(false, None), + "an unreadable container state must be treated as still running" + ); + } + + #[test] + fn a_stop_that_succeeded_still_removes_the_firewall() { + // The negative control for the test above. Bailing out is only correct + // when the stop actually failed; a rollback that never removed the + // firewall would leak the chain on every signal, so "always bail" must + // not pass. + let plan = rollback_plan(SignalRollback::NetworkOnly, true); + assert_eq!( + run_plan(&plan, &[]), + vec![RollbackStep::StopContainer, RollbackStep::RemoveFirewall], + "a successful stop must still be followed by removing the firewall" + ); + } + + #[test] + fn a_destroy_that_succeeded_still_removes_the_firewall() { + // The negative control for the test below. The gate is specific to a + // step that actually failed; a rollback that never removed the firewall + // would leak the chain on every signal, so "always bail" must not pass. + let plan = rollback_plan(SignalRollback::DestroyContainer, true); + assert_eq!( + run_plan(&plan, &[]), + vec![RollbackStep::DestroyContainer, RollbackStep::RemoveFirewall], + "a destroy rollback must still remove the firewall it created" + ); + } + + #[test] + fn a_destroy_that_failed_does_not_unfilter_the_container_anyway() { + // A failed `lxc-destroy` may well have left the container running, so + // removing the firewall afterwards would strip egress filtering off a + // live container with no process left to notice -- the same fail-open + // the stop path already guards against. Ordering alone does not achieve + // this: destroy runs first precisely so that its failure is observable + // before anything unfilters the container. + let plan = rollback_plan(SignalRollback::DestroyContainer, true); + assert_eq!( + run_plan(&plan, &[RollbackStep::DestroyContainer]), + vec![RollbackStep::DestroyContainer], + "a failed destroy must not be followed by removing the firewall" + ); + } + + #[test] + fn a_state_aware_rollback_stops_the_container_before_unfiltering_it() { + // The start phase installs the chain before lxc-start so the container + // is never up without it. A signal can land after lxc-start has already + // succeeded, so a rollback that removed the firewall first would create + // exactly the state the ordering exists to prevent -- a running, + // unfiltered container with no process left to notice. Nothing later + // repairs it: the watchdog exits the process immediately afterward. + let plan = rollback_plan(SignalRollback::NetworkOnly, true); + assert_eq!( + plan, + vec![RollbackStep::StopContainer, RollbackStep::RemoveFirewall], + "a state-aware rollback must stop the container before removing its firewall" + ); + + // The container is provisioned to outlive this process, so the rollback + // stops it and never destroys it. + assert!( + !plan.contains(&RollbackStep::DestroyContainer), + "a provisioned container must survive a signal" + ); + + // The inbound rules live inside the container netns and are reachable + // only through its init PID, so the sole moment this plan could remove + // them is before the stop -- which is the unfiltered-and-still-running + // state the ordering above exists to prevent. Stopping discards them + // anyway, so there is nothing to gain for the exposure. No plan removes + // them for that reason, which is why there is no step left to name. + assert!( + !plan.contains(&RollbackStep::StopContainer) + || plan + .iter() + .all(|s| !matches!(s, RollbackStep::DestroyContainer)), + "a stop rollback must not also destroy the container" + ); + + // A process that created no chain must remove none: the name depends + // only on the container name, so the chain may by now belong to a + // different live container. The container this process was starting is + // still stopped, because that start is what is being rolled back. + assert_eq!( + rollback_plan(SignalRollback::NetworkOnly, false), + vec![RollbackStep::StopContainer], + "a rollback that owns no chain must not remove one" + ); + } + + #[test] + fn a_one_shot_rollback_destroys_the_container_before_unfiltering_it() { + // This container is going away entirely and destroy subsumes stopping + // it, which once argued for removing the chain first, while the name + // still unambiguously referred to this container. But `lxc-destroy` can + // fail, and that order would then have unfiltered a container that is + // still running. + // + // Inbound is not a step at all. Those rules live inside the container's + // own netns, so the destroy takes them with it: removing them first + // would change nothing on success and would strip the inbound deny off + // a still-running container on failure. + assert_eq!( + rollback_plan(SignalRollback::DestroyContainer, true), + vec![RollbackStep::DestroyContainer, RollbackStep::RemoveFirewall], + ); + + // Same ownership rule, and the destroy is unconditional: a one-shot + // container is this process's to reclaim whether or not a chain was + // ever created. + assert_eq!( + rollback_plan(SignalRollback::DestroyContainer, false), + vec![RollbackStep::DestroyContainer], + ); + } + + /// The registration slot is process-global, so these assertions cannot be + /// split across test functions without racing each other. + #[test] + fn registration_records_who_owns_the_container_not_just_its_name() { + // One-shot: this process made the container, so a signal takes it. + set_active("box"); + { + let slot = lock_slot(); + assert_eq!(slot.name.as_deref(), Some("box")); + assert_eq!(slot.rollback, SignalRollback::DestroyContainer); + } + + // State-aware: the container is provisioned and must survive a signal, + // so only the firewall this process installed is rolled back. + set_active_network_only("provisioned-box"); + { + let slot = lock_slot(); + assert_eq!(slot.name.as_deref(), Some("provisioned-box")); + assert_eq!(slot.rollback, SignalRollback::NetworkOnly); + } + + // A veth discovered later attaches to whichever registration is live. + set_active_veth("mxcv-abc"); + assert_eq!(lock_slot().veth.as_deref(), Some("mxcv-abc")); + + // Registering a name is not by itself a claim on the firewall chain. + // The watchdog gates its ownership-blind force_cleanup on this record, + // so a process whose `iptables -N` lost the race to a concurrent start + // must not be holding one — otherwise a signal would make the loser + // delete the winner's chain and leave the winner unfiltered. + assert!(lock_slot().created.is_empty()); + + // The manager publishes it the moment its own creating command succeeds. + let v4_only = CreatedResources::for_test(true, false, true, false); + set_active_created(v4_only); + assert_eq!(lock_slot().created, v4_only); + + // A successful teardown gives the claim back. Without this the record + // outlives the chain it describes, and since the chain name depends + // only on the container name, a signal arriving afterwards would run + // cleanup against a name that may by then answer for a different, live + // container. + set_active_network_only("torn-down-box"); + set_active_created(v4_only); + assert!(!lock_slot().created.is_empty()); + set_active_created(CreatedResources::default()); + assert!(lock_slot().created.is_empty()); + + // Giving the claim back is not the same as unregistering: the container + // is still the active one, so a signal must still roll back whatever + // else the registration covers. + assert_eq!(lock_slot().name.as_deref(), Some("torn-down-box")); + + // Re-registering a different container drops the claim with it. + set_active_network_only("yet-another-box"); + assert!(lock_slot().created.is_empty()); + set_active_created(v4_only); + assert!(!lock_slot().created.is_empty()); + set_active("one-shot-box"); + assert!(lock_slot().created.is_empty()); + + // Clearing leaves nothing to roll back. Without this, a signal after a + // successful start would strip the firewall off a running container. + clear_active(); + { + let slot = lock_slot(); + assert!(slot.name.is_none()); + assert!(slot.veth.is_none()); + assert!(slot.created.is_empty()); + } + + // Neither late registration may resurrect a cleared slot. + set_active_veth("mxcv-def"); + set_active_created(v4_only); + { + let slot = lock_slot(); + assert!(slot.veth.is_none()); + assert!(slot.created.is_empty()); + } + + // Re-registering resets the veth, since the new container has not had + // one discovered yet. + set_active_network_only("another-box"); + assert!(lock_slot().veth.is_none()); + clear_active(); + } + /// `ACTIVE_CONTAINER` is process-global and the test binary runs tests in /// parallel, so the whole publication contract is asserted in one test. /// Splitting it would let two tests race on the same slot. @@ -235,8 +748,7 @@ mod tests { // Bubblewrap builds the same firewall manager but never registers, so // its resources must not become something the watchdog would remove. set_active_created(CreatedResources::for_test(true, true, true, true)); - set_active_pid(4242); - let (name, veth, created, netns_pid) = active_snapshot(); + let (name, veth, created) = active_snapshot(); assert_eq!(name, None, "no container should be registered yet"); assert_eq!( created, @@ -244,15 +756,10 @@ mod tests { "ownership published with no registered container must be discarded" ); assert_eq!(veth, None); - assert_eq!( - netns_pid, None, - "a netns PID published with no registered container must be discarded" - ); // Registering a container opens the slot. set_active("ctr-a"); set_active_veth("veth-a"); - set_active_pid(1234); let v4_chain_only = CreatedResources::for_test(true, false, false, false); set_active_created(v4_chain_only); assert_eq!( @@ -261,7 +768,6 @@ mod tests { Some("ctr-a".to_owned()), Some("veth-a".to_owned()), v4_chain_only, - Some(1234) ), "a registered container must see its own identity and ownership" ); @@ -283,13 +789,8 @@ mod tests { set_active("ctr-b"); assert_eq!( active_snapshot(), - ( - Some("ctr-b".to_owned()), - None, - CreatedResources::default(), - None - ), - "registering a new container must reset veth, ownership, and netns PID" + (Some("ctr-b".to_owned()), None, CreatedResources::default()), + "registering a new container must reset both veth and ownership" ); clear_active(); diff --git a/src/backends/lxc/common/src/state_aware.rs b/src/backends/lxc/common/src/state_aware.rs new file mode 100644 index 000000000..bb25d09c8 --- /dev/null +++ b/src/backends/lxc/common/src/state_aware.rs @@ -0,0 +1,2097 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! State-aware lifecycle implementation for the LXC backend. +//! +//! LXC keeps the durable sandbox state in the named container. Provision creates +//! the container, start applies mount/network policy and starts it, exec reuses +//! the one-shot `lxc-attach` PTY path, stop stops the container, and +//! deprovision destroys it plus any remaining iptables state. + +use std::time::Duration; + +use serde::Serialize; + +use semver::Version; + +use wxc_common::id::mint_random_token; +use wxc_common::logger::{Logger, Mode}; +use wxc_common::models::{ContainerPolicy, ExecutionRequest, LxcConfig}; +use wxc_common::mxc_error::MxcError; +use wxc_common::state_aware_backend::{ + null_pipe_handle, DeprovisionResult, ExecHandle, ExecOutcome, ExecStdio, ProvisionResult, + StartResult, StatefulSandboxBackend, StopResult, +}; + +use crate::filesystem_mounts; +use crate::lxc_bindings::{mint_exec_marker, LxcContainer}; +use crate::network_ingress::IngressManager; +use crate::network_iptables::{installs_firewall, CreatedResources, NetworkIptablesManager}; +use crate::signal_cleanup; + +/// Stateless state-aware LXC runner. +pub struct LxcStateAwareRunner { + tooling_probe: fn() -> Result<(), MxcError>, +} + +impl LxcStateAwareRunner { + pub fn new() -> Self { + Self { + tooling_probe: reject_if_lxc_tooling_missing, + } + } + + /// A runner whose host is treated as already having the LXC tools, for + /// tests that assert on request validation rather than on the host. + #[cfg(test)] + fn with_lxc_installed() -> Self { + Self { + tooling_probe: || Ok(()), + } + } +} + +impl Default for LxcStateAwareRunner { + fn default() -> Self { + Self::new() + } +} + +/// Provision-phase metadata for diagnostics and caller cleanup visibility. +#[derive(Debug, Clone, Serialize)] +pub struct LxcProvisionMetadata { + #[serde(rename = "containerName")] + pub container_name: String, + pub created: bool, +} + +/// Parses the `lxc:` sandbox_id form and returns the container +/// name segment. +fn extract_container_name(sandbox_id: &str) -> Result<&str, MxcError> { + let prefix = ::ID_PREFIX; + match sandbox_id.split_once(':') { + Some((p, rest)) if p == prefix && is_valid_container_name(rest) => Ok(rest), + _ => Err(MxcError::malformed_id(format!( + "expected {}:, got {:?}", + prefix, sandbox_id + ))), + } +} + +/// Maximum LXC sandbox container-name length. +/// +/// Bounds a sandbox container name to a sane length and character set so the +/// name is well-formed for LXC and for the derived iptables chain name. The +/// bound is input hygiene, and on the state-aware path it is also the only +/// thing narrowing the set of names that reach chain derivation. +/// +/// It does not make chain names unique. `NetworkIptablesManager` folds a +/// deterministic hash of the full container name into the chain name, which +/// breaks the systematic collapse of shared prefixes, but that derivation is +/// non-cryptographic and not injective — distinct names can still map to one +/// chain, and a caller that chooses `containerId` can construct such a pair. +/// See `NetworkIptablesManager::chain_name_for` for the work factor. A +/// collision lets one container's stop/deprovision tear down another +/// container's rules, leaving the incumbent running with no firewall. The +/// durable fix is persisted chain ownership verified before any flush or +/// delete, not a wider hash; tracked in AB#62953349. +const MAX_CONTAINER_NAME_LEN: usize = 20; + +/// Returns whether `name` is a valid LXC sandbox container name: non-empty, at +/// most [`MAX_CONTAINER_NAME_LEN`] characters, and restricted to ASCII +/// alphanumerics, `-`, and `_`. +/// +/// The character set keeps the name well-formed for LXC and for the derived +/// iptables chain name. `'.'` is intentionally excluded because it is stripped +/// by the chain-name sanitizer; the chain hash guards against collisions +/// regardless, but rejecting it up front keeps the sandbox id readable. +fn is_valid_container_name(name: &str) -> bool { + // Valid characters are ASCII (one byte each), so the byte length reported + // by `str::len` equals the character count for any otherwise-valid name. + !name.is_empty() + && name.len() <= MAX_CONTAINER_NAME_LEN + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) +} + +/// Where a container name came from. +/// +/// Provision has to tell the two apart. A caller-supplied name that already +/// exists is adopted on purpose, so provisioning the same named sandbox twice +/// is idempotent. A name MXC minted and that already exists is a collision: +/// the token is 32 bits, so adopting it would silently hand the caller a +/// container somebody else is already using. +enum ContainerName { + Supplied(String), + Minted(String), +} + +fn resolve_container_name(request: &ExecutionRequest) -> Result { + if request.container_id.is_empty() { + return Ok(ContainerName::Minted(format!( + "mxc-{}", + mint_random_token() + ))); + } + if is_valid_container_name(&request.container_id) { + Ok(ContainerName::Supplied(request.container_id.clone())) + } else { + Err(MxcError::malformed_request(format!( + "containerId contains characters that are not valid for an LXC sandbox id: {:?}", + request.container_id + ))) + } +} + +/// Number of names `mint_unused_container_name` will try before giving up. +/// +/// The token is 32 bits, so a single collision is already unlikely and eight +/// consecutive ones are not something a healthy host produces. The bound +/// exists so a host that answers "taken" for every name -- a stuck probe, or a +/// `lxc-info` that reports every query as defined -- fails instead of looping. +const NAME_MINT_ATTEMPTS: usize = 8; + +/// Re-mint until the name is free, rather than adopting whatever is there. +/// +/// `claim` is injected so the decision can be tested without an LXC host. A +/// probe that cannot answer propagates: "unknown" must not be read as "free", +/// because that is how a collision becomes an adoption. +/// +/// The claim closes the collision window rather than merely narrowing it. An +/// earlier version probed for a free name and returned it, leaving the caller +/// to create it afterwards; two provisions that minted the same candidate could +/// both see it free, and the loser would then adopt the winner's container -- +/// handing two callers who each asked for a fresh sandbox the same one. Taking +/// the lifecycle lock inside the claim makes the probe and the create one +/// critical section, and a candidate found defined under the lock is re-minted +/// rather than adopted. Adoption remains correct for a name the caller supplied, +/// which is a request for *that* container; it is never correct for a name MXC +/// invented precisely because nobody else was using it. +fn mint_unused_container_name( + first: String, + mut claim: impl FnMut(&str) -> Result, MxcError>, +) -> Result<(String, T), MxcError> { + let mut candidate = first; + for _ in 0..NAME_MINT_ATTEMPTS { + if let Some(claimed) = claim(&candidate)? { + return Ok((candidate, claimed)); + } + candidate = format!("mxc-{}", mint_random_token()); + } + Err(MxcError::backend_error(format!( + "Could not mint an unused LXC container name in {NAME_MINT_ATTEMPTS} attempts; \ + the last candidate {candidate:?} was already defined" + ))) +} + +fn validate_lxc_config(config: Option<&LxcConfig>) -> Result<(), MxcError> { + let Some(config) = config else { + return Err(MxcError::malformed_request( + "experimental.lxc.provision with distribution and release is required", + )); + }; + if config.distribution.is_empty() || config.release.is_empty() { + return Err(MxcError::malformed_request( + "LXC distribution and release are required", + )); + } + Ok(()) +} + +/// Map a container state-probe failure onto a backend error. +/// +/// Every phase asks whether the container exists or is running before deciding +/// to create, start, stop, or unfilter it. An unreadable answer is a backend +/// failure, never a licence to assume whichever value is convenient -- assuming +/// "gone" or "stopped" is what turns a broken probe into an unfiltered running +/// container. +fn probe_failed(question: &str, container_name: &str, detail: String) -> MxcError { + MxcError::backend_error(format!( + "Failed to determine whether LXC container {container_name:?} {question}: {detail}" + )) +} + +/// Refuse a phase when the `lxc-*` command-line tools are not on this host. +/// +/// Every phase drives LXC by spawning those tools, and a host without them +/// fails the first probe with a spawn error that reads as a broken container +/// rather than as a machine that never had LXC installed. The contract +/// separates the two: a missing runtime dependency is `backend_unavailable`, +/// while `backend_error` is a container operation that genuinely failed. +/// +/// Asking `lxc-info` for its version is the cheapest question that still spawns +/// the real binary, which catches a tool that is present but not executable. +/// A nonzero exit answers the question -- the tool ran. +fn reject_if_lxc_tooling_missing() -> Result<(), MxcError> { + match std::process::Command::new("lxc-info") + .arg("--version") + .output() + { + Ok(_) => Ok(()), + Err(e) => Err(MxcError::backend_unavailable(format!( + "the LXC command-line tools are not available on this host: failed to run lxc-info: {e}" + ))), + } +} + +fn has_filesystem_policy(policy: &ContainerPolicy) -> bool { + !policy.readwrite_paths.is_empty() + || !policy.readonly_paths.is_empty() + || !policy.denied_paths.is_empty() +} + +fn has_network_policy(policy: &ContainerPolicy) -> bool { + // `network_specified` is the outermost bit: it is true for any `network` + // block at all, including an empty one and one that only sets + // `allowLocalNetwork: false`. The narrower bits below cannot see those -- + // both produce a policy indistinguishable from the struct default -- so a + // phase that documents "no network section" would otherwise accept one. + policy.network_specified + || !policy.allowed_hosts.is_empty() + || !policy.blocked_hosts.is_empty() + || policy.allow_local_network + || policy.network_proxy.is_enabled() +} + +fn reject_start_policy_on_other_phase( + phase: &str, + policy: &ContainerPolicy, +) -> Result<(), MxcError> { + if has_filesystem_policy(policy) || has_network_policy(policy) { + return Err(MxcError::policy_validation(format!( + "LXC state-aware {phase} does not accept filesystem or network policy; pass it to start" + ))); + } + Ok(()) +} + +fn normalized_policy( + request: &ExecutionRequest, + logger: &mut Logger, +) -> Result { + let policy = + match wxc_common::filesystem_object::normalize_object_conflicts(&request.policy, logger) { + Ok(Some(policy)) => policy, + Ok(None) => request.policy.clone(), + Err(msg) => return Err(MxcError::policy_validation(msg)), + }; + + wxc_common::filesystem_access::check_delegation(&policy) + .map_err(MxcError::policy_validation)?; + Ok(policy) +} + +fn apply_filesystem_policy( + container: &LxcContainer, + request: &ExecutionRequest, + logger: &mut Logger, +) -> Result<(), MxcError> { + let policy = normalized_policy(request, logger)?; + filesystem_mounts::configure_filesystem_mounts(container, &policy, logger) + .map_err(|e| MxcError::policy_validation(format!("Failed to configure filesystem: {e}"))) +} + +/// The network rejections that depend on the requested policy alone. +/// +/// Split out of `apply_network_policy` so `validate_start` can reach the same +/// verdict without a container. A dry run stops after `validate_start` +/// (`state_aware_dispatch.rs`, `Phase::Start`), so anything checked only inside +/// the apply path is invisible to it -- and a dry run that answers "this start +/// is fine" for a policy the real start refuses is worse than no dry run, since +/// the caller has asked precisely that question and been told the wrong answer. +fn reject_unenforceable_network_policy( + policy: &ContainerPolicy, + uses_directional_schema: bool, +) -> Result<(), MxcError> { + if policy.network_proxy.is_enabled() { + return Err(MxcError::policy_validation( + "LXC state-aware start does not support network.proxy", + )); + } + + // A dry run stops before the ingress apply, so without this it would report + // success for a start that `IngressManager` refuses. The classification is + // borrowed from the applier rather than restated here, so the two verdicts + // cannot drift apart as the schema grows new ways to ask for the same thing. + if let Some(field) = IngressManager::permissive_inbound_field(policy, uses_directional_schema) { + return Err(MxcError::policy_validation(format!( + "LXC state-aware start does not support {field}: the container's inbound chain can \ + only open a source range, and opening every source is broader than the local-network \ + access requested. See microsoft/mxc AB#63505947." + ))); + } + + Ok(()) +} + +/// The lowest schema version LXC's state-aware lifecycle accepts. +pub const STATE_AWARE_MIN_SCHEMA_VERSION: &str = "0.8.0"; + +/// Refuse a state-aware request declaring a schema version below the floor. +pub fn enforce_schema_version_floor(declared: &str) -> Result<(), MxcError> { + if schema_version_is_at_least(declared, STATE_AWARE_MIN_SCHEMA_VERSION) { + return Ok(()); + } + let declared = if declared.trim().is_empty() { + "no schema version".to_string() + } else { + format!("schema version '{}'", declared) + }; + Err(MxcError::malformed_request(format!( + "the LXC state-aware lifecycle requires schema version \ + {STATE_AWARE_MIN_SCHEMA_VERSION} or later; this request declares {declared}" + ))) +} + +pub fn schema_version_is_at_least(declared: &str, minimum: &str) -> bool { + let minimum = Version::parse(minimum).expect("the minimum is a literal semver version"); + let minimum = Version::new(minimum.major, minimum.minor, minimum.patch); + Version::parse(declared).is_ok_and(|declared| { + Version::new(declared.major, declared.minor, declared.patch) >= minimum + }) +} + +/// Every start rejection that needs only the request, in the order the real +/// start reaches them: filesystem normalization first (`apply_filesystem_policy` +/// runs before `apply_network_policy`), then the network verdicts. Keeping the +/// order means a dry run reports the same error the real start would, not merely +/// some error. +fn validate_start_policy(request: &ExecutionRequest) -> Result<(), MxcError> { + let mut logger = Logger::new(Mode::Buffer); + let policy = normalized_policy(request, &mut logger)?; + reject_unenforceable_network_policy( + &policy, + wxc_common::supports_directional_network(&request.schema_version), + ) +} + +/// Apply the network policy, returning the record of what it installed. +/// +/// The record is what lets the caller undo exactly this attempt if the start +/// then fails, without touching a chain that a concurrent start owns. An +/// enforcement-free policy installs nothing and returns an empty record. +fn apply_network_policy( + container: &LxcContainer, + request: &ExecutionRequest, + logger: &mut Logger, +) -> Result { + let uses_directional_schema = wxc_common::supports_directional_network(&request.schema_version); + reject_unenforceable_network_policy(&request.policy, uses_directional_schema)?; + + let policy = normalized_policy(request, logger)?; + + let mut fw_manager = NetworkIptablesManager::new(container.name()); + fw_manager.set_directional_schema(uses_directional_schema); + + // When the policy requires the egress firewall, MXC installs a + // `lxc.hook.start-host` hook that renames the container's host-side veth to + // a deterministic name. The hook runs after liblxc creates the veth pair and + // attaches it to the bridge, but before the container's init runs, so the + // chain and its FORWARD hook can be built against that name *before* anything + // in the container can transmit. The prior flow discovered the veth only + // after start and applied rules afterward, leaving a window in which a + // container with a deny policy had unrestricted network. iptables accepts an + // interface name that does not exist yet, so hooking the not-yet-created + // veth by its deterministic name closes that window entirely. The hook key + // is container-global, so enforcement no longer depends on which + // `lxc.net.` index the interface uses. + // + // This gate is the same predicate `apply_firewall_rules` uses to decide + // whether to install rules, so the pin hook and the multi-interface guard + // run exactly when a chain will be scoped to the veth, and never when the + // apply is a no-op. + if installs_firewall(&policy, uses_directional_schema) { + // Exactly one interface gets a pinned veth, and apply_firewall_rules + // hooks exactly one interface. A container this run created has exactly + // that one interface, but provision also adopts containers it did not + // create, and an adopted one can carry several. Those would keep + // routing while start reported that a deny policy had been applied — + // the policy would be a claim rather than a control. Refuse instead: + // failing closed on a config MXC cannot fully enforce is the only + // honest answer, and hooking every interface is the follow-up. + let net = container + .configured_net_interfaces() + .map_err(|e| MxcError::backend_error(format!("Failed to read network config: {e}")))?; + + if net.count > 1 { + return Err(MxcError::policy_validation(format!( + "Container {:?} has {} configured network interfaces; \ + a firewall-enforced network policy can only be applied to a container \ + with a single interface, because traffic on the others would bypass it", + container.name(), + net.count, + ))); + } + // Zero is refused for the mirror-image reason. There is no interface to + // enforce against, so the pin hook would find nothing to rename and the + // run would either fail later or install a chain nothing routes + // through. Either way the caller asked for enforced networking on a + // container that has no network to enforce. + if net.count == 0 { + return Err(MxcError::policy_validation(format!( + "Container {:?} has no configured network interface, \ + so a firewall-enforced network policy has nothing to attach to", + container.name() + ))); + } + + // Exactly one interface is necessary but not sufficient -- it also has + // to be a veth. Enforcement works by naming the host end of a veth pair + // and hooking that name in FORWARD, so a macvlan or phys interface would + // get a chain built against a name that never exists while its traffic + // ran unfiltered and MXC reported the policy as enforced. + let sole_kind = net.sole_kind.as_deref().unwrap_or_default(); + if sole_kind != "veth" { + return Err(MxcError::policy_validation(format!( + "Container {:?} configures a network interface of type {:?}; a \ + firewall-enforced network policy can only be applied to a veth \ + interface, because enforcement pins the host end of the veth pair and \ + hooks that name in FORWARD. Use a veth interface, or run without \ + firewall enforcement", + container.name(), + sole_kind, + ))); + } + + let veth = NetworkIptablesManager::deterministic_veth_name(container.name()); + container.ensure_veth_pin_hook(&veth).map_err(|e| { + MxcError::backend_error(format!("Failed to install veth pin hook: {e}")) + })?; + fw_manager.set_veth_interface(&veth); + // The watchdog removes the chain by name, but the return-path rules in + // FORWARD carry the interface instead. Registering the veth is what + // lets a signal here delete those two rules rather than strand them. + signal_cleanup::set_active_veth(&veth); + } + + match fw_manager.apply_firewall_rules(&policy, logger) { + Ok(true) => { + if fw_manager.rules_applied() { + // Rules must survive after the start phase returns. stop and + // deprovision call force_cleanup_authoritative to remove this + // persistent state. + // + // Read the ownership record out before forgetting the manager. + // It is the only surviving evidence of what this attempt + // installed, and the start path needs it to tear down exactly + // that much if the container then fails to start. + let created = fw_manager.created(); + std::mem::forget(fw_manager); + return Ok(created); + } + Ok(CreatedResources::default()) + } + Ok(false) => Err(MxcError::policy_validation( + "Failed to apply network firewall rules", + )), + Err(e) => { + // Fail closed: tear down any partially-applied chain so an aborted + // policy application does not leak iptables state, and let the error + // propagate so the caller does not start the container unfiltered. + // + // Only when this run created the chain, though. The chain name is + // derived from the container name, so a concurrent start of the same + // sandbox aims at the same chain and the loser's `iptables -N` fails + // against the winner's. Cleaning up unconditionally would have the + // loser delete the winner's chain and hooks, and the winner would + // then start its container with nothing filtering it — turning a + // recoverable collision into a fail-open. + if fw_manager.owns_resources() { + // Tear down through the manager that created the chain, not a + // fresh one. `Drop` re-runs the teardown whenever + // `chain_created` is still set, and a fresh manager cannot clear + // that flag on the original -- so cleaning up any other way + // leaves this function returning into a second teardown of a + // chain it has already deleted. Between the two, another start + // can create the same deterministic chain name and install its + // rules, and the trailing teardown would then strip the new + // owner's hooks and delete its chain, leaving *its* container + // running unfiltered. + // + // Going through `fw_manager` clears `chain_created` on a + // successful `-X`, so the drop is a no-op; when `-X` fails the + // flag stays set and the drop is the retry it is there to be. + // It also knows the pinned veth, which a fresh manager does not. + let _ = fw_manager.remove_firewall_rules(logger); + return Err(MxcError::policy_validation(format!( + "Network policy error: {e}" + ))); + } + // Nothing was created, so nothing is removed. Say so explicitly: + // the chain existing without this run creating it means either a + // concurrent start holds it or a previous run left it behind, and + // the two are indistinguishable from iptables alone until ownership + // is persisted (AB#62953349). Both are cleared the same way, so name + // the remedy rather than leaving the caller to guess. + Err(MxcError::policy_validation(format!( + "Network policy error: could not create the firewall chain for container {:?}, \ + so no rules were applied and none were removed. Its chain already exists, \ + which means another start of this sandbox is in progress or an earlier run \ + left it behind; stop or deprovision the sandbox to clear it, then start again. \ + Underlying error: {e}", + container.name() + ))) + } + } +} + +/// Best-effort teardown of iptables state this process installed. +/// +/// `veth` is the host-side veth interface name when it is known. Teardown no +/// longer needs it to find the FORWARD hooks — those are located by +/// enumerating the live FORWARD chain and matching the `-j ` target — +/// but it is still passed through for logging and for the callers that +/// discovered it while the container was running. +/// +/// `created` is the ownership record: only the chains and hooks named in it are +/// removed, so a process that created nothing removes nothing. Use this from +/// the start path, which knows what it installed. +/// Install the container's inbound default-deny chain, failing closed. +/// +/// The egress chain is installed *before* the container runs, because iptables +/// accepts a veth name that does not exist yet. Ingress cannot work that way: +/// the chain lives inside the container's **own** network namespace, which does +/// not exist until the container starts, so this necessarily runs afterwards. +/// The one-shot path orders it the same way for the same reason. +/// +/// Without this the state-aware path enforced only half the network policy. +/// `allowLocalNetwork` defaults to false, so a container started here accepted +/// inbound connections from the host and the LAN while MXC reported the policy +/// as enforced -- egress filtered, ingress wide open. `IngressManager` also +/// refuses `allowLocalNetwork: true` outright rather than installing an +/// over-broad accept, so wiring it in is what makes that value honored or +/// refused instead of silently ignored. +fn apply_ingress_policy( + container: &LxcContainer, + container_name: &str, + request: &ExecutionRequest, + logger: &mut Logger, +) -> Result<(), MxcError> { + let policy = normalized_policy(request, logger)?; + let uses_directional_schema = wxc_common::supports_directional_network(&request.schema_version); + + let Some(pid) = container.init_pid() else { + if !installs_firewall(&policy, uses_directional_schema) { + return Ok(()); + } + // Enforcing inbound means entering the container's netns, and the init + // PID is the only handle on it. Continuing would silently drop the + // inbound deny, so refuse the start instead. + return Err(MxcError::backend_error( + "Failed to discover the container init PID; cannot enter the container \ + network namespace to enforce the inbound network policy", + )); + }; + + let mut manager = IngressManager::new(container_name, pid, uses_directional_schema); + let pinned_veth = NetworkIptablesManager::deterministic_veth_name(container.name()); + // A container that cannot be asked answers no, which sends the lookup to + // the name liblxc recorded rather than to the pinned one. + let pin_hook_present = container.has_veth_pin_hook(&pinned_veth).unwrap_or(false); + if let Some(gateway) = + NetworkIptablesManager::live_veth_interface(container.name(), pin_hook_present) + .as_deref() + .and_then(NetworkIptablesManager::host_gateway_for_veth) + { + manager.set_host_gateway(&gateway); + } + let applied = manager + .apply_firewall_rules(&policy, logger) + .map_err(|e| MxcError::backend_error(format!("Inbound network policy error: {e}")))?; + if !applied { + return Err(MxcError::backend_error( + "Failed to apply inbound network firewall rules", + )); + } + + // The container outlives this call, so its rules must too. `Drop` otherwise + // tears them down on the way out of this function, undoing the install this + // function exists to perform. + manager.set_preserve_policy(true); + Ok(()) +} + +fn cleanup_network_owned( + container_name: &str, + veth: Option<&str>, + created: CreatedResources, + logger: &mut Logger, +) { + NetworkIptablesManager::force_cleanup(container_name, veth, created, logger); +} + +/// Teardown of whatever iptables state exists for a container, whichever +/// process installed it. +/// +/// For `stop` and `deprovision` only. They run in a different process from the +/// `start` that created the chain, so they hold no ownership record and an +/// ownership-gated teardown would silently do nothing — stranding the chain and +/// blocking every later start, which fails on a chain it did not create. Both +/// callers have already stopped or destroyed the container by this point, so +/// nothing is left for the chain to protect. See +/// `NetworkIptablesManager::force_cleanup_authoritative` for the full argument. +/// +/// A failure is reported rather than swallowed. Discarding it made stop and +/// deprovision answer success over a chain that survived them, which is the +/// one outcome the caller has to know about: the stranded chain blocks every +/// later start of that container name. +/// The host-side veth name MXC guarantees for a container it enforces. +/// +/// Teardown deletes FORWARD rules by their full specification, so it needs the +/// name those rules actually carry. Asking liblxc for it is wrong: the pin hook +/// renames the interface *after* liblxc creates it, so `lxc-info` keeps +/// reporting the random name it generated, and deletes replayed against that +/// name match nothing — the hooks survive and the chain is stranded. The name +/// is derived rather than discovered because enforcement is what put it there. +fn enforced_veth_name(container_name: &str) -> Option { + Some(NetworkIptablesManager::deterministic_veth_name( + container_name, + )) +} + +fn cleanup_network_authoritative( + container_name: &str, + veth: Option<&str>, + logger: &mut Logger, +) -> Result<(), MxcError> { + NetworkIptablesManager::force_cleanup_authoritative(container_name, veth, logger).map_err(|e| { + MxcError::backend_error(format!( + "Failed to remove the network filtering state for LXC container {container_name:?}; \ + it is still installed and will block a later start: {e}" + )) + }) +} + +/// Advisory lock that makes each state transition one critical section per +/// sandbox. +/// +/// `start` reads whether the container is running and then, if it is not, +/// writes filesystem policy, installs the firewall, and starts it. Nothing +/// held the container still in between, so two concurrent starts both read +/// "not running" and both take the else arm. Both then write policy: the +/// container that comes up can be running behind the other start's mounts, and +/// the `already_started` refusal — whose whole job is to stop start policy +/// being reapplied to a live container — never fires. The loser gets `Ok`, +/// not the error the guard exists to produce. +/// +/// `stop` and `deprovision` have to take the same lock, because excluding only +/// a second start leaves the worse race open: a concurrent stop reads "not +/// running" in the window after a start installs its FORWARD chain and before +/// `container.start()`, runs the authoritative network teardown, and removes +/// the chain. The start then brings the container up with no egress filter at +/// all. Teardown is only safely ordered against a running container if no +/// start can be part-way through one. +/// +/// `exec` deliberately stays out. It installs and removes no host state, so it +/// cannot produce that fail-open, and an exclusive lock there would serialize +/// concurrent execs into one sandbox, which is a supported thing to do. Every +/// other phase takes it: `provision` because minting probes for an unused name +/// and then creates it, which is the same shape of race. +/// +/// The lock file lives in the LXC root rather than in the container directory +/// because `deprovision` destroys that directory: a lock file inside it would +/// be unlinked while still held, and the next phase would create a fresh file +/// and take a *different* lock, which is no lock at all. Container names are +/// validated for length and character set before they reach this, so the name +/// cannot escape the root. `flock` is released by the kernel when the +/// descriptor closes, so a phase that dies mid-sequence frees it; an `O_EXCL` +/// lock file would instead wedge every later phase of that container name. +struct LifecycleLock { + /// Held for its `Drop`, which is what releases the lock. `None` when the + /// LXC root does not exist; see `acquire`. + #[cfg(target_os = "linux")] + _guard: Option>, +} + +/// How many times `acquire` will re-take a lock whose file was replaced under +/// it before giving up. +/// +/// Each retry costs one open and one `flock`, and the only thing that triggers +/// one is a `deprovision` reclaiming the file, so the bound exists to stop an +/// adversary spinning the loop rather than to absorb ordinary contention. +/// Exhausting it refuses the phase, which is the safe direction. +#[cfg(target_os = "linux")] +const LOCK_ACQUIRE_ATTEMPTS: usize = 8; + +impl LifecycleLock { + /// Where the lock file for `container_name` lives. + /// + /// One function so `acquire` and `release_and_reclaim` cannot drift: a + /// reclaim that computed a different path would unlink a file nobody holds + /// and leave the real one behind. + #[cfg(target_os = "linux")] + fn lock_path(container: &LxcContainer, container_name: &str) -> String { + format!( + "{}/.mxc-lifecycle-{}.lock", + container.lxc_path(), + container_name + ) + } + + /// Take the lock, waiting for any concurrent transition of the same sandbox. + /// + /// Off Linux there is no LXC to serialize, so this is a no-op that exists + /// only so the phases read the same on every target. + fn acquire(container: &LxcContainer, container_name: &str) -> Result { + #[cfg(target_os = "linux")] + { + let path = Self::lock_path(container, container_name); + for _ in 0..LOCK_ACQUIRE_ATTEMPTS { + let file = match std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&path) + { + Ok(file) => file, + // No LXC root means no container directory under it, so + // nothing has passed the `is_defined` gate that every start + // clears before it touches host state — there is no + // transition to be serialized against. `stop` and + // `deprovision` are required to be idempotent, and refusing + // them here would make cleanup on a host that never had LXC + // an error. Any other failure, permission in particular, + // still refuses the phase. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(LifecycleLock { _guard: None }) + } + Err(e) => { + return Err(MxcError::backend_error(format!( + "Failed to open the lifecycle lock for LXC container \ + {container_name:?} at {path}: {e}" + ))) + } + }; + let guard = nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) + .map_err(|(_, errno)| { + MxcError::backend_error(format!( + "Failed to take the lifecycle lock for LXC container \ + {container_name:?}: {errno}" + )) + })?; + if Self::path_still_names(&guard, &path) { + return Ok(LifecycleLock { + _guard: Some(guard), + }); + } + // A `deprovision` reclaimed the file between the open and the + // lock, so this lock is on an inode nobody else can reach -- + // which excludes nobody. Drop it and take the current one. + drop(guard); + } + Err(MxcError::backend_error(format!( + "Failed to take the lifecycle lock for LXC container {container_name:?}: the lock \ + file at {path} was replaced on every one of {LOCK_ACQUIRE_ATTEMPTS} attempts" + ))) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (container, container_name); + Ok(LifecycleLock {}) + } + } + + /// Whether `path` still names the inode `guard` holds a lock on. + /// + /// `flock` follows the open descriptor, not the name, so a lock taken on a + /// file that has since been unlinked excludes nothing: the next phase + /// creates a fresh file at the same path and locks that instead. The + /// inode is what makes the two distinguishable. + /// + /// Any error reading either side answers `false`, which costs a retry and + /// eventually refuses the phase. Answering `true` on an unreadable path + /// would hand out a lock that excludes nothing. + #[cfg(target_os = "linux")] + fn path_still_names(guard: &nix::fcntl::Flock, path: &str) -> bool { + use std::os::unix::fs::MetadataExt; + + let (Ok(locked), Ok(named)) = (guard.metadata(), std::fs::metadata(path)) else { + return false; + }; + locked.ino() == named.ino() && locked.dev() == named.dev() + } + + /// Release the lock and remove its file. + /// + /// Only the terminal phase may call this, and only once the container is + /// gone: the file is a permanent inode per sandbox name otherwise, and a + /// host that provisions many short-lived sandboxes accumulates one for + /// every name it ever used. + /// + /// The unlink happens while the lock is still held, so no other phase can + /// be between its own open and its own `flock` and reach a state this did + /// not create. A phase that is already blocked on the lock wakes holding + /// the now-unlinked inode, sees the path no longer names it, and retakes + /// the current one; see `path_still_names`. + /// + /// A failed unlink is not reported. The container is already destroyed by + /// this point, so an error here would fail a `deprovision` that did + /// everything it was asked to, and the only consequence is the zero-byte + /// file this was trying to reclaim. + fn release_and_reclaim(self, container: &LxcContainer, container_name: &str) { + #[cfg(target_os = "linux")] + { + if self._guard.is_some() { + let _ = std::fs::remove_file(Self::lock_path(container, container_name)); + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = (container, container_name); + } + // `self` is consumed, so the lock releases here -- after the unlink, + // which is the order that matters. + } +} + +impl StatefulSandboxBackend for LxcStateAwareRunner { + const ID_PREFIX: &'static str = "lxc"; + const BACKEND_KEY: &'static str = "lxc"; + + type ProvisionConfig = LxcConfig; + type StartConfig = (); + type ExecConfig = (); + type StopConfig = (); + type DeprovisionConfig = (); + type ProvisionMetadata = LxcProvisionMetadata; + type StartMetadata = (); + type StopMetadata = (); + type DeprovisionMetadata = (); + + fn provision( + &mut self, + request: &ExecutionRequest, + config: Option, + ) -> Result, MxcError> { + validate_lxc_config(config.as_ref())?; + reject_start_policy_on_other_phase("provision", &request.policy)?; + + let config = config.expect("validated above"); + // The lock has to be held across the "does this name exist" probe and + // whatever that answer leads to, or the answer is stale before it is + // used: two provisions both see the name free and both create. + // + // What a name found defined under the lock *means* differs by origin. A + // supplied name is a request for that specific container, so finding it + // already there is the adoption the caller asked for. A minted name was + // invented because nobody was using it, so finding it defined means the + // mint lost a race -- adopting there would hand two callers who each + // asked for a fresh sandbox the same container, and the loser would + // report `created: false` and never reclaim it. + let (container_name, container, _lifecycle_lock, created) = + match resolve_container_name(request)? { + ContainerName::Supplied(name) => { + let container = LxcContainer::new(&name, None); + let lock = LifecycleLock::acquire(&container, &name)?; + let created = !container + .is_defined() + .map_err(|e| probe_failed("exists", &name, e))?; + (name, container, lock, created) + } + ContainerName::Minted(first) => { + let (name, (container, lock)) = + mint_unused_container_name(first, |candidate| { + let container = LxcContainer::new(candidate, None); + let lock = LifecycleLock::acquire(&container, candidate)?; + if container + .is_defined() + .map_err(|e| probe_failed("exists", candidate, e))? + { + // Releases as it falls out of scope, so the + // next candidate is not taken while holding a + // lock on this one. + return Ok(None); + } + Ok(Some((container, lock))) + })?; + (name, container, lock, true) + } + }; + if created { + container + .create(&config.distribution, &config.release) + .map_err(|e| MxcError::backend_error(format!("Failed to create container: {e}")))?; + } + + Ok(ProvisionResult { + sandbox_id: format!("{}:{}", Self::ID_PREFIX, container_name), + metadata: Some(LxcProvisionMetadata { + container_name, + created, + }), + }) + } + + fn start( + &mut self, + sandbox_id: &str, + request: &ExecutionRequest, + _config: Option<()>, + ) -> Result, MxcError> { + let container_name = extract_container_name(sandbox_id)?; + let container = LxcContainer::new(container_name, None); + // Hold the sandbox still for the whole phase: the running/not-running + // decision below and the policy writes that depend on it have to be one + // critical section, or a second start slips past the `already_started` + // refusal and applies its policy to a container the first one is + // bringing up — and a concurrent stop tears this start's firewall back + // off between installing it and running the container. + let _lifecycle_lock = LifecycleLock::acquire(&container, container_name)?; + if !container + .is_defined() + .map_err(|e| probe_failed("exists", container_name, e))? + { + return Err(MxcError::stale_id(format!( + "LXC container {:?} does not exist", + container_name + ))); + } + let mut logger = Logger::new(Mode::Buffer); + if container + .is_running() + .map_err(|e| probe_failed("is running", container_name, e))? + { + // A running container cannot receive this start's chain, so + // refusing is the only answer that does not report enforcement + // that never happened. + let uses_directional_schema = + wxc_common::supports_directional_network(&request.schema_version); + if has_filesystem_policy(&request.policy) + || has_network_policy(&request.policy) + || installs_firewall(&request.policy, uses_directional_schema) + { + return Err(MxcError::already_started( + "LXC container is already running; start policy cannot be reapplied", + )); + } + } else { + apply_filesystem_policy(&container, request, &mut logger)?; + // Install the firewall *before* the container is allowed to run. + // Applying it after start left a roughly 10-second window in which a + // container with a deny policy had unrestricted network. A + // firewall-install failure aborts the start (fail closed) rather + // than proceeding unfiltered. + // + // Register a signal rollback across that same window first. The + // chain is host state that outlives this process, so a SIGTERM + // between installing it and finishing the start would strand it with + // nobody to remove it. `set_active_network_only` is deliberately not + // the one-shot `set_active`: this container is provisioned and must + // survive, so only the firewall is rolled back. + signal_cleanup::set_active_network_only(container_name); + let installed = match apply_network_policy(&container, request, &mut logger) { + Ok(created) => created, + Err(e) => { + // Clear before returning. apply_network_policy already removed + // whatever it created, and when it failed because another start + // owns the chain it deliberately removed nothing — a signal + // arriving now would otherwise delete that owner's chain and + // leave its container running unfiltered. + signal_cleanup::clear_active(); + return Err(e); + } + }; + if let Err(e) = container.start() { + // A failed `lxc-start` is not evidence that the container is + // down. `LIVE_STATES` counts STARTING and ABORTING as live for + // exactly this reason: a start can fail with the container + // already up or still coming up. Removing the egress chain on + // the assumption that it never started would leave a running + // container unfiltered, so establish that it is stopped first. + // + // Only a definitive "not running" is evidence that unfiltering + // is safe -- an unreadable probe is not, so it is treated as + // live. + if container.is_running().unwrap_or(true) { + // Name the veth the rules were scoped to, then kill the + // container. Kill, not stop, for the reason the failed-ingress + // rollback below gives: a graceful stop waits up to 60 s, + // and every second of it is a container whose start already + // failed sitting there with a half-applied policy. + let veth = enforced_veth_name(container_name); + if let Err(stop_err) = container.kill() { + // The egress chain is the only part of the policy still + // in force, so removing it now would turn a filtered + // container into an unfiltered one. It stays -- the same + // trade the ingress rollback and `stop` both make. + signal_cleanup::clear_active(); + return Err(MxcError::backend_error(format!( + "Failed to start container: {e}; it could not be stopped afterwards \ + ({stop_err}), so it may still be running and its egress rules were \ + left in place" + ))); + } + cleanup_network_owned(container_name, veth.as_deref(), installed, &mut logger); + } else { + // Confirmed stopped, so there is no veth to discover and the + // FORWARD hooks are found by enumerating on the chain name. + // + // Ownership-scoped, not authoritative: `apply_network_policy` + // may have installed nothing, and a chain present without + // this attempt creating it belongs to a concurrent start + // whose container is running behind it. + cleanup_network_owned(container_name, None, installed, &mut logger); + } + signal_cleanup::clear_active(); + return Err(MxcError::backend_error(format!( + "Failed to start container: {e}" + ))); + } + // Inbound enforcement lands only once the container's network + // namespace exists, so unlike the egress chain it comes after start. + if let Err(e) = apply_ingress_policy(&container, container_name, request, &mut logger) { + // The container is up and its inbound deny is not in force, so + // leaving it running is exactly the fail-open this guard exists + // to prevent. Name the veth the rules were scoped to, then kill + // the container -- which also discards the netns holding any + // partial ingress chain -- and remove the egress state this + // start installed. + // + // Kill, not stop: a graceful `lxc-stop` waits up to 60 s and can + // fail outright under systemd-in-userns, and every second of + // that wait is a running container with no inbound filtering. + let veth = enforced_veth_name(container_name); + let stopped = container.kill(); + signal_cleanup::clear_active(); + if let Err(stop_err) = stopped { + // The container is still up, and the egress chain is the + // only half of its policy still in force. Removing it now + // would turn a half-filtered container into an unfiltered + // one, so it stays -- the same trade `stop` makes when it + // cannot stop the container, and the same one the signal + // rollback makes when its stop fails. + return Err(MxcError::backend_error(format!( + "{e}; the container could not be stopped afterwards ({stop_err}), so it \ + is still running and its egress rules were left in place" + ))); + } + cleanup_network_owned(container_name, veth.as_deref(), installed, &mut logger); + return Err(e); + } + // Past this point the chain and the container are both meant to + // persist, so a signal must not roll either back. + signal_cleanup::clear_active(); + } + Ok(StartResult { metadata: None }) + } + + fn exec( + &mut self, + sandbox_id: &str, + request: &ExecutionRequest, + _config: Option<()>, + stdio: ExecStdio, + ) -> Result { + // Before any work: `attach_run` relays to this process's stdio and + // returns no pipes, so it cannot serve a piped caller. Refusing + // after running it would make the refusal describe output that has + // already gone somewhere the caller never asked for. + if stdio == ExecStdio::Piped { + return Err(wxc_common::state_aware_backend::unsupported_piped_exec( + "LXC", + )); + } + + let container_name = extract_container_name(sandbox_id)?; + reject_start_policy_on_other_phase("exec", &request.policy)?; + + let container = LxcContainer::new(container_name, None); + if !container + .is_defined() + .map_err(|e| probe_failed("exists", container_name, e))? + { + return Err(MxcError::stale_id(format!( + "LXC container {:?} does not exist", + container_name + ))); + } + if !container + .is_running() + .map_err(|e| probe_failed("is running", container_name, e))? + { + return Err(MxcError::not_started(format!( + "LXC container {:?} is not started", + container_name + ))); + } + + let timeout = if request.script_timeout == 0 { + None + } else { + Some(Duration::from_millis(u64::from(request.script_timeout))) + }; + + // Registered for the whole attach, not just the timed part: a signal + // kills this process without waiting for the timeout, and the container + // is persistent, so anything the script started would otherwise be + // inherited by the next exec. + let marker = mint_exec_marker(); + signal_cleanup::set_active_exec(container_name, &marker); + // Cleared unconditionally: an empty env would otherwise leave + // `lxc-attach` in keep-env mode, inheriting this process's environment + // and the credentials in it. + let outcome = container.attach_run( + &request.script_code, + &request.working_directory, + &request.env, + true, + timeout, + Some(&marker), + ); + signal_cleanup::clear_active(); + + let exit_code = outcome + .map(|(exit_code, _, _)| exit_code) + .map_err(|e| MxcError::backend_error(format!("Execution failed: {e}")))?; + + Ok(ExecHandle { + stdout: null_pipe_handle(), + stderr: null_pipe_handle(), + stdin: null_pipe_handle(), + stdin_closer: None, + waiter: Box::new(move || Ok(ExecOutcome::Exited(exit_code))), + terminator: Box::new(|| Ok(())), + }) + } + + fn stop( + &mut self, + sandbox_id: &str, + request: &ExecutionRequest, + _config: Option<()>, + ) -> Result, MxcError> { + let container_name = extract_container_name(sandbox_id)?; + reject_start_policy_on_other_phase("stop", &request.policy)?; + + let container = LxcContainer::new(container_name, None); + // Same lock the start takes. Without it this teardown can observe a + // start's container as not running, strip the FORWARD chain that start + // just installed, and return before it calls `container.start()`, + // leaving a running container with no egress filtering. + let _lifecycle_lock = LifecycleLock::acquire(&container, container_name)?; + if !container + .is_defined() + .map_err(|e| probe_failed("exists", container_name, e))? + { + return Err(MxcError::stale_id(format!( + "LXC container {:?} does not exist", + container_name + ))); + } + + let mut logger = Logger::new(Mode::Buffer); + // Name the veth the rules were scoped to. The interface disappears once + // the container stops, but iptables can still delete a FORWARD rule that + // names it. Stop the container *before* tearing down its firewall rules + // so no + // process runs without egress filtering during the shutdown drain. If + // the stop fails, propagate the error and leave the rules in place + // rather than exposing a still-running container. + let veth = enforced_veth_name(container_name); + if !container + .is_running() + .map_err(|e| probe_failed("is running", container_name, e))? + { + // A container that exited on its own still has this start's rules + // on the host. Tear them down before refusing, or they outlive the + // container until deprovision. + cleanup_network_authoritative(container_name, veth.as_deref(), &mut logger)?; + return Err(MxcError::already_stopped(format!( + "LXC container {:?} is not running", + container_name + ))); + } + container + .stop() + .map_err(|e| MxcError::backend_error(format!("Failed to stop container: {e}")))?; + cleanup_network_authoritative(container_name, veth.as_deref(), &mut logger)?; + Ok(StopResult { metadata: None }) + } + + fn deprovision( + &mut self, + sandbox_id: &str, + request: &ExecutionRequest, + _config: Option<()>, + ) -> Result, MxcError> { + let container_name = extract_container_name(sandbox_id)?; + reject_start_policy_on_other_phase("deprovision", &request.policy)?; + + let mut logger = Logger::new(Mode::Buffer); + let container = LxcContainer::new(container_name, None); + // Same lock the start takes, for the same reason as `stop`: this path + // also runs the authoritative network teardown, and must not do it + // while a start is between installing the firewall and running the + // container. + let _lifecycle_lock = LifecycleLock::acquire(&container, container_name)?; + let veth = enforced_veth_name(container_name); + // A probe that could not answer must not be read as "already gone". + // Skipping the destroy and then running the authoritative network + // teardown below would strip filtering from a container that is still + // running, which is the fail-open this ordering exists to prevent. The + // `?` leaves the rules in place and lets the caller retry. + if container + .is_defined() + .map_err(|e| probe_failed("exists", container_name, e))? + { + // `destroy` force-stops and removes the container, so once it + // returns no container process can run. Destroy *before* removing + // the firewall rules so nothing runs without egress filtering + // during teardown; if the destroy fails, leave the rules in place. + container.destroy().map_err(|e| { + MxcError::backend_error(format!("Failed to destroy container: {e}")) + })?; + } + cleanup_network_authoritative(container_name, veth.as_deref(), &mut logger)?; + // Terminal phase: the container is gone, so nothing will take this + // lock for that name again until something creates the container + // afresh. Reclaim the file rather than leave one zero-byte inode per + // sandbox name the host ever provisioned. + _lifecycle_lock.release_and_reclaim(&container, container_name); + Ok(DeprovisionResult { metadata: None }) + } + + fn validate_provision( + &self, + request: &ExecutionRequest, + config: Option<&LxcConfig>, + ) -> Result<(), MxcError> { + validate_lxc_config(config)?; + resolve_container_name(request)?; + reject_start_policy_on_other_phase("provision", &request.policy)?; + (self.tooling_probe)() + } + + fn validate_start( + &self, + sandbox_id: &str, + request: &ExecutionRequest, + _config: Option<&()>, + ) -> Result<(), MxcError> { + extract_container_name(sandbox_id)?; + validate_start_policy(request)?; + (self.tooling_probe)() + } + + fn validate_exec( + &self, + sandbox_id: &str, + request: &ExecutionRequest, + _config: Option<&()>, + ) -> Result<(), MxcError> { + extract_container_name(sandbox_id)?; + reject_start_policy_on_other_phase("exec", &request.policy)?; + (self.tooling_probe)() + } + + fn validate_stop( + &self, + sandbox_id: &str, + request: &ExecutionRequest, + _config: Option<&()>, + ) -> Result<(), MxcError> { + extract_container_name(sandbox_id)?; + reject_start_policy_on_other_phase("stop", &request.policy)?; + (self.tooling_probe)() + } + + fn validate_deprovision( + &self, + sandbox_id: &str, + request: &ExecutionRequest, + _config: Option<&()>, + ) -> Result<(), MxcError> { + extract_container_name(sandbox_id)?; + reject_start_policy_on_other_phase("deprovision", &request.policy)?; + (self.tooling_probe)() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wxc_common::models::LifecycleConfig; + use wxc_common::models::NetworkAction; + use wxc_common::models::NetworkEnforcementMode; + use wxc_common::models::NetworkIngressPolicy; + use wxc_common::models::NetworkPolicy; + use wxc_common::models::ProxyConfig; + use wxc_common::mxc_error::MxcErrorCode; + + fn provision_config() -> LxcConfig { + LxcConfig { + distribution: "alpine".to_string(), + release: "3.20".to_string(), + } + } + + fn restrictive_policy() -> ContainerPolicy { + ContainerPolicy { + blocked_hosts: vec!["evil.example.com".to_string()], + ..Default::default() + } + } + + #[test] + fn a_host_restriction_without_an_enforcement_mode_is_accepted_not_rejected() { + // This gate refuses only what LXC cannot enforce at all: a proxy, and + // permissive inbound. A host restriction is enforceable whatever the + // enforcement mode decides about the firewall, so it passes here and + // the mode question is settled later, by `installs_firewall`. + assert!(reject_unenforceable_network_policy(&restrictive_policy(), false).is_ok()); + assert!(reject_unenforceable_network_policy( + &ContainerPolicy { + allowed_hosts: vec!["example.com".to_string()], + ..Default::default() + }, + false + ) + .is_ok()); + } + + #[test] + fn an_explicit_default_block_without_an_enforcement_mode_is_accepted() { + // `defaultPolicy: "block"` states a restriction this gate can leave + // alone; whether it reaches iptables is the enforcement mode's business, + // not a reason to refuse the start. + assert!(reject_unenforceable_network_policy( + &ContainerPolicy { + default_network_policy: NetworkPolicy::Block, + ..Default::default() + }, + false + ) + .is_ok()); + } + + #[test] + fn an_explicit_capabilities_mode_is_not_rejected() { + // A caller may set `enforcementMode: "capabilities"` explicitly. That + // is a statement about which mechanism enforces the policy, not an + // unenforceable request, so this gate passes it through. + let policy = ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Capabilities, + ..restrictive_policy() + }; + assert!(reject_unenforceable_network_policy(&policy, false).is_ok()); + } + + #[test] + fn allow_local_network_is_rejected_whatever_the_enforcement_mode() { + // `IngressManager` refuses this once invoked. A dry run stops before the + // ingress apply, so this request-only rejection is what gives the dry + // run the same verdict as the real start. Looping the modes shows the + // verdict does not depend on them. + for mode in [ + NetworkEnforcementMode::Capabilities, + NetworkEnforcementMode::Firewall, + NetworkEnforcementMode::Both, + ] { + let policy = ContainerPolicy { + network_enforcement_mode: mode, + allow_local_network: true, + ..Default::default() + }; + let err = reject_unenforceable_network_policy(&policy, false) + .expect_err("allowLocalNetwork has no enforceable LXC implementation"); + assert!( + format!("{err}").contains("allowLocalNetwork"), + "the refusal has to name the field the caller set, got {err}" + ); + } + } + + #[test] + fn directional_permissive_ingress_is_rejected_by_the_dry_run_too() { + // 0.8 states permissive inbound in two fields the legacy shape has no + // word for. The real start refuses both; a dry run that approved them + // would answer the caller's question wrongly. + for (field, ingress) in [ + ( + "network.ingress.default", + NetworkIngressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }, + ), + ( + "network.ingress.hostLoopback", + NetworkIngressPolicy { + host_loopback: NetworkAction::Allow, + ..Default::default() + }, + ), + ] { + let policy = ContainerPolicy { + network_ingress: Some(ingress), + ..Default::default() + }; + let err = reject_unenforceable_network_policy(&policy, true) + .expect_err("permissive directional inbound has no enforceable LXC implementation"); + assert!( + format!("{err}").contains(field), + "the refusal has to name the field the caller set, got {err}" + ); + + // The same request read as legacy states nothing about inbound, so + // the refusal must come from the directional reading, not from the + // gate refusing everything it is handed. + assert!( + reject_unenforceable_network_policy(&policy, false).is_ok(), + "a directional ingress section is not a legacy refusal" + ); + } + } + + #[test] + fn a_policy_that_leaves_allow_local_network_alone_is_not_rejected_for_it() { + // The negative control: the gate above must not swallow ordinary + // starts. + assert!(reject_unenforceable_network_policy(&ContainerPolicy::default(), false).is_ok()); + } + + #[test] + fn a_network_block_that_expresses_no_restriction_is_still_a_network_block() { + // `network: {}`, and a block that only restates the `allowLocalNetwork: + // false` default, both parse to a policy identical to the struct + // default -- every narrower bit reads false. Only `network_specified` + // can see them. The phase contract says provision, exec, stop, and + // deprovision take no network section, so without this bit they were + // accepted there and silently ignored. + let empty_block = ContainerPolicy { + network_specified: true, + ..Default::default() + }; + assert!(has_network_policy(&empty_block)); + assert!(reject_start_policy_on_other_phase("exec", &empty_block).is_err()); + } + + #[test] + fn a_policy_with_no_network_section_is_not_a_network_policy() { + // The negative control for the test above: without it, "reject + // everything" would pass, and the plain start in + // run_lxc_state_aware_test.sh would start failing on every phase. + let none = ContainerPolicy::default(); + assert!(!has_network_policy(&none)); + assert!(reject_start_policy_on_other_phase("exec", &none).is_ok()); + } + + #[test] + fn backend_key_matches_wire_format() { + assert_eq!( + ::BACKEND_KEY, + "lxc" + ); + } + + #[test] + fn id_prefix_matches_wire_format() { + assert_eq!( + ::ID_PREFIX, + "lxc" + ); + } + + /// A `Library` exec is refused before the sandbox id is even parsed. + /// + /// `attach_run` relays the workload's output to this process's stdio and + /// returns no pipes, so this backend cannot serve an in-process caller. The + /// refusal has to precede any work, and this id would otherwise fail as + /// `MalformedId` first — so any error but the refusal means the consumer + /// check came too late. + #[test] + fn a_piped_exec_is_refused_before_the_workload_runs() { + let mut runner = LxcStateAwareRunner::with_lxc_installed(); + let err = runner + .exec( + "not-a-valid-sandbox-id", + &ExecutionRequest::default(), + None, + ExecStdio::Piped, + ) + .expect_err("an in-process caller must be refused"); + assert!( + err.message.contains("cannot return exec streams"), + "expected the shared refusal ahead of the id check, got: {}", + err.message + ); + } + + #[test] + fn extract_container_name_unwraps_lxc_prefix() { + assert_eq!( + extract_container_name("lxc:mxc-abcd1234").unwrap(), + "mxc-abcd1234" + ); + } + + #[test] + fn extract_container_name_rejects_other_prefix() { + let err = extract_container_name("iso:abc").unwrap_err(); + assert_eq!(err.code, MxcErrorCode::MalformedId); + } + + #[test] + fn extract_container_name_rejects_missing_colon() { + let err = extract_container_name("no-colon").unwrap_err(); + assert_eq!(err.code, MxcErrorCode::MalformedId); + } + + #[test] + fn extract_container_name_rejects_empty_payload() { + let err = extract_container_name("lxc:").unwrap_err(); + assert_eq!(err.code, MxcErrorCode::MalformedId); + } + + #[test] + fn extract_container_name_rejects_invalid_name_chars() { + let err = extract_container_name("lxc:name/with/slash").unwrap_err(); + assert_eq!(err.code, MxcErrorCode::MalformedId); + } + + #[test] + fn is_valid_container_name_rejects_dot() { + // '.' is stripped by the iptables chain-name derivation, so "a.b" and + // "ab" would collide onto the same chain; reject dotted names. + assert!(!is_valid_container_name("a.b")); + } + + #[test] + fn is_valid_container_name_rejects_overlong_name() { + // One character over the bound: the chain derivation would truncate it, + // letting names that differ only past the bound collide. + assert!(!is_valid_container_name( + &"a".repeat(MAX_CONTAINER_NAME_LEN + 1) + )); + } + + #[test] + fn is_valid_container_name_accepts_max_length_name() { + assert!(is_valid_container_name(&"a".repeat(MAX_CONTAINER_NAME_LEN))); + } + + #[test] + fn extract_container_name_rejects_dotted_name() { + let err = extract_container_name("lxc:a.b").unwrap_err(); + assert_eq!(err.code, MxcErrorCode::MalformedId); + } + + #[test] + fn generated_container_name_fits_iptables_chain_bound() { + // The auto-generated name must itself satisfy the tightened rules so the + // firewall chain derived from it stays within the netfilter length bound + // and is collision-resistant (a deterministic hash of the full name is + // folded in; the mapping is not injective, only hard to collide). + let ContainerName::Minted(name) = resolve_container_name(&ExecutionRequest::default()) + .expect("a default request carries an empty containerId") + else { + panic!("an empty containerId must mint a name rather than adopt one"); + }; + assert!( + is_valid_container_name(&name), + "generated name {name:?} is invalid" + ); + assert!(name.len() <= MAX_CONTAINER_NAME_LEN); + } + + #[test] + fn no_filesystem_block_at_all_is_not_a_filesystem_policy() { + // Plain provisions must stay outside the filesystem phase gate. + assert!( + !has_filesystem_policy(&ContainerPolicy::default()), + "a policy with no filesystem section must not look like one" + ); + } + + #[test] + fn any_one_path_list_alone_is_a_filesystem_policy() { + // The gate is an OR across the three lists, so a dropped clause still + // leaves the other two answering true. Each list is exercised on its + // own so that a phase stops refusing only the list that went missing. + let readwrite = ContainerPolicy { + readwrite_paths: vec!["/tmp/rw".to_string()], + ..Default::default() + }; + let readonly = ContainerPolicy { + readonly_paths: vec!["/tmp/ro".to_string()], + ..Default::default() + }; + let denied = ContainerPolicy { + denied_paths: vec!["/tmp/denied".to_string()], + ..Default::default() + }; + + assert!( + has_filesystem_policy(&readwrite), + "readwritePaths alone must be seen as a filesystem policy" + ); + assert!( + has_filesystem_policy(&readonly), + "readonlyPaths alone must be seen as a filesystem policy" + ); + assert!( + has_filesystem_policy(&denied), + "deniedPaths alone must be seen as a filesystem policy" + ); + } + + #[test] + fn a_minted_name_nobody_holds_is_the_one_used() { + let (chosen, ()) = + mint_unused_container_name("mxc-first".to_string(), |_| Ok(Some(()))).expect("free"); + assert_eq!( + chosen, "mxc-first", + "a name that is free must be used as minted" + ); + } + + #[test] + fn a_minted_name_that_collides_is_re_minted_rather_than_adopted() { + // provision computed `created = !is_defined()` and skipped the create + // when the name was taken, so a collision on a 32-bit token silently + // handed the caller a container that was already in use. Minting is + // the phase that has to resolve it: by start the name is all that is + // left, and a caller-supplied name is adopted on purpose. + let mut probes = 0; + let (chosen, ()) = mint_unused_container_name("mxc-taken".to_string(), |_| { + probes += 1; + Ok(if probes == 1 { None } else { Some(()) }) + }) + .expect("a free name after one collision"); + + assert_ne!( + chosen, "mxc-taken", + "a name that is already defined must not be adopted" + ); + assert!( + is_valid_container_name(&chosen), + "the re-minted name {chosen:?} must still be a valid container name" + ); + assert!(chosen.len() <= MAX_CONTAINER_NAME_LEN); + } + + #[test] + fn a_claim_that_succeeds_hands_back_what_it_took() { + // The claim exists so the caller can hold a lock across it. If the + // payload were dropped on the way out, the lock would release before + // the create it is meant to cover and the race would be back with the + // retry loop still passing its own tests. + let (chosen, payload) = mint_unused_container_name("mxc-first".to_string(), |candidate| { + Ok(Some(format!("locked:{candidate}"))) + }) + .expect("free"); + assert_eq!(chosen, "mxc-first"); + assert_eq!( + payload, "locked:mxc-first", + "the value the claim produced must reach the caller" + ); + } + + #[test] + fn a_host_that_holds_every_name_is_an_error_not_an_adoption() { + let err = mint_unused_container_name("mxc-taken".to_string(), |_| Ok(None::<()>)) + .expect_err("every candidate was taken"); + assert!( + err.message.contains("Could not mint an unused"), + "unexpected message: {}", + err.message + ); + } + + #[test] + fn a_probe_that_cannot_answer_is_not_read_as_free() { + // The whole point of the retry is that "taken" is detected. A probe + // that fails and is treated as "free" reinstates the adoption this + // guards against, so the failure has to propagate. + let err = mint_unused_container_name("mxc-unknown".to_string(), |_| { + Err::, _>(MxcError::backend_error("lxc-info could not be run")) + }) + .expect_err("the probe failed"); + assert!( + err.message.contains("lxc-info could not be run"), + "unexpected message: {}", + err.message + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_lifecycle_lock_excludes_a_concurrent_phase_and_releases_on_drop() { + // The lock is the whole of the fix for the check-then-act races, so + // this asserts exclusion directly rather than racing two threads and + // timing them: a second, non-blocking attempt must be refused while + // the lock is held, and must succeed once it is dropped. + let dir = std::env::temp_dir().join(format!("mxc-lifecycle-lock-{}", mint_random_token())); + let name = "locked"; + std::fs::create_dir_all(dir.join(name)).expect("container directory"); + let container = LxcContainer::new(name, Some(dir.to_str().expect("utf-8 temp dir"))); + + let held = + LifecycleLock::acquire(&container, name).expect("the first phase takes the lock"); + + let path = dir.join(format!(".mxc-lifecycle-{name}.lock")); + let probe = || { + let file = std::fs::OpenOptions::new() + .write(true) + .open(&path) + .expect("the lock file the first phase created"); + nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock) + }; + + assert!( + probe().is_err(), + "a second phase must not enter while the first holds the lock" + ); + drop(held); + assert!( + probe().is_ok(), + "the lock must be released when the phase that took it returns" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_lifecycle_lock_is_skipped_when_there_is_no_lxc_root() { + // `stop` and `deprovision` have to stay idempotent on a host that never + // had LXC. No root means no container directory under it, so no start + // can have cleared its `is_defined` gate and there is no transition to + // serialize against. + let dir = + std::env::temp_dir().join(format!("mxc-lifecycle-noroot-{}", mint_random_token())); + let container = LxcContainer::new("absent", Some(dir.to_str().expect("utf-8 temp dir"))); + + assert!( + LifecycleLock::acquire(&container, "absent").is_ok(), + "a missing LXC root must not refuse the phase" + ); + assert!( + !dir.exists(), + "the lock must not create the LXC root as a side effect" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_lifecycle_lock_outlives_the_container_directory() { + // `deprovision` holds this lock across the destroy that removes the + // container directory. If the lock file lived in there it would be + // unlinked while held, and the next phase would create a fresh file and + // take a different lock — so the file has to sit in the LXC root and + // still be acquirable once the container is gone. + let dir = std::env::temp_dir().join(format!("mxc-lifecycle-gone-{}", mint_random_token())); + let name = "destroyed"; + std::fs::create_dir_all(dir.join(name)).expect("container directory"); + let container = LxcContainer::new(name, Some(dir.to_str().expect("utf-8 temp dir"))); + + let held = + LifecycleLock::acquire(&container, name).expect("the deprovision takes the lock"); + std::fs::remove_dir_all(dir.join(name)).expect("the destroy removes the container"); + + assert!( + dir.join(format!(".mxc-lifecycle-{name}.lock")).exists(), + "the lock file must survive the container it guards" + ); + drop(held); + assert!( + LifecycleLock::acquire(&container, name).is_ok(), + "a later phase must still be able to take the lock" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(target_os = "linux")] + #[test] + fn the_terminal_phase_reclaims_the_lock_file() { + // One zero-byte inode per sandbox name, forever, on a host that + // provisions many short-lived sandboxes. + let dir = + std::env::temp_dir().join(format!("mxc-lifecycle-reclaim-{}", mint_random_token())); + let name = "reclaimed"; + std::fs::create_dir_all(&dir).expect("lxc root"); + let container = LxcContainer::new(name, Some(dir.to_str().expect("utf-8 temp dir"))); + let path = dir.join(format!(".mxc-lifecycle-{name}.lock")); + + let held = LifecycleLock::acquire(&container, name).expect("deprovision takes the lock"); + assert!(path.exists(), "the lock file must exist while held"); + held.release_and_reclaim(&container, name); + assert!( + !path.exists(), + "the terminal phase must not leave its lock file behind" + ); + + // And the next provision of the same name still works. + assert!( + LifecycleLock::acquire(&container, name).is_ok(), + "reclaiming must not wedge the name" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_lock_on_a_replaced_file_is_not_accepted_as_held() { + // `flock` follows the descriptor, not the name. A lock taken on a file + // that has since been replaced excludes nobody, because the next phase + // opens the new inode and locks that instead. Accepting it would hand + // out two simultaneous "exclusive" locks on one sandbox. + let dir = std::env::temp_dir().join(format!("mxc-lifecycle-inode-{}", mint_random_token())); + std::fs::create_dir_all(&dir).expect("lxc root"); + let name = "replaced"; + let container = LxcContainer::new(name, Some(dir.to_str().expect("utf-8 temp dir"))); + let path = dir.join(format!(".mxc-lifecycle-{name}.lock")); + + let held = LifecycleLock::acquire(&container, name).expect("first acquire"); + // Stand a different inode at the same path, exactly as an unlink plus a + // later create would. + std::fs::remove_file(&path).expect("unlink the locked inode"); + std::fs::write(&path, b"").expect("a fresh inode at the same path"); + + let guard = held._guard.as_ref().expect("the lock is held on Linux"); + assert!( + !LifecycleLock::path_still_names(guard, &path.to_string_lossy()), + "a lock on a replaced inode must not read as current" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn validate_provision_requires_distribution_and_release() { + let runner = LxcStateAwareRunner::new(); + let err = runner + .validate_provision(&ExecutionRequest::default(), Some(&LxcConfig::default())) + .unwrap_err(); + assert_eq!(err.code, MxcErrorCode::MalformedRequest); + } + + #[test] + fn validate_provision_accepts_config_and_generated_id() { + let runner = LxcStateAwareRunner::with_lxc_installed(); + runner + .validate_provision(&ExecutionRequest::default(), Some(&provision_config())) + .unwrap(); + } + + #[test] + fn validate_provision_rejects_invalid_container_id() { + let runner = LxcStateAwareRunner::with_lxc_installed(); + let req = ExecutionRequest { + container_id: "bad/name".to_string(), + ..Default::default() + }; + let err = runner + .validate_provision(&req, Some(&provision_config())) + .unwrap_err(); + assert_eq!(err.code, MxcErrorCode::MalformedRequest); + } + + #[test] + fn validate_provision_rejects_dotted_container_id() { + // A dotted containerId would collide with its dot-stripped sibling on + // the derived iptables chain, so provisioning must reject it up front. + let runner = LxcStateAwareRunner::new(); + let req = ExecutionRequest { + container_id: "has.dot".to_string(), + ..Default::default() + }; + let err = runner + .validate_provision(&req, Some(&provision_config())) + .unwrap_err(); + assert_eq!(err.code, MxcErrorCode::MalformedRequest); + } + + #[test] + fn validate_provision_rejects_start_phase_policy() { + let runner = LxcStateAwareRunner::new(); + let req = ExecutionRequest { + policy: ContainerPolicy { + readonly_paths: vec!["/workspace".to_string()], + ..Default::default() + }, + ..Default::default() + }; + let err = runner + .validate_provision(&req, Some(&provision_config())) + .unwrap_err(); + assert_eq!(err.code, MxcErrorCode::PolicyValidation); + } + + #[test] + fn validate_start_accepts_policy_and_lxc_id() { + let runner = LxcStateAwareRunner::with_lxc_installed(); + let req = ExecutionRequest { + policy: ContainerPolicy { + readonly_paths: vec!["/workspace".to_string()], + ..Default::default() + }, + ..Default::default() + }; + runner + .validate_start("lxc:mxc-abcd1234", &req, None) + .unwrap(); + } + + #[test] + fn validate_start_rejects_a_start_the_real_start_would_refuse() { + // A dry run stops after `validate_start` and answers with an empty + // success envelope (`state_aware_dispatch.rs`, `Phase::Start`). While + // the start-only rejections lived inside `apply_network_policy`, the + // dry run could not see them, so it answered "this start is fine" for + // policies the real start refuses outright -- the one question a dry run + // exists to answer, answered wrongly. These are the two rejections that + // need nothing but the request. + let runner = LxcStateAwareRunner::new(); + + let proxy = ExecutionRequest { + policy: ContainerPolicy { + network_proxy: ProxyConfig { + builtin_test_server: true, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + let err = runner + .validate_start("lxc:mxc-abcd1234", &proxy, None) + .unwrap_err(); + assert_eq!(err.code, MxcErrorCode::PolicyValidation); + assert!( + err.message.contains("network.proxy"), + "expected the proxy rejection, got: {}", + err.message + ); + + // allowLocalNetwork: the inbound chain can only open every source, which + // is broader than the local-network access requested, so IngressManager + // refuses it and the real start aborts. This is the other rejection that + // needs only the request, so the dry run must give the same verdict. + let permissive_inbound = ExecutionRequest { + policy: ContainerPolicy { + allow_local_network: true, + ..Default::default() + }, + ..Default::default() + }; + let err = runner + .validate_start("lxc:mxc-abcd1234", &permissive_inbound, None) + .unwrap_err(); + assert_eq!(err.code, MxcErrorCode::PolicyValidation); + assert!( + err.message.contains("allowLocalNetwork"), + "expected the allowLocalNetwork rejection, got: {}", + err.message + ); + } + + #[test] + fn validate_start_accepts_a_restriction_without_an_enforcement_mode() { + // The negative control for the test above, and the core behavior change + // seen through the caller: the same host restriction with no + // `enforcementMode` is now enforceable through iptables, so validation + // accepts it instead of refusing it as unenforceable. Without this, + // "reject everything" would pass. + let runner = LxcStateAwareRunner::with_lxc_installed(); + let req = ExecutionRequest { + policy: restrictive_policy(), + ..Default::default() + }; + runner + .validate_start("lxc:mxc-abcd1234", &req, None) + .unwrap(); + } + + #[test] + fn validate_exec_rejects_policy() { + let runner = LxcStateAwareRunner::with_lxc_installed(); + let req = ExecutionRequest { + policy: ContainerPolicy { + blocked_hosts: vec!["example.com".to_string()], + network_enforcement_mode: NetworkEnforcementMode::Firewall, + ..Default::default() + }, + ..Default::default() + }; + let err = runner + .validate_exec("lxc:mxc-abcd1234", &req, None) + .unwrap_err(); + assert_eq!(err.code, MxcErrorCode::PolicyValidation); + } + + #[test] + fn a_lower_version_is_below_the_minimum() { + assert!(!schema_version_is_at_least("0.7.0-alpha", "0.8.0")); + } + + #[test] + fn an_absent_version_is_below_the_minimum() { + assert!(!schema_version_is_at_least("", "0.8.0")); + } + + #[test] + fn an_unparsable_version_is_below_the_minimum() { + assert!(!schema_version_is_at_least("banana", "0.8.0")); + } + + #[test] + fn a_pre_release_of_the_minimum_is_at_or_above_it() { + assert!(schema_version_is_at_least("0.8.0-alpha", "0.8.0")); + } + + #[test] + fn a_higher_version_is_at_or_above_the_minimum() { + assert!(schema_version_is_at_least("0.9.0-alpha", "0.8.0")); + } + + #[test] + fn the_floor_refusal_names_the_minimum_and_the_declared_version() { + let err = enforce_schema_version_floor("0.7.0-alpha").unwrap_err(); + assert_eq!(err.code, MxcErrorCode::MalformedRequest); + assert!(err.message.contains("0.8.0"), "message: {}", err.message); + assert!( + err.message.contains("schema version '0.7.0-alpha'"), + "message: {}", + err.message + ); + } + + #[test] + fn the_floor_refusal_names_an_absent_version_as_absent() { + let err = enforce_schema_version_floor("").unwrap_err(); + assert_eq!(err.code, MxcErrorCode::MalformedRequest); + assert!( + err.message.contains("no schema version"), + "message: {}", + err.message + ); + } + + #[test] + fn the_floor_admits_the_minimum_version() { + assert!(enforce_schema_version_floor("0.8.0-alpha").is_ok()); + } + + #[test] + fn a_phase_hook_does_not_carry_the_version_gate() { + // The gate is `lxc-exec`'s, applied once ahead of the lifecycle. A hook + // that refused a below-floor request here would be a second copy. + let runner = LxcStateAwareRunner::with_lxc_installed(); + let req = ExecutionRequest { + schema_version: "0.7.0-alpha".to_string(), + ..Default::default() + }; + assert!(runner.validate_stop("lxc:mxc-abcd1234", &req, None).is_ok()); + } + + #[test] + fn state_aware_runner_is_constructible_next_to_one_shot_lifecycle() { + let _runner = LxcStateAwareRunner::new(); + let lifecycle = LifecycleConfig::default(); + assert!(lifecycle.destroy_on_exit); + } +} diff --git a/src/core/lxc/Cargo.toml b/src/core/lxc/Cargo.toml index 6b0b692d7..551ac5ca9 100644 --- a/src/core/lxc/Cargo.toml +++ b/src/core/lxc/Cargo.toml @@ -22,6 +22,7 @@ mxc_engine = { workspace = true } lxc_common = { workspace = true } anyhow = { workspace = true } clap = { workspace = true } +serde_json = { workspace = true } nanvix_binaries = { path = "../../backends/nanvix/binaries", optional = true } [target.'cfg(target_arch = "x86_64")'.dependencies] diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index ea26499e9..ccd2a317b 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -6,10 +6,13 @@ use std::process; use std::time::Instant; use clap::Parser; -use wxc_common::config_parser::load_request; +use wxc_common::config_parser::{load_mxc_request, ParseError}; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{ExecutionRequest, ScriptResponse}; +use wxc_common::mxc_error::{MxcError, ResponseEnvelope}; use wxc_common::script_runner::handle_dry_run_exit; +use wxc_common::state_aware_dispatch::DispatchOutcome; +use wxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest}; use wxc_common::telemetry; use lxc_common::signal_cleanup; @@ -108,9 +111,22 @@ fn delete_lxc_container(name: &str, logger: &mut Logger) -> bool { let container = LxcContainer::new(name, None); - if !container.is_defined() { - logger.log_line(&format!("Container '{}' does not exist.", name)); - return false; + match container.is_defined() { + Ok(true) => {} + Ok(false) => { + logger.log_line(&format!("Container '{}' does not exist.", name)); + return false; + } + Err(e) => { + // An unanswered probe is not an answer of "absent". The caller + // asked for the container to be gone, so try the destroy and let + // its outcome be the report rather than leaving a container behind + // on the strength of a question we could not ask. + logger.log_line(&format!( + "Could not determine whether container '{}' exists ({}); attempting delete anyway.", + name, e + )); + } } match container.destroy() { @@ -125,6 +141,71 @@ fn delete_lxc_container(name: &str, logger: &mut Logger) -> bool { } } +fn run_state_aware_main( + mut parsed: ParsedStateAwareRequest, + dry_run: bool, + experimental: bool, + testing_features: bool, + logger: &mut Logger, +) -> ! { + parsed.request.experimental_enabled = experimental; + parsed.request.testing_features_enabled = testing_features; + parsed.request.dry_run = dry_run; + + // A state-aware phase never reaches the one-shot telemetry init in `main`, + // so start the provider here. Telemetry is stable and is not gated on + // `--experimental`. + let telemetry_active = parsed + .request + .telemetry + .as_ref() + .map(|config| telemetry::init(config, logger)) + .unwrap_or(false); + + // Shares the `wxc` executor's telemetry / correlation-vector orchestration + // rather than calling dispatch directly, so a Linux lifecycle emits the same + // events, carries the same MS-CV, and installs the same crash hook. + let outcome = + mxc_engine::run_state_aware_with_telemetry(parsed, dry_run, telemetry_active, logger); + + // Mirrors the wxc executor: on dispatch failure the error goes only to the + // auxiliary diagnostic sinks (log file / diagnostic pipe), never the primary + // buffer/stderr, so the client-facing error envelope below is not shadowed + // by a duplicate. + if let Err(error) = &outcome { + logger.log_diagnostic_line(&error.to_string()); + } + + let buffered = logger.get_buffer().to_string(); + if !buffered.is_empty() { + eprint!("{}", buffered); + } + match outcome { + Ok(DispatchOutcome::Envelope(value)) => { + println!("{}", value); + process::exit(0); + } + Ok(DispatchOutcome::ExecCompleted { exit_code }) => process::exit(exit_code), + Err(e) => { + println!("{}", error_envelope_string(&e)); + process::exit(1); + } + } +} + +/// Serialise `error` to its JSON response-envelope string, including the +/// last-resort fallback for when the envelope itself fails to serialise. +/// +/// Every caller writes it to stdout, which the cross-backend contract (§7.3) +/// reserves for the response envelope in every phase. +fn error_envelope_string(error: &MxcError) -> String { + let envelope: ResponseEnvelope<()> = ResponseEnvelope::from_error(error); + serde_json::to_string(&envelope).unwrap_or_else(|_| { + r#"{"error":{"code":"backend_error","message":"failed to serialise error envelope"}}"# + .to_string() + }) +} + fn main() { // Install before spawning any other threads so the signal mask propagates. // Failure here is fatal: install() either succeeds with the watchdog @@ -191,11 +272,14 @@ fn main() { (String::new(), false) }; - let mut logger = Logger::new(if cli.debug { - Mode::Console - } else { - Mode::Buffer - }); + // Always buffered until the request is parsed. `--debug` selects console + // logging, which writes diagnostics straight to stdout -- but a state-aware + // request needs stdout to carry nothing but its JSON envelope, and config + // parsing emits warnings (overlapping path lists, a mount path missing on + // the host) through this logger before the phase is even known. Promoting + // to console only on the one-shot branch keeps `{ debug: true }` from + // corrupting the state-aware channel. + let mut logger = Logger::new(Mode::Buffer); if let Some(ref log_path) = cli.log_file { if let Err(e) = logger.enable_file_sink(std::path::Path::new(log_path)) { @@ -218,14 +302,38 @@ fn main() { } // Load request - let mut request = match load_request(&config_data, &mut logger, is_base64) { - Ok(r) => r, - Err(_) => { + let request = match load_mxc_request(&config_data, &mut logger, is_base64) { + Ok(MxcRequest::OneShot(req)) => { + // One-shot owns stdout outright, so `--debug` streams there as it + // always has. Flush what was buffered while the phase was still + // unknown so the switch costs no output. + if cli.debug { + let buffered = logger.promote_to_console(); + print!("{}", buffered); + } + req + } + Ok(MxcRequest::StateAware(parsed)) => run_state_aware_main( + parsed, + cli.dry_run, + cli.experimental, + cli.allow_testing_features, + &mut logger, + ), + Err(ParseError::OneShot(_)) + | Err(ParseError::OneShotMalformed(_)) + | Err(ParseError::Decode(_)) => { eprint!("Request error\n{}", logger.get_buffer()); process::exit(1); } + Err(ParseError::StateAware(e)) => { + println!("{}", error_envelope_string(&e)); + eprint!("{}", logger.get_buffer()); + process::exit(1); + } }; + let mut request = request; request.experimental_enabled = cli.experimental; request.testing_features_enabled = cli.allow_testing_features; request.dry_run = cli.dry_run; diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index dfaceae4a..b01f158e3 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -374,8 +374,7 @@ Ok(()) } ``` -Three backends implement the state-aware lifecycle — IsolationSession, WSLc and -Windows Sandbox. Only IsolationSession serves `exec_sandbox`. +Four backends implement the state-aware lifecycle: IsolationSession, WSLc, and Windows Sandbox (Windows-only), and Lxc (Linux-only). Only IsolationSession serves `exec_sandbox`. `exec_attached` is verified against **IsolationSession** only. diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 17d403b91..92e3197b1 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -210,7 +210,7 @@ pub fn run(request: SandboxRequest) -> Result { /// /// `experimental` is the in-process equivalent of the executor's /// `--experimental` flag. The experimental backends — WindowsSandbox, -/// IsolationSession and WSLc — are refused with +/// IsolationSession, WSLc, and Lxc — are refused with /// [`ErrorCode::BackendUnavailable`] unless it is set, before any work is done. /// It is an API parameter rather than a field in the request JSON so that a /// config cannot grant itself experimental access. diff --git a/src/core/mxc-sdk/tests/state_aware.rs b/src/core/mxc-sdk/tests/state_aware.rs index 2d3644bc3..ac4902b3f 100644 --- a/src/core/mxc-sdk/tests/state_aware.rs +++ b/src/core/mxc-sdk/tests/state_aware.rs @@ -5,10 +5,11 @@ //! (`run_state_aware_json` / `exec_sandbox`). //! //! These exercise request parsing, phase routing, and error mapping without a -//! live host backend. All three state-aware backends — IsolationSession, WSLc -//! and Windows Sandbox — are Windows-only, and IsolationSession additionally -//! needs the OS-side IsoSessionOps service, so the real lifecycle paths are -//! exercised by the executor E2E suites instead. +//! live host backend. The state-aware backends — IsolationSession, WSLc, and +//! Windows Sandbox (all Windows-only) and Lxc (Linux-only) — all require a +//! live host environment, and IsolationSession additionally needs the OS-side +//! IsoSessionOps service, so the real lifecycle paths are exercised by the +//! executor E2E suites instead. //! //! Those suites drive the `ExecStdio::Relayed` path. The //! `ExecStdio::Piped` path [`exec_sandbox`] uses is covered end-to-end by diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index afcafed4b..b87624cd2 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -23,6 +23,8 @@ //! - [`run`] / [`resolve_runner`] (Windows) — run-to-completion backend //! selection and execution. //! - [`run_state_aware`] — state-aware lifecycle backend resolution + dispatch. +//! - [`run_state_aware_with_telemetry`] — the same dispatch wrapped in the +//! shared telemetry / correlation-vector orchestration every executor needs. //! - [`platform_support`] / [`PlatformSupport`] — host support detection. //! - [`available_backends`] / [`AvailableBackend`] — read-only host //! backend-availability probe (with effective isolation tier). @@ -40,6 +42,7 @@ mod probe; #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] mod run; mod state_aware; +mod state_aware_telemetry; #[cfg(target_os = "windows")] mod verbose_telemetry; @@ -61,6 +64,7 @@ pub use run::{log_policy_hash, resolve_runner, run, ResolvedRunner}; pub use state_aware::{ exec_state_aware_attached, exec_state_aware_json, run_state_aware, run_state_aware_json, }; +pub use state_aware_telemetry::run_state_aware_with_telemetry; #[cfg(target_os = "windows")] pub use verbose_telemetry::emit_verbose_telemetry; diff --git a/src/core/mxc_engine/src/state_aware.rs b/src/core/mxc_engine/src/state_aware.rs index 401c492a0..de1e405e3 100644 --- a/src/core/mxc_engine/src/state_aware.rs +++ b/src/core/mxc_engine/src/state_aware.rs @@ -60,6 +60,7 @@ fn require_experimental_optin( wxc_common::models::ContainmentBackend::WindowsSandbox | wxc_common::models::ContainmentBackend::IsolationSession | wxc_common::models::ContainmentBackend::Wslc + | wxc_common::models::ContainmentBackend::Lxc ) && !parsed.request.experimental_enabled { return Err(MxcError::backend_unavailable(format!( @@ -139,6 +140,14 @@ pub fn run_state_aware( let mut runner = isolation_session_common::IsolationSessionRunner::new(); wxc_common::state_aware_dispatch::dispatch_state_aware(&mut runner, parsed, dry_run) } + #[cfg(target_os = "linux")] + wxc_common::models::ContainmentBackend::Lxc => { + lxc_common::state_aware::enforce_schema_version_floor( + parsed.request.schema_version.as_str(), + )?; + let mut runner = lxc_common::state_aware::LxcStateAwareRunner::new(); + wxc_common::state_aware_dispatch::dispatch_state_aware(&mut runner, parsed, dry_run) + } #[cfg(all(target_os = "windows", feature = "wslc"))] wxc_common::models::ContainmentBackend::Wslc => { let mut runner = wslc_common::WslcStateAwareRunner::new(); @@ -200,10 +209,51 @@ pub fn exec_state_aware( wxc_common::models::ContainmentBackend::IsolationSession => { Err(isolation_session_unavailable()) } - _ => Err(MxcError::unsupported_phase(format!( - "backend {:?} does not implement the state-aware lifecycle", - backend - ))), + _ => Err(exec_unsupported_error(&backend)), + } +} + +/// The error returned when a backend cannot serve a **streaming** exec. +/// +/// Two distinct situations reach here and the caller needs to tell them apart: +/// +/// - The backend has no state-aware lifecycle at all, so nothing about it works +/// through these APIs. +/// - The backend does implement the lifecycle ([`run_state_aware`] dispatches +/// provision/start/exec/stop/deprovision for it) but has no streaming +/// [`SandboxProcess`], so only this one API is unavailable. +/// +/// LXC is the second case. Reporting it as "does not implement the state-aware +/// lifecycle" sent callers off to debug a provision path that works fine, so +/// the message names the real gap and points at the API that does work. +fn exec_unsupported_error(backend: &wxc_common::models::ContainmentBackend) -> MxcError { + if backend_has_state_aware_lifecycle(backend) { + MxcError::unsupported_phase(format!( + "backend {backend:?} implements the state-aware lifecycle but not streaming exec; \ + use the non-streaming exec phase instead" + )) + } else { + MxcError::unsupported_phase(format!( + "backend {backend:?} does not implement the state-aware lifecycle" + )) + } +} + +/// Whether `backend` has a `StatefulSandboxBackend` impl wired into +/// [`run_state_aware`] on this target. Kept next to that `match` so the two stay +/// in step — a backend added there without being added here would be described +/// by the wrong error. +fn backend_has_state_aware_lifecycle(backend: &wxc_common::models::ContainmentBackend) -> bool { + match backend { + #[cfg(target_os = "windows")] + wxc_common::models::ContainmentBackend::WindowsSandbox => true, + #[cfg(all(target_os = "windows", feature = "isolation_session"))] + wxc_common::models::ContainmentBackend::IsolationSession => true, + #[cfg(all(target_os = "windows", feature = "wslc"))] + wxc_common::models::ContainmentBackend::Wslc => true, + #[cfg(target_os = "linux")] + wxc_common::models::ContainmentBackend::Lxc => true, + _ => false, } } @@ -411,7 +461,7 @@ fn exec_state_aware_attached_with( /// or [`exec_state_aware_json`] to drive the pipes yourself. /// /// `experimental` opts in to the experimental backends (WindowsSandbox, -/// IsolationSession, WSLc); without it they are refused with +/// IsolationSession, WSLc, Lxc); without it they are refused with /// `backend_unavailable` before any work is done. pub fn run_state_aware_json( request_json: &str, @@ -638,6 +688,49 @@ mod tests { assert!(error.message.contains("experimental")); } + #[test] + fn lxc_state_aware_requires_experimental_optin() { + let parsed = ParsedStateAwareRequest { + request: ExecutionRequest::default(), + phase: Phase::Provision, + containment: Some(ContainmentBackend::Lxc), + sandbox_id: None, + experimental_raw: None, + source_text: None, + }; + + let error = run_state_aware(parsed, false).unwrap_err(); + + assert_eq!(error.code, MxcErrorCode::BackendUnavailable); + assert!(error.message.contains("experimental")); + } + + #[test] + #[cfg(target_os = "linux")] + fn exec_error_distinguishes_missing_streaming_from_missing_lifecycle() { + // LXC dispatches the lifecycle but has no streaming SandboxProcess. The + // old blanket message sent callers off to debug a provision path that + // works, so the two cases must read differently. + let lxc = exec_unsupported_error(&ContainmentBackend::Lxc); + assert_eq!(lxc.code, MxcErrorCode::UnsupportedPhase); + assert!( + lxc.message.contains("not streaming exec"), + "expected the streaming-specific message, got {:?}", + lxc.message + ); + assert!(!lxc.message.contains("does not implement")); + + let bwrap = exec_unsupported_error(&ContainmentBackend::Bubblewrap); + assert_eq!(bwrap.code, MxcErrorCode::UnsupportedPhase); + assert!( + bwrap + .message + .contains("does not implement the state-aware lifecycle"), + "expected the no-lifecycle message, got {:?}", + bwrap.message + ); + } + #[test] fn exec_experimental_backend_requires_optin() { // The streaming exec entry point applies the same opt-in gate as the @@ -663,6 +756,28 @@ mod tests { assert!(error.message.contains("experimental")); } + #[test] + fn exec_lxc_experimental_backend_requires_optin() { + let parsed = ParsedStateAwareRequest { + request: ExecutionRequest::default(), + phase: Phase::Exec, + containment: Some(ContainmentBackend::Lxc), + sandbox_id: Some("lxc:00000000000000000000000000000000".to_string()), + experimental_raw: None, + source_text: None, + }; + + let error = match exec_state_aware(parsed) { + Ok(_) => { + panic!("expected the experimental gate to reject an lxc exec without the opt-in") + } + Err(e) => e, + }; + + assert_eq!(error.code, MxcErrorCode::BackendUnavailable); + assert!(error.message.contains("experimental")); + } + #[test] fn attached_exec_gate_admits_only_a_terminal_host() { assert!(exec_attached_gate(|| true).is_ok()); diff --git a/src/core/mxc_engine/src/state_aware_telemetry.rs b/src/core/mxc_engine/src/state_aware_telemetry.rs new file mode 100644 index 000000000..b2201889d --- /dev/null +++ b/src/core/mxc_engine/src/state_aware_telemetry.rs @@ -0,0 +1,310 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Telemetry and correlation-vector orchestration for the state-aware +//! lifecycle. +//! +//! This wraps [`crate::run_state_aware`] with everything a lifecycle dispatch +//! needs around it: process attribution (backend, sandbox kind, and phase), the +//! Microsoft Correlation Vector (MS-CV) for the phase, the effective policy +//! hash, the crash panic hook, the sandbox identity record, and the terminal +//! `emit_state_aware` event. +//! +//! It lives here — rather than in an executor binary — because every executor +//! that dispatches the state-aware lifecycle needs identical behavior. When +//! this logic lived in `wxc`'s `main.rs`, the `lxc` executor called +//! [`crate::run_state_aware`] directly and so silently produced no lifecycle +//! telemetry and no correlation vector: a Linux lifecycle was invisible to the +//! same dashboards that observed a Windows one. Sharing the orchestration is +//! what keeps the two executors from drifting apart again. + +use std::time::Instant; + +use wxc_common::audit::{AuditEvent, AuditEventName}; +use wxc_common::config_rejection::log_config_rejection_for_outcome; +use wxc_common::logger::Logger; +use wxc_common::mxc_error::MxcError; +use wxc_common::policy_identity; +use wxc_common::state_aware_dispatch::{resolve_backend, DispatchOutcome}; +use wxc_common::state_aware_request::ParsedStateAwareRequest; +use wxc_common::telemetry; + +/// Marker logged for the `MXC.PolicyHash` identity field when the caller +/// supplied a `sandboxId` for this phase. This call happens before the +/// dispatcher validates the id against real backend state, so a caller-chosen +/// string that merely *looks* like an MXC-minted `iso:`/`wsb:`/`lxc:` id would +/// otherwise pass `redact_identity`'s shape check unverified. Since MXC has not +/// confirmed the id yet, the field records only that a caller-provided id was +/// present, not its value; the later, post-dispatch `SandboxIdentity` record is +/// unaffected and still discloses the real, backend-verified identity on +/// success. +const UNVERIFIED_SANDBOX_ID_MARKER: &str = "unverified"; + +/// Resolve the identity to report on `MXC.PolicyHash` for this phase. +pub fn state_aware_policy_identity(sandbox_id: Option<&str>) -> String { + match sandbox_id.filter(|id| !id.is_empty()) { + None => policy_identity::redact_identity("CLI"), + Some(_) => UNVERIFIED_SANDBOX_ID_MARKER.to_string(), + } +} + +/// Resolve the sandbox id to report on `mxc.SandboxIdentity` for a completed +/// state-aware dispatch. +/// +/// `provision` mints the id, so it is read out of the result envelope; every +/// later phase carries the id inbound. Returns `None` when the phase failed — +/// a failed dispatch produced no sandbox to identify, and emitting an identity +/// record for one would be a lie. +pub fn sandbox_id_for_identity_record( + outcome: &Result, + incoming_sandbox_id: Option<&str>, +) -> Option { + let Ok(outcome) = outcome else { + return None; + }; + if let DispatchOutcome::Envelope(value) = outcome { + if let Some(minted) = value + .get("result") + .and_then(|r| r.get("sandboxId")) + .and_then(|v| v.as_str()) + { + return Some(minted.to_string()); + } + } + incoming_sandbox_id.map(str::to_string) +} + +/// Run a state-aware phase with full telemetry and correlation-vector +/// orchestration, returning the dispatch outcome for the caller to render. +/// +/// The caller still owns the terminal behavior (flushing `logger`'s buffer, +/// writing the envelope to stdout, choosing an exit code) — only the +/// observability wrapper is shared. Telemetry is stable and independent of the +/// `--experimental` gate used by experimental containment backends, so the +/// caller decides activation and passes it in. +pub fn run_state_aware_with_telemetry( + parsed: ParsedStateAwareRequest, + dry_run: bool, + telemetry_active: bool, + logger: &mut Logger, +) -> Result { + // Resolve attribution (phase + backend) BEFORE dispatch consumes `parsed`. + let phase = parsed.phase.as_str(); + // Whether this invocation is the provision phase: its `sandbox_id` doesn't + // exist yet, so it always seeds a fresh base rather than deriving one. + let is_provision = phase == "provision"; + // `sandbox_id` for non-provision phases, from which the lifecycle's shared + // correlation base is derived internally. Captured before `dispatch` + // consumes `parsed`. `None` for provision. + let sandbox_id = if is_provision { + None + } else { + parsed.sandbox_id.clone() + }; + let resolved_backend = resolve_backend(&parsed).ok(); + let backend = resolved_backend + .as_ref() + .map(|b| b.wire_name()) + .unwrap_or("unknown"); + let requested_sandbox_kind = parsed + .request + .telemetry + .as_ref() + .and_then(|config| config.requested_sandbox_kind); + + // This phase's Microsoft Correlation Vector (MS-CV), purely internal to + // MXC — no caller supplies or relays one. `provision` seeds a fresh + // vector (persisted post-dispatch below, once its `sandbox_id` exists); + // every later phase recalls that persisted lifecycle root and spins a + // child off it. Empty when telemetry is inactive. + let correlation = telemetry::correlation_state::pre_dispatch_vector( + telemetry_active, + is_provision, + sandbox_id.as_deref(), + ); + + // Attribute out-of-band emit paths (the console-control handler installed in + // `main`, and the panic hook installed just below) to the resolved backend + // and the lifecycle phase, and install a crash-telemetry panic hook for this + // dispatch — mirroring the one-shot path, which the `-> !` entry points + // bypass. The shared hook chains the previous hook (default stderr backtrace + // still prints) and is panic-free. + if telemetry_active { + if let Some(containment) = resolved_backend.as_ref() { + telemetry::set_process_context_with_kind(containment, requested_sandbox_kind); + } + telemetry::set_process_phase(phase); + // Stash this phase's correlation vector so out-of-band events + // (panic / cancellation) carry the same cV as the terminal emit below. + telemetry::set_process_correlation_vector(&correlation); + telemetry::install_panic_hook(); + } + + let started = Instant::now(); + // State-aware dispatch bypasses the one-shot runner funnel, so anchor the + // effective lifecycle policy here before the request is consumed. + let phase_config = parsed.experimental_raw.as_ref().and_then(|raw| { + resolved_backend.as_ref().and_then(|backend| { + raw.get(backend.wire_name()) + .and_then(|section| section.get(phase)) + }) + }); + let diagnostics_active = logger.has_diagnostic_sink(); + if telemetry_active || diagnostics_active { + let policy_hash = + policy_identity::state_aware_policy_hash(&parsed.request, backend, phase, phase_config); + let identity = state_aware_policy_identity(parsed.sandbox_id.as_deref()); + telemetry::log_policy_hash(&identity, &policy_hash, &parsed.request.schema_version); + if diagnostics_active { + let record = AuditEvent::new(AuditEventName::PolicyHash) + .str("backend", backend) + .str("policy_hash", &policy_hash) + .str("config_schema_version", &parsed.request.schema_version); + logger.log_audit_event(&record); + } + } + + // Publish the driver's diagnostic sinks (--log-file, and the diagnostic + // console pipe on Windows) on this thread so a backend whose + // `StatefulSandboxBackend::exec` signature has no `Logger` parameter + // can inherit them via `Logger::inherit_thread_diagnostic_sink` instead + // of building a throwaway `Logger::new(Mode::Buffer)` and silently + // dropping every record. The returned guard clears the sink when it + // drops -- including if `run_state_aware` panics -- so we never leak + // duplicated handles across independent invocations even on an unwind. + let diag_sink_guard = logger.install_thread_diagnostic_sink(); + let outcome = crate::run_state_aware(parsed, dry_run); + drop(diag_sink_guard); + let elapsed = started.elapsed(); + + // Persist (provision) or forget (deprovision) this lifecycle's correlation + // root now that the outcome — and, for provision, the freshly minted + // `sandbox_id` — is known. + if is_provision { + telemetry::correlation_state::on_provision_outcome( + telemetry_active, + &correlation, + &outcome, + ); + } else if phase == "deprovision" { + if let Some(id) = sandbox_id.as_deref() { + telemetry::correlation_state::on_deprovision_outcome( + telemetry_active, + id, + dry_run, + &outcome, + ); + } + } + + // Record the sandbox identity join key. For `isolation_session` the + // `sandboxId` tail is the OS-side `provisionId`, which is what joins an MXC + // record to the `Microsoft.Windows.IsolationSession` OS records. Emitted on + // success only: a failed phase produced no sandbox to identify. + if let Some(sandbox_id) = sandbox_id_for_identity_record(&outcome, sandbox_id.as_deref()) { + let record = AuditEvent::new(AuditEventName::SandboxIdentity) + .str("backend", backend) + .str("identity", &policy_identity::redact_identity(&sandbox_id)) + .str_opt("phase", phase); + logger.log_audit_event(&record); + } + + log_config_rejection_for_outcome(logger, &outcome, backend, phase); + + // Emit lifecycle telemetry (and shut the provider down) before the caller + // flushes the diagnostic buffer / envelope. Terminal path — safe to shut + // down here. + telemetry::emit_state_aware_with_kind( + telemetry_active, + requested_sandbox_kind, + telemetry::TelemetryContext { + backend, + phase, + correlation_vector: &correlation, + }, + &outcome, + elapsed, + ); + + outcome +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn identity_record_reads_the_minted_id_out_of_a_provision_envelope() { + let outcome = Ok(DispatchOutcome::Envelope( + json!({"result": {"sandboxId": "iso:wxc-abcd1234"}}), + )); + assert_eq!( + sandbox_id_for_identity_record(&outcome, None).as_deref(), + Some("iso:wxc-abcd1234") + ); + } + + #[test] + fn identity_record_falls_back_to_the_inbound_id_for_later_phases() { + // Later phases return an envelope with no `sandboxId` (the client + // already has it), so the inbound id is the one to report. + let outcome = Ok(DispatchOutcome::Envelope(json!({"result": {}}))); + assert_eq!( + sandbox_id_for_identity_record(&outcome, Some("iso:wxc-abcd1234")).as_deref(), + Some("iso:wxc-abcd1234") + ); + + // Exec completes without an envelope at all. + let exec = Ok(DispatchOutcome::ExecCompleted { exit_code: 0 }); + assert_eq!( + sandbox_id_for_identity_record(&exec, Some("iso:wxc-abcd1234")).as_deref(), + Some("iso:wxc-abcd1234") + ); + } + + #[test] + fn no_identity_record_for_a_failed_phase() { + // A failed dispatch produced no sandbox to identify; claiming one would + // be a lie. + let outcome = Err(MxcError::backend_unavailable("nope")); + assert!(sandbox_id_for_identity_record(&outcome, Some("iso:wxc-abcd1234")).is_none()); + } + + #[test] + fn entra_provision_ids_are_never_logged_verbatim() { + // `state_aware.rs::provision` sets `provision_id = user.upn` for Entra + // sandboxes, so the sandboxId tail is a real user identifier. It must not + // reach a log file in any recoverable form. + let outcome = Ok(DispatchOutcome::Envelope( + json!({"result": {"sandboxId": "iso:alice@contoso.com"}}), + )); + let id = sandbox_id_for_identity_record(&outcome, None).expect("id"); + let rendered = policy_identity::redact_identity(&id); + assert!(!rendered.contains("alice"), "got: {rendered}"); + assert!(!rendered.contains('@'), "got: {rendered}"); + assert_eq!( + rendered, + policy_identity::ENTRA_UPN_MARKER, + "got: {rendered}" + ); + } + + #[test] + fn state_aware_policy_identity_uses_sandbox_join_key() { + assert_eq!(state_aware_policy_identity(None), "CLI"); + // An empty id is no id at all. + assert_eq!(state_aware_policy_identity(Some("")), "CLI"); + // A caller-supplied sandbox id is not yet backend-verified at this + // logging call site, so it must not be echoed back even when it + // happens to match the shape of an MXC-minted id. + assert_eq!( + state_aware_policy_identity(Some("wsb:0123abcd")), + UNVERIFIED_SANDBOX_ID_MARKER + ); + assert_eq!( + state_aware_policy_identity(Some("some-caller-chosen-string")), + UNVERIFIED_SANDBOX_ID_MARKER + ); + } +} diff --git a/src/core/mxc_pty/src/lib.rs b/src/core/mxc_pty/src/lib.rs index 7ebd8e94e..4758851d7 100644 --- a/src/core/mxc_pty/src/lib.rs +++ b/src/core/mxc_pty/src/lib.rs @@ -257,6 +257,10 @@ pub fn run_with_pty(mut command: Command, options: PtyOptions) -> Result(); + // Lets the drain below tell a reader that is still forwarding output from a + // reader that is merely blocked on a pty a leaked process left open. + let bytes_forwarded = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let reader_progress = std::sync::Arc::clone(&bytes_forwarded); let output_thread = thread::spawn(move || { let mut buf = [0u8; 4096]; let mut signaled = false; @@ -271,6 +275,7 @@ pub fn run_with_pty(mut command: Command, options: PtyOptions) -> Result break, } @@ -312,6 +317,26 @@ pub fn run_with_pty(mut command: Command, options: PtyOptions) -> Result { let status = child.wait().map_err(|e| format!("wait: {}", e))?; @@ -322,29 +347,153 @@ pub fn run_with_pty(mut command: Command, options: PtyOptions) -> Result break PtyOutcome::Exited(status), Ok(None) => { if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); + kill_child_and_group(&mut child); break PtyOutcome::TimedOut; } thread::sleep(PtyOptions::POLL_INTERVAL); } Err(e) => { - let _ = child.kill(); - let _ = child.wait(); + kill_child_and_group(&mut child); return Err(format!("try_wait: {}", e)); } } }, }; - // Drain remaining output before returning. The secondary fds are closed - // on child exit, so primary_reader hits EOF and the thread exits. - let _ = output_thread.join(); + // Drain remaining output before returning. + const DRAIN_GRACE: Duration = Duration::from_secs(2); + match outcome { + PtyOutcome::Exited(_) => { + // Normally the child's exit closes its secondary fds, primary_reader + // hits EOF, and the drain thread finishes on its own — so join + // unbounded and every last byte is flushed. + // + // But the direct child exiting does not guarantee EOF. A process it + // left running that escaped into its own session still holds the pty + // secondary open, and then this join blocks forever. That defeats a + // requested timeout exactly as the killed-child case does, just via + // a different outcome. So whenever a deadline was asked for, bound + // the drain. + // + // Neither a flat grace nor the remaining budget is the right bound + // on its own. A flat grace charges the common case -- a child that + // exits quickly having buffered a lot of output -- for a hazard a + // different one creates, and truncates output this function promises + // to forward. The remaining budget alone lets a silent leaked holder + // keep the caller for the whole timeout the run never needed, which + // is worse than the flat grace for precisely the case the bound + // exists for. + // + // The two are distinguishable: a real drain keeps producing bytes, + // and a leaked holder of an idle pty produces none. So the bound is + // silence, capped by the budget. Output flowing extends the wait, + // DRAIN_GRACE of nothing ends it, and a pathological slow drip still + // cannot outlast what the caller already authorized. + match deadline { + Some(d) => { + let _ = join_while_draining( + output_thread, + &bytes_forwarded, + DRAIN_GRACE, + d + DRAIN_GRACE, + ); + } + None => { + let _ = output_thread.join(); + } + } + } + PtyOutcome::TimedOut => { + // We killed the child's process group, but a process it left + // running inside the sandbox that escaped into its own session can + // still hold the pty secondary open — primary_reader would then + // never see EOF and an unbounded join would block forever, + // defeating the very timeout we just enforced. Give the drain a + // short grace to flush buffered output, then abandon the reader. + // A one-shot executor exits immediately afterward and the OS reaps + // the thread, but an in-process caller does not, so the thread and + // the pty fd survive until its host exits. + let _ = join_with_timeout(output_thread, DRAIN_GRACE); + } + } Ok(outcome) } -/// Background thread that watches for SIGWINCH on the outer pty +/// Join `handle`, returning once the thread finishes or `grace` elapses — +/// whichever comes first. Returns `true` if the thread finished within the +/// grace period, `false` if it was abandoned. +/// +/// [`run_with_pty`] uses this to drain a killed child's buffered output on the +/// timeout path without risking a permanent hang: a process the timed-out +/// child left running inside the sandbox can keep the pty open, so the reader +/// thread never sees EOF and a plain `JoinHandle::join` would block forever. +/// The abandoned reader thread lives until its host process exits, which is +/// immediate for a one-shot executor and not immediate for an in-process +/// caller such as the SDK or the FFI layer. +/// +/// Implemented with a helper thread that performs the blocking join and +/// signals a channel, since the standard library has no join-with-timeout. +#[cfg_attr(not(any(target_os = "linux", target_os = "macos")), allow(dead_code))] +fn join_with_timeout(handle: std::thread::JoinHandle<()>, grace: Duration) -> bool { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = handle.join(); + let _ = tx.send(()); + }); + rx.recv_timeout(grace).is_ok() +} + +/// Join `handle`, waiting for as long as it keeps making progress but giving up +/// after `idle_grace` of silence, and in no case later than `hard_deadline`. +/// +/// Returns whether the thread finished. +/// +/// `progress` is a monotonically increasing count the joined thread bumps +/// whenever it does the work being waited on. That is what separates the two +/// situations a plain timeout cannot tell apart: a reader still forwarding a +/// large buffered backlog, which deserves as long as it needs, and a reader +/// blocked on a pty a leaked process is holding open, which will never finish +/// and must not hold the caller. `hard_deadline` keeps even a pathological slow +/// drip from outlasting the budget the caller authorized. +#[cfg_attr(not(any(target_os = "linux", target_os = "macos")), allow(dead_code))] +fn join_while_draining( + handle: std::thread::JoinHandle<()>, + progress: &std::sync::atomic::AtomicU64, + idle_grace: Duration, + hard_deadline: std::time::Instant, +) -> bool { + use std::sync::atomic::Ordering; + + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = handle.join(); + let _ = tx.send(()); + }); + + let mut last_seen = progress.load(Ordering::Relaxed); + loop { + let budget = + idle_grace.min(hard_deadline.saturating_duration_since(std::time::Instant::now())); + match rx.recv_timeout(budget) { + Ok(()) => return true, + // The joining helper vanished without reporting; nothing further to + // wait for. + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return true, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if std::time::Instant::now() >= hard_deadline { + return false; + } + let seen = progress.load(Ordering::Relaxed); + if seen == last_seen { + return false; + } + last_seen = seen; + } + } + } +} + /// (delivered to *some* thread because fd 0 is the outer secondary) and /// forwards the new window size to the inner pty primary via TIOCSWINSZ. /// @@ -526,6 +675,126 @@ mod tests { assert_eq!(PtyOptions::POLL_INTERVAL, Duration::from_millis(500)); } + #[test] + fn join_with_timeout_returns_true_when_thread_finishes() { + let handle = std::thread::spawn(|| {}); + assert!(join_with_timeout(handle, Duration::from_secs(5))); + } + + #[test] + fn join_with_timeout_gives_up_on_a_blocked_thread() { + // A thread that blocks far past the grace must not make + // join_with_timeout wait for it — this is what stops a leaked + // pty-holding child from hanging exec forever. + let (tx, rx) = std::sync::mpsc::channel::<()>(); + let handle = std::thread::spawn(move || { + // Blocks until the test drops `tx`, well past the grace below. + let _ = rx.recv(); + }); + let start = std::time::Instant::now(); + assert!(!join_with_timeout(handle, Duration::from_millis(200))); + assert!( + start.elapsed() < Duration::from_secs(5), + "join_with_timeout must return near the grace, not block" + ); + drop(tx); // let the abandoned thread exit before the test ends + } + + #[test] + fn a_drain_that_keeps_producing_output_is_allowed_past_the_idle_grace() { + // The point of the idle bound: a child that exits having buffered a lot + // of output has done nothing wrong, and a flat grace would truncate it. + // This thread keeps reporting progress well past the grace and must be + // joined in full. + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + let progress = Arc::new(AtomicU64::new(0)); + let worker_progress = Arc::clone(&progress); + let handle = std::thread::spawn(move || { + for _ in 0..10 { + std::thread::sleep(Duration::from_millis(60)); + worker_progress.fetch_add(4096, Ordering::Relaxed); + } + }); + + assert!( + join_while_draining( + handle, + &progress, + Duration::from_millis(100), + std::time::Instant::now() + Duration::from_secs(30), + ), + "a reader still forwarding bytes must be waited for, not abandoned" + ); + assert_eq!(progress.load(Ordering::Relaxed), 40960); + } + + #[test] + fn a_silent_drain_is_abandoned_after_the_idle_grace_not_at_the_deadline() { + // The mirror case, and the one the bound exists for: a process the child + // left behind holds the pty open, so the reader blocks and produces + // nothing. Waiting out the caller's remaining budget here would hand a + // leaked descendant the whole timeout the run never needed. + use std::sync::atomic::AtomicU64; + + let progress = AtomicU64::new(0); + let (tx, rx) = std::sync::mpsc::channel::<()>(); + let handle = std::thread::spawn(move || { + let _ = rx.recv(); + }); + + let start = std::time::Instant::now(); + assert!(!join_while_draining( + handle, + &progress, + Duration::from_millis(150), + std::time::Instant::now() + Duration::from_secs(30), + )); + assert!( + start.elapsed() < Duration::from_secs(5), + "a silent reader must be abandoned at the idle grace, not at the \ + far-off deadline; took {:?}", + start.elapsed() + ); + drop(tx); + } + + #[test] + fn even_a_steadily_progressing_drain_cannot_outlast_the_deadline() { + // Progress extends the wait, so on its own it would let a leak that + // drips a byte at a time hold the caller forever -- this test hangs + // outright without the backstop. The budget the caller authorized is + // what bounds it. + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + let progress = Arc::new(AtomicU64::new(0)); + let worker_progress = Arc::clone(&progress); + let (tx, rx) = std::sync::mpsc::channel::<()>(); + let handle = std::thread::spawn(move || { + while rx.try_recv().is_err() { + std::thread::sleep(Duration::from_millis(20)); + worker_progress.fetch_add(1, Ordering::Relaxed); + } + }); + + let start = std::time::Instant::now(); + assert!(!join_while_draining( + handle, + &progress, + Duration::from_millis(50), + std::time::Instant::now() + Duration::from_millis(300), + )); + assert!( + start.elapsed() < Duration::from_secs(5), + "the hard deadline must cap a drain that never stops dripping; \ + took {:?}", + start.elapsed() + ); + drop(tx); + } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn echo_runs_under_pty() { @@ -553,6 +822,36 @@ mod tests { assert!(matches!(outcome, PtyOutcome::TimedOut)); } + /// Regression for the exec-timeout hang: a timed-out command that left a + /// process running (here a backgrounded `sleep` sibling of the foreground + /// command) must not stall `run_with_pty`. The leaked process inherits the + /// pty secondary, so without the process-group kill + bounded drain the + /// output reader never sees EOF and the join blocks until the straggler + /// exits (~30s). With the fix the whole process group is killed and the + /// call returns right after the (sub-second) timeout. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn timeout_returns_promptly_despite_leaked_background_process() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut cmd = Command::new("/bin/sh"); + // Background one sleep, then become another via exec. `sleep 30` + // (not 300) bounds any worst-case leak if this ever regresses. + cmd.arg("-c").arg("sleep 30 & exec sleep 30"); + let opts = PtyOptions { + timeout: Some(Duration::from_millis(750)), + ..PtyOptions::default() + }; + let start = std::time::Instant::now(); + let outcome = run_with_pty(cmd, opts).expect("bridge spawns"); + let elapsed = start.elapsed(); + assert!(matches!(outcome, PtyOutcome::TimedOut)); + assert!( + elapsed < Duration::from_secs(20), + "exec must return promptly after timeout, took {elapsed:?} (leaked \ + process hung the drain?)" + ); + } + /// Documents the libc invariant motivating the FD_CLOEXEC fixup /// inside `run_with_pty`. If a future libc starts defaulting to /// `FD_CLOEXEC` on `openpty(3)`, this test skips with a message — diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 9b2cdb315..065f37c7b 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -1,2659 +1,2311 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -#[cfg(target_os = "windows")] -mod audit; -#[cfg(target_os = "windows")] -use std::fmt::Write; -use std::process; -use std::sync::{Mutex, OnceLock}; -use std::time::Instant; - -use appcontainer_common::appcontainer_runner::delete_app_container_profile; -use clap::Parser; -use wxc_common::audit::{AuditEvent, AuditEventName, RejectionReason}; -use wxc_common::config_parser::{LoadOptions, ParseError}; -#[cfg(target_os = "windows")] -use wxc_common::diagnostic::DiagnosticConfig; -use wxc_common::logger::{Logger, Mode}; -use wxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; -use wxc_common::mxc_error::{MxcError, MxcErrorCode, ResponseEnvelope}; -use wxc_common::script_runner::{handle_dry_run_exit, ScriptRunner}; -use wxc_common::state_aware_dispatch::{resolve_backend, DispatchOutcome}; -use wxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest}; -use wxc_common::telemetry; - -#[derive(Parser)] -#[command(name = "wxc-exec", about = "Windows Container Executor")] -struct Cli { - /// Path to config JSON file (positional) - #[arg(value_name = "CONFIG_PATH")] - config_path: Option, - - /// Path to config JSON file - #[arg(long = "config")] - config: Option, - - /// Base64-encoded JSON config - #[arg(long = "config-base64")] - config_base64: Option, - - /// Enable debug/console output - #[arg(long)] - debug: bool, - - /// Delete container profile mode - #[arg(long)] - delete: bool, - - /// Container name (required with --delete) - #[arg(long = "containername")] - containername: Option, - - /// Enable experimental features - #[arg(long)] - experimental: bool, - - /// Allow testing-only features that must never run in production, currently - /// `network.proxy.builtinTestServer` (a bundled, deliberately-permissive - /// test HTTP proxy). Distinct from --experimental. - #[arg(long = "allow-testing-features")] - allow_testing_features: bool, - - /// Parse and validate config then exit without executing - #[arg(long = "dry-run")] - dry_run: bool, - - /// Path to diagnostic log file (appends, creates if missing) - #[arg(long = "log-file")] - log_file: Option, - - /// Install the warmed Hyperlight snapshot and exit. Pulls the - /// published kernel + initrd from GHCR (via docker or podman), - /// warms them up, and writes the snapshot into the default user - /// data dir (~/.local/share/pyhl on Linux, %LOCALAPPDATA%\pyhl on - /// Windows). $PYHL_HOME overrides the destination if set. Intended - /// for tool install hooks so first-run has zero warmup cost. - #[arg(long = "setup-hyperlight")] - setup_hyperlight: bool, - - /// Rebuild the snapshot even if one already exists. Use after - /// upgrading `kernel` or `initrd.cpio` so the warm state matches - /// the new bits. Requires --setup-hyperlight. - #[arg(long, requires = "setup_hyperlight")] - force: bool, - - /// Pre-pull a WSLC container image into the local image cache and exit. - /// MXC is an execution layer and does not pull images at run time; this - /// flag (or `scripts/setup-wslc.ps1`) is how operators populate the cache. - /// Requires `--image` to specify which image to pull. - #[arg(long = "setup-wslc")] - setup_wslc: bool, - - /// Image reference to pre-pull (e.g. `alpine:latest`, - /// `ghcr.io/owner/image:tag`). Required with `--setup-wslc`. - #[arg(long = "image", requires = "setup_wslc")] - image: Option, - - /// Optional WSLC storage path. When omitted the runner default is used - /// (`%TEMP%\mxc-wslc-sessions`). Pass the same value here that your - /// runtime configs set in `experimental.wslc.storagePath`, otherwise - /// the runner will not find the pulled image. Requires `--setup-wslc`. - #[arg(long = "storage-path", requires = "setup_wslc")] - storage_path: Option, - - /// Run the fallback detector and emit JSON, without spawning a sandbox. - #[arg(long)] - probe: bool, - - /// Manage telemetry consent without spawning a sandbox. - #[arg( - long = "telemetry-consent", - value_name = "ACTION", - conflicts_with_all = [ - "config_path", - "config", - "config_base64", - "command", - "delete", - "containername", - "experimental", - "allow_testing_features", - "dry_run", - "setup_hyperlight", - "force", - "setup_wslc", - "image", - "storage_path", - "probe", - "force_reclaim" - ] - )] - #[cfg_attr( - target_os = "windows", - arg(conflicts_with_all = ["audit", "audit_verbose"]) - )] - telemetry_consent: Option, - - /// Preferred BCP 47 locale for a telemetry consent request. - #[arg(long = "telemetry-consent-locale", requires = "telemetry_consent")] - telemetry_consent_locale: Option, - - /// Windows Sandbox: tear down a running WSB VM that mxc cannot prove it - /// launched, instead of refusing — clears a host wedged by an orphan left - /// after a launcher hard-kill. DANGER: proofless, so it may also kill a - /// foreign sandbox. Never overrides an active mxc run or an unreadable probe. - #[arg(long = "force-reclaim")] - force_reclaim: bool, - - /// Audit mode: run captureDenials in permissive allow mode with ETL - /// retention, then generate developer policy-authoring artifacts. Windows - /// ProcessContainer only. - #[cfg(target_os = "windows")] - #[arg(long)] - audit: bool, - - /// Print learned-policy post-processing details for `--audit`. - #[cfg(target_os = "windows")] - #[arg(long)] - audit_verbose: bool, - - /// Command to run inside the container, overriding `process.commandLine` - /// from the policy. The command must follow a `--` separator so normal - /// CLI flags remain usable after the config path. Examples: - /// wxc-exec policy.json -- python --version - /// When provided, `process.commandLine` in the policy file becomes - /// optional and is overridden. - #[arg(value_name = "COMMAND", last = true, allow_hyphen_values = true)] - command: Vec, -} - -impl Cli { - fn normalize_named_config_command(mut self) -> Self { - if self.config.is_some() || self.config_base64.is_some() { - if let Some(first) = self.config_path.take() { - self.command.insert(0, first); - } - } - self - } -} - -fn parse_cli() -> Cli { - match Cli::try_parse() { - Ok(cli) => cli, - Err(error) => { - if matches!( - error.kind(), - clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion - ) { - error.exit(); - } - let is_consent_command = - telemetry::consent_cli::invocation_uses_consent_options(std::env::args_os()); - if is_consent_command { - let _ = error.print(); - process::exit(64); - } - error.exit(); - } - } -} - -fn log_request(request: &ExecutionRequest, logger: &mut Logger) { - if !request.container_id.is_empty() { - let _ = writeln!(logger, "Container ID: {}", request.container_id); - } - let _ = writeln!(logger, "Script code length: {}", request.script_code.len()); - let _ = writeln!(logger, "Working directory: {}", request.working_directory); - let _ = writeln!(logger, "Script timeout: {}", request.script_timeout); - let _ = writeln!( - logger, - "Container name: {}", - if request.container_id.is_empty() { - "CLI" - } else { - &request.container_id - } - ); -} - -fn display_script_results(response: &ScriptResponse, logger: &mut Logger) { - let code = response.exit_code; - let _ = writeln!(logger, "Exit code: {} (0x{:08X})", code, code as u32); - if !response.error_message.is_empty() { - let _ = writeln!(logger, "Error: {}", response.error_message); - } -} - -fn apply_permissive_learning_mode(capabilities: &mut Vec) -> bool { - capabilities.retain(|capability| !capability.eq_ignore_ascii_case("learningModeLogging")); - if capabilities - .iter() - .any(|capability| capability.eq_ignore_ascii_case("permissiveLearningMode")) - { - false - } else { - capabilities.push("permissiveLearningMode".to_string()); - true - } -} - -const AUDIT_CAPTURE_DENIALS_CONFLICT_MSG: &str = - "--audit cannot be combined with processContainer.captureDenials; use captureDenials.mode: \"allow\" for permissive capture"; - -fn validate_audit_request(request: &ExecutionRequest) -> Result<(), String> { - if request.containment != ContainmentBackend::ProcessContainer { - return Err(format!( - "--audit is only supported with the Windows ProcessContainer backend; \ - resolved containment is '{}'", - request.containment.wire_name() - )); - } - if request.policy.capture_denials.is_some() { - return Err(AUDIT_CAPTURE_DENIALS_CONFLICT_MSG.to_string()); - } - Ok(()) -} - -/// Read the request source (file path / base64 blob) once, returning the -/// decoded JSON. Reused by `--probe` and the normal request loader so a single -/// source is only read once per invocation. -fn decode_config_input_once(cli: &Cli) -> Option> { - let (input, is_base64) = config_input(cli)?; - Some(wxc_common::config_parser::decode_request_input( - &input, is_base64, - )) -} - -/// On a state-aware dispatch failure, record the error only on the auxiliary -/// diagnostic sinks (`--log-file` and the diagnostic pipe) via -/// [`Logger::log_diagnostic_line`]. It is deliberately kept out of the primary -/// console/buffer output so it never interleaves with the stdout response -/// envelope; the failure reason still reaches the client through the JSON error -/// envelope on stdout. -fn log_state_aware_dispatch_error(logger: &mut Logger, error: &MxcError) { - logger.log_diagnostic_line(&error.to_string()); -} - -/// Record `mxc.ConfigRejected` for a request that was refused before it could -/// run. -/// -/// **Bounded content only.** The `reason` is a closed [`RejectionReason`] -/// variant and `offending_field` is a *config field path* (e.g. -/// `process.commandLine`) — never a field value, never the rich error text. -/// The human-readable diagnostic still reaches the operator on stderr and, for -/// state-aware phases, in the JSON error envelope; this record exists so the -/// same rejection is machine-readable without parsing prose. -/// -/// Unlike the ETW path this replaces, there is no initialisation-ordering -/// problem: `Logger` is constructed from CLI flags before any config is read, -/// so *every* rejection site — including "the input was not JSON at all" — can -/// reach it. -fn log_config_rejected( - logger: &mut Logger, - reason: RejectionReason, - backend: &str, - offending_field: &str, - phase: &str, -) { - let correlation_id = wxc_common::audit::process_correlation_id(); - wxc_common::telemetry::log_config_rejected( - correlation_id, - backend, - reason.as_str(), - offending_field, - phase, - ); - let record = AuditEvent::new(AuditEventName::ConfigRejected) - // No sandbox exists yet — the config was refused — so M-ETW-7's - // "correlation id (or identity if assigned)" resolves to the - // correlation id here. It groups every rejection from one invocation - // on a sink that concurrent sandboxes share. - .str("correlation_id", correlation_id) - .str("backend", backend) - .str("reason", reason.as_str()) - .str_opt("offending_field", offending_field) - .str_opt("phase", phase); - logger.log_audit_event(&record); -} - -/// Extract the bounded field path already rendered by the config parser. -/// -/// Parse failures intentionally keep their rich human-readable message for -/// stderr or the state-aware response envelope. The audit event only needs the -/// path between the parser's backticks, never the surrounding error text. -fn offending_field_from_message(message: &str) -> &str { - const PREFIX: &str = "Invalid configuration at `"; - let Some(start) = message.find(PREFIX) else { - return ""; - }; - let field = &message[start + PREFIX.len()..]; - field.split('`').next().unwrap_or("") -} - -/// Backend name for a rejection that happened before (or without) backend -/// resolution. -const UNKNOWN_BACKEND: &str = "unknown"; - -/// Map a state-aware [`MxcError`] to its bounded [`RejectionReason`]. -/// -/// Driven by the error's own `code`, which is already an exhaustive closed set — -/// no message-text matching. -fn config_rejection_reason_for(error: &MxcError) -> Option { - match error.code { - MxcErrorCode::MalformedRequest => Some(RejectionReason::SchemaViolation), - MxcErrorCode::MalformedId => Some(RejectionReason::IdentityShapeInvalid), - MxcErrorCode::PolicyValidation => Some(RejectionReason::UnsupportedFieldForBackend), - MxcErrorCode::UnsupportedContainment => Some(RejectionReason::UnsupportedContainment), - MxcErrorCode::UnsupportedPhase => Some(RejectionReason::UnsupportedPhase), - MxcErrorCode::BackendUnavailable - | MxcErrorCode::StaleId - | MxcErrorCode::NotProvisioned - | MxcErrorCode::NotStarted - | MxcErrorCode::AlreadyStarted - | MxcErrorCode::AlreadyStopped - | MxcErrorCode::BackendError => None, - } -} - -fn rejection_reason_for(error: &MxcError) -> RejectionReason { - config_rejection_reason_for(error).unwrap_or(RejectionReason::RunnerUnavailable) -} - -/// Marker logged for the `MXC.PolicyHash` identity field when the caller -/// supplied a `sandboxId` for this phase. This call happens before the -/// dispatcher validates the id against real backend state (see the -/// `install_thread_diagnostic_sink` comment below), so a caller-chosen string -/// that merely *looks* like an MXC-minted `iso:`/`wsb:` id would otherwise -/// pass `redact_identity`'s shape check unverified. Since MXC has not -/// confirmed the id yet, the field records only that a caller-provided id was -/// present, not its value; the later, post-dispatch `SandboxIdentity` audit -/// record (see `sandbox_id_for_identity_record` below) is unaffected and -/// still discloses the real, backend-verified identity on success. -const UNVERIFIED_SANDBOX_ID_MARKER: &str = "unverified"; - -fn state_aware_policy_identity(sandbox_id: Option<&str>) -> String { - match sandbox_id.filter(|id| !id.is_empty()) { - None => wxc_common::policy_identity::redact_identity("CLI"), - Some(_) => UNVERIFIED_SANDBOX_ID_MARKER.to_string(), - } -} - -/// Resolve the sandbox id to report on `mxc.SandboxIdentity` for a completed -/// state-aware dispatch. -/// -/// `provision` mints the id, so it is read out of the result envelope; every -/// later phase carries the id inbound. Returns `None` when the phase failed — -/// a failed dispatch produced no sandbox to identify, and emitting an identity -/// record for one would be a lie. -fn sandbox_id_for_identity_record( - outcome: &Result, - incoming_sandbox_id: Option<&str>, -) -> Option { - let Ok(outcome) = outcome else { - return None; - }; - if let DispatchOutcome::Envelope(value) = outcome { - if let Some(minted) = value - .get("result") - .and_then(|r| r.get("sandboxId")) - .and_then(|v| v.as_str()) - { - return Some(minted.to_string()); - } - } - incoming_sandbox_id.map(str::to_string) -} - -/// Drives the state-aware dispatch flow. On envelope success, writes the -/// JSON to stdout and exits 0. On exec success, exits with the script's -/// exit code (output already streamed). On failure, writes a JSON error -/// envelope to stdout and exits 1. Diagnostic logger output goes to stderr -/// regardless of mode (per design §7.3 stream protocol — stdout reserved -/// for the response envelope). -fn run_state_aware_main( - parsed: ParsedStateAwareRequest, - dry_run: bool, - telemetry_active: bool, - logger: &mut Logger, -) -> ! { - // Resolve attribution (phase + backend) and telemetry enablement BEFORE - // dispatch consumes `parsed`. Telemetry is stable and independent of the - // `--experimental` gate used by experimental containment backends. - let phase = parsed.phase.as_str(); - // Whether this invocation is the provision phase: its `sandbox_id` doesn't - // exist yet, so it always seeds a fresh base rather than deriving one. - let is_provision = phase == "provision"; - // `sandbox_id` for non-provision phases, from which the lifecycle's shared - // correlation base is derived internally. Captured before `dispatch` - // consumes `parsed`. `None` for provision. - let sandbox_id = if is_provision { - None - } else { - parsed.sandbox_id.clone() - }; - let resolved_backend = resolve_backend(&parsed).ok(); - let backend = resolved_backend - .as_ref() - .map(|b| b.wire_name()) - .unwrap_or("unknown"); - let requested_sandbox_kind = parsed - .request - .telemetry - .as_ref() - .and_then(|config| config.requested_sandbox_kind); - - // This phase's Microsoft Correlation Vector (MS-CV), purely internal to - // MXC — no caller supplies or relays one. `provision` seeds a fresh - // vector (persisted post-dispatch below, once its `sandbox_id` exists); - // every later phase recalls that persisted lifecycle root and spins a - // child off it. Empty when telemetry is inactive. - let correlation = telemetry::correlation_state::pre_dispatch_vector( - telemetry_active, - is_provision, - sandbox_id.as_deref(), - ); - - // Attribute out-of-band emit paths (the console-control handler installed in - // `main`, and the panic hook installed just below) to the resolved backend - // and the lifecycle phase, and install a crash-telemetry panic hook for this - // dispatch — mirroring the one-shot path, which this `-> !` entry point - // bypasses. The shared hook chains the previous hook (default stderr - // backtrace still prints) and is panic-free. - if telemetry_active { - if let Some(containment) = resolved_backend.as_ref() { - telemetry::set_process_context_with_kind(containment, requested_sandbox_kind); - } - telemetry::set_process_phase(phase); - // Stash this phase's correlation vector so out-of-band events - // (panic / cancellation) carry the same cV as the terminal emit below. - telemetry::set_process_correlation_vector(&correlation); - telemetry::install_panic_hook(); - } - - let started = Instant::now(); - // State-aware dispatch bypasses the one-shot runner funnel, so anchor the - // effective lifecycle policy here before the request is consumed. - let phase_config = parsed.experimental_raw.as_ref().and_then(|raw| { - resolved_backend.as_ref().and_then(|backend| { - raw.get(backend.wire_name()) - .and_then(|section| section.get(phase)) - }) - }); - let diagnostics_active = logger.has_diagnostic_sink(); - if telemetry_active || diagnostics_active { - let policy_hash = wxc_common::policy_identity::state_aware_policy_hash( - &parsed.request, - backend, - phase, - phase_config, - ); - let identity = state_aware_policy_identity(parsed.sandbox_id.as_deref()); - wxc_common::telemetry::log_policy_hash( - &identity, - &policy_hash, - &parsed.request.schema_version, - ); - if diagnostics_active { - let record = AuditEvent::new(AuditEventName::PolicyHash) - .str("backend", backend) - .str("policy_hash", &policy_hash) - .str("config_schema_version", &parsed.request.schema_version); - logger.log_audit_event(&record); - } - } - // Publish the driver's diagnostic sinks (--log-file, and the diagnostic - // console pipe on Windows) on this thread so a backend whose - // `StatefulSandboxBackend::exec` signature has no `Logger` parameter - // can inherit them via `Logger::inherit_thread_diagnostic_sink` instead - // of building a throwaway `Logger::new(Mode::Buffer)` and silently - // dropping every record. The returned guard clears the sink when it - // drops at the end of this scope -- including if `run_state_aware` - // panics -- so we never leak duplicated handles across independent - // invocations even on an unwind. - let _diag_sink_guard = logger.install_thread_diagnostic_sink(); - let outcome = mxc_engine::run_state_aware(parsed, dry_run); - drop(_diag_sink_guard); - let elapsed = started.elapsed(); - - // Persist (provision) or forget (deprovision) this lifecycle's - // correlation root now that the outcome — and, for provision, the - // freshly minted `sandbox_id` — is known. - if is_provision { - telemetry::correlation_state::on_provision_outcome( - telemetry_active, - &correlation, - &outcome, - ); - } else if phase == "deprovision" { - if let Some(id) = sandbox_id.as_deref() { - telemetry::correlation_state::on_deprovision_outcome( - telemetry_active, - id, - dry_run, - &outcome, - ); - } - } - - // Record the sandbox identity join key. For `isolation_session` the - // `sandboxId` tail is the OS-side `provisionId`, which is what joins an MXC - // record to the `Microsoft.Windows.IsolationSession` OS records. Emitted on - // success only: a failed phase produced no sandbox to identify. - if let Some(sandbox_id) = sandbox_id_for_identity_record(&outcome, sandbox_id.as_deref()) { - let record = AuditEvent::new(AuditEventName::SandboxIdentity) - .str("backend", backend) - .str( - "identity", - &wxc_common::policy_identity::redact_identity(&sandbox_id), - ) - .str_opt("phase", phase); - logger.log_audit_event(&record); - } - - // Emit lifecycle telemetry (and shut the provider down) before flushing the - // diagnostic buffer / envelope. Terminal path — safe to shutdown here. - if let Err(error) = &outcome { - if let Some(reason) = config_rejection_reason_for(error) { - let message = error.message.as_str(); - log_config_rejected( - logger, - reason, - backend, - offending_field_from_message(message), - phase, - ); - } - } - - telemetry::emit_state_aware_with_kind( - telemetry_active, - requested_sandbox_kind, - telemetry::TelemetryContext { - backend, - phase, - correlation_vector: &correlation, - }, - &outcome, - elapsed, - ); - - // On dispatch failure, route the error to the auxiliary diagnostic sinks - // only (log file / diagnostic pipe) — never the primary buffer/stderr — so - // the stdout error envelope written below stays the single client-facing - // channel and is not shadowed by a duplicate on stderr. - if let Err(error) = &outcome { - log_state_aware_dispatch_error(logger, error); - } - for warning in logger.take_warnings() { - eprintln!("{warning}"); - } - // Diagnostic buffer flushes to stderr regardless of success/failure so it - // never interleaves with the stdout envelope. - let buffered = logger.get_buffer().to_string(); - if !buffered.is_empty() { - eprint!("{}", buffered); - } - match finalize_state_aware_outcome(outcome) { - StateAwareExit::Envelope(json) => { - println!("{}", json); - process::exit(0); - } - StateAwareExit::ExecCode(exit_code) => process::exit(exit_code), - StateAwareExit::Error(json) => { - println!("{}", json); - process::exit(1); - } - } -} - -/// The terminal action [`run_state_aware_main`] takes for a dispatch outcome, -/// factored out of that `-> !` entry point so the outcome→exit mapping is -/// unit-testable without spawning a process or calling [`process::exit`]. -#[cfg_attr(test, derive(Debug))] -enum StateAwareExit { - /// Print this JSON success envelope to stdout, then exit 0. - Envelope(String), - /// Exit with this script exit code (its output was already streamed). - ExecCode(i32), - /// Print this JSON error envelope to stdout, then exit 1. - Error(String), -} - -/// Map a dispatch outcome to its terminal exit action. Pure — it neither prints -/// nor exits — so [`run_state_aware_main`]'s final branch can be exercised in a -/// unit test. The `-> !` caller performs the actual stdout write + exit. -fn finalize_state_aware_outcome(outcome: Result) -> StateAwareExit { - match outcome { - Ok(DispatchOutcome::Envelope(value)) => StateAwareExit::Envelope(value.to_string()), - Ok(DispatchOutcome::ExecCompleted { exit_code }) => StateAwareExit::ExecCode(exit_code), - Err(e) => StateAwareExit::Error(error_envelope_string(&e)), - } -} - -enum RequestErrorRoute<'a> { - Diagnostic, - Envelope(&'a MxcError), -} - -fn request_error_route(error: &ParseError) -> RequestErrorRoute<'_> { - match error { - ParseError::Decode(_) | ParseError::OneShot(_) | ParseError::OneShotMalformed(_) => { - RequestErrorRoute::Diagnostic - } - ParseError::StateAware(e) => RequestErrorRoute::Envelope(e), - } -} - -fn print_error_envelope(error: &MxcError) { - println!("{}", error_envelope_string(error)); -} - -/// Serialise `error` to its JSON response-envelope string. Split out of -/// [`print_error_envelope`] so [`finalize_state_aware_outcome`] can build the -/// error-exit envelope without printing, and so both the print path and the -/// state-aware exit path share one serialisation (including the last-resort -/// fallback when the envelope itself fails to serialise). -fn error_envelope_string(error: &MxcError) -> String { - let envelope: ResponseEnvelope<()> = ResponseEnvelope::from_error(error); - serde_json::to_string(&envelope).unwrap_or_else(|_| { - // Last-resort path: serialisation of the envelope itself failed — emit a - // minimal structurally-valid envelope so consumers that require - // `error.code` still parse something. - r#"{"error":{"code":"backend_error","message":"failed to serialise error envelope"}}"# - .to_string() - }) -} - -fn config_input(cli: &Cli) -> Option<(String, bool)> { - if let Some(b64) = cli.config_base64.as_ref() { - Some((b64.clone(), true)) - } else if let Some(p) = cli.config.as_ref() { - Some((p.clone(), false)) - } else { - cli.config_path.as_ref().map(|p| (p.clone(), false)) - } -} - -/// Resolve the on-disk config path passed to `wxc-exec`, if any. Returns -/// `None` when the config was supplied as `--config-base64` (no file path) -/// or not at all. Used by `--audit` to thread `plm stop --config-path` so -/// findings can be merged back into the source policy. -#[cfg(target_os = "windows")] -fn config_file_path(cli: &Cli) -> Option { - if cli.config_base64.is_some() { - return None; - } - cli.config - .as_ref() - .or(cli.config_path.as_ref()) - .map(std::path::PathBuf::from) -} - -// --------------------------------------------------------------------------- -// Graceful-exit DACL cleanup -// --------------------------------------------------------------------------- -// -// `DaclManager`'s `Drop` is the only thing that restores host ACEs we -// applied during a Tier 2 / Tier 3 run. We need that `Drop` to fire on -// every code path that can leave main, including the abnormal ones. -// There are three: -// -// 1. **Normal exit / `process::exit`** — destructors of stack-owned -// values run on the former and are SKIPPED on the latter. We deal -// with this by explicitly `drop(take_parked_dacl())`-ing before any -// `process::exit` site below. -// 2. **Panic unwind** — destructors of stack-owned values run; the -// release profile uses the default `unwind` strategy (see -// `src/Cargo.toml`). But the `DaclManager` we extract from -// `Dispatched` lives inside a process-global static (so the Ctrl-C -// handler can reach it), and statics are NOT touched by unwinding. -// To restore on panic we install a stack-owned `ParkedDaclGuard` in -// `main` whose `Drop` calls `take_parked_dacl()` and drops the -// manager. The guard sits at function scope so the unwind path -// threads through it. -// 3. **Ctrl-C / Ctrl-Break / console close / logoff / shutdown** — the -// default Windows handler calls `ExitProcess` directly, skipping -// every Rust destructor. We install a `SetConsoleCtrlHandler` that -// takes-and-drops the parked manager before yielding to the -// default handler. -// -// The mutex in the slot serializes the Ctrl-C handler and the guard -// against each other, so the manager is taken (and therefore -// `Drop`'d) at most once. -// -// Parent-process kill (`TerminateProcess`) still bypasses every -// handler; any leak there is reaped by `recover_orphaned_state` on -// the next wxc-exec startup (which we already run at the top of -// `main`). - -#[cfg(target_os = "windows")] -static DACL_CLEANUP_SLOT: OnceLock>> = - OnceLock::new(); - -#[cfg(target_os = "windows")] -fn dacl_cleanup_slot() -> &'static Mutex> { - DACL_CLEANUP_SLOT.get_or_init(|| Mutex::new(None)) -} - -/// Park the DACL manager in the global slot so the Ctrl-C handler can -/// drop it if a signal arrives before the normal-exit path runs. -#[cfg(target_os = "windows")] -fn park_dacl_for_cleanup(mgr: wxc_common::filesystem_dacl::DaclManager) { - let slot = dacl_cleanup_slot(); - let mut guard = slot.lock().unwrap_or_else(|p| p.into_inner()); - *guard = Some(mgr); -} - -/// Take the parked DACL manager (if any) so the caller can drop it, -/// triggering ACE restore. Returns `None` if either nothing was parked -/// or another path (the Ctrl-C handler) already took it. -/// -/// Recovers from `PoisonError` the same way [`park_dacl_for_cleanup`] -/// does (`into_inner`): a poisoned mutex must NOT silently swallow a -/// parked manager — that would leak ACEs until the next-startup -/// recovery scan reaps them. -#[cfg(target_os = "windows")] -fn take_parked_dacl() -> Option { - DACL_CLEANUP_SLOT.get().and_then(|slot| { - let mut guard = slot.lock().unwrap_or_else(|p| p.into_inner()); - guard.take() - }) -} - -/// Stack-owned witness that ensures `take_parked_dacl()` runs on every -/// path out of `main`, including panic unwind. The parked -/// `DaclManager` lives in a process-global static (so the Ctrl-C -/// handler can reach it), and Rust's unwinder doesn't touch statics — -/// without this guard, a panic between `park_dacl_for_cleanup` and -/// the explicit `drop(take_parked_dacl())` near the end of `main` -/// would leave host ACEs in place until the next startup's recovery -/// scan. -/// -/// `Drop` is a no-op if nothing was ever parked or if the Ctrl-C -/// handler already drained the slot. -#[cfg(target_os = "windows")] -struct ParkedDaclGuard; - -#[cfg(target_os = "windows")] -impl Drop for ParkedDaclGuard { - fn drop(&mut self) { - drop(take_parked_dacl()); - } -} - -/// Windows console-control handler. Called by the OS on Ctrl-C, Ctrl-Break, -/// console close, logoff, and shutdown. Takes the parked DACL manager and -/// drops it — `Drop` runs `restore()` which removes the ACEs we applied. -/// Returns `FALSE` so the default handler still runs (which terminates -/// the process). -/// -/// Acquires the slot with a bounded wait -/// (`plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT`), not `try_lock`. -/// If the main thread is mid-`Drop` on the same manager — which can be -/// doing a `SetNamedSecurityInfoW` — returning FALSE immediately lets -/// the default handler call `ExitProcess`, terminating that drop mid- -/// Win32 and leaving the host DACL in an inconsistent state. The -/// bounded wait blocks the default handler until either main finishes -/// (lock released) or the shared timeout elapses — whichever comes -/// first. On timeout we proceed anyway; the recovery scan on the next -/// `wxc-exec` startup reaps anything left behind. -#[cfg(target_os = "windows")] -unsafe extern "system" fn dacl_ctrl_handler(_ctrl_type: u32) -> windows::core::BOOL { - // Ordering guarantee: best-effort cancellation telemetry runs STRICTLY - // BEFORE the up-to-5s DACL cleanup loop, which can consume the OS - // shutdown-handler budget. Factored through `cancel_then_cleanup` so a - // future edit that reorders these two would fail a unit test rather than - // silently drop cancellation telemetry when the cleanup times out. - cancel_then_cleanup( - // Emit first. No-op unless telemetry is active; emits no message text - // and does not shut the provider down (the OS reclaims it at exit). - telemetry::emit_cancellation, - // Then the security-critical host DACL cleanup. - || { - if let Some(slot) = DACL_CLEANUP_SLOT.get() { - use std::time::{Duration, Instant}; - // CTRL_CLOSE_EVENT / CTRL_LOGOFF / CTRL_SHUTDOWN have a hard - // ~5s OS-imposed kill budget. The persistent elevated PLM - // child handles trace cleanup after this owner terminates. - let deadline = Instant::now() + plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT; - loop { - if let Ok(mut guard) = slot.try_lock() { - // Either main already took the manager (guard is None) - // or it never parked one; dropping `Option::take` is - // a no-op in both cases. Either way, the contract — no - // restore thread running concurrently with the default - // handler's ExitProcess — is satisfied. - drop(guard.take()); - break; - } - if Instant::now() >= deadline { - break; - } - std::thread::sleep(Duration::from_millis(50)); - } - } - }, - ); - // FALSE = "I did not fully handle this; run the next handler in the - // chain (i.e. the default handler that calls ExitProcess)". - windows::core::BOOL(0) -} - -/// Runs the cancellation `emit` strictly before the (possibly slow, -/// security-critical) `cleanup`, and guarantees `cleanup` runs even if `emit` -/// panics. Factored out of [`dacl_ctrl_handler`] so both invariants are lockable -/// by a unit test: -/// -/// 1. **Ordering** — `emit` runs before `cleanup`. The cleanup can burn the OS -/// shutdown-handler budget and never return, so emitting first ensures the -/// cancellation telemetry is not lost. -/// 2. **Cleanup-always** — `emit` is wrapped in [`catch_unwind`] so a panic in -/// telemetry can never skip the DACL restore (which would leave host ACEs in -/// place) and can never unwind across the `extern "system"` `dacl_ctrl_handler` -/// boundary (which is undefined behaviour). -/// -/// A caught panic is intentionally swallowed: this runs on the OS -/// shutdown-handler path where there is nothing left to propagate it to, and the -/// security-critical `cleanup` is what must not be skipped. -#[cfg(target_os = "windows")] -fn cancel_then_cleanup(emit: impl FnOnce(), cleanup: impl FnOnce()) { - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(emit)); - cleanup(); -} - -/// Install the console-control handler. Idempotent — calling twice -/// registers the same handler twice, which is harmless because the -/// take-and-drop is `Option::take`-based. -#[cfg(target_os = "windows")] -fn install_dacl_ctrl_handler() { - use windows::Win32::System::Console::SetConsoleCtrlHandler; - // SAFETY: `dacl_ctrl_handler` has the correct ABI; the `Add=TRUE` - // call merely appends to the OS handler chain. - let _ = unsafe { SetConsoleCtrlHandler(Some(dacl_ctrl_handler), true) }; -} - -fn main() { - let cli = parse_cli().normalize_named_config_command(); - - if let Some(action) = cli.telemetry_consent { - let outcome = telemetry::consent_cli::handle_consent_command( - action, - cli.telemetry_consent_locale.as_deref(), - ); - process::exit(outcome.emit()); - } - // Decode the request source (file path / base64) once, up front. - let decoded_config: Option> = - decode_config_input_once(&cli); - - // Propagate --force-reclaim via the environment so it reaches both the - // in-process one-shot reconcile and the detached daemon. Set before any - // backend dispatch or daemon spawn. - if cli.force_reclaim { - std::env::set_var("WXC_WSB_FORCE_RECLAIM", "1"); - } - - // Best-effort: reap any orphaned DACL state files left behind by - // crashed prior MXC runs. Runs BEFORE the `--probe` arm because - // `wxc-exec --probe` is the canonical recovery trigger consumers - // (WinProcessContainer-Tests Phase 6, SDK warm-start) rely on. Errors here - // are non-fatal and only surface via stderr. On a healthy host - // with zero state files this is sub-millisecond. - match wxc_common::filesystem_dacl::recover_orphaned_state() { - Ok(report) => { - if report.files_processed > 0 || !report.errors.is_empty() { - eprintln!( - "DACL recovery: {} file(s), {} ACE(s) restored, {} pruned (missing), {} error(s)", - report.files_processed, - report.aces_restored, - report.aces_pruned_missing, - report.errors.len() - ); - for e in &report.errors { - eprintln!(" {e}"); - } - } - } - Err(e) => eprintln!("DACL recovery failed: {e}"), - } - - // --probe is a detection-only fast path used by SDK - // `getPlatformSupport()` on every first call. It does not spawn a - // sandbox and never parks a DaclManager. - // - // It does activate WinRT: `isolation_session_available()` resolves the - // IsolationSession activation factory. That probe owns and releases its - // own COM apartment, so it neither needs nor disturbs the process-wide - // init below — this ordering is not a claim that the probe is COM-free. - // - // Run it AFTER recovery (so consumers that rely on `--probe`-as- - // reaper still get it) but BEFORE COM init / SetConsoleCtrlHandler - // (which probe doesn't need; deferring them shaves cold-start cost - // off the SDK warm path). - if cli.probe { - let policy = if let Some(decoded) = decoded_config.as_ref() { - // Parse using the existing pipeline but route logger output to - // an in-memory buffer that we discard — the probe must not - // emit anything other than its JSON line on stdout. - let mut probe_logger = Logger::new(Mode::Buffer); - match decoded { - Ok(json) => { - match wxc_common::config_parser::load_request_from_json(json, &mut probe_logger) - { - Ok(r) => r.policy, - Err(_) => { - eprintln!("Error: failed to load probe config"); - eprint!("{}", probe_logger.get_buffer()); - process::exit(1); - } - } - } - Err(_) => { - eprintln!("Error: failed to load probe config"); - process::exit(1); - } - } - } else { - wxc_common::models::ContainerPolicy::default() - }; - let output = appcontainer_common::probe::run_probe(&policy); - // appcontainer_common has no dependency on the isolation-session - // backend, so it reports `isolationSessionAvailable` as `false`. When - // the backend is compiled in, override it with a read-only activation - // probe of the in-proc service. - #[cfg(all(target_os = "windows", feature = "isolation_session"))] - let output = { - let mut output = output; - output.probes.isolation_session_available = mxc_engine::isolation_session_available(); - output - }; - // WHP is delay-loaded; check before pyhl::install warms a VM. - #[cfg(all(target_os = "windows", feature = "hyperlight", target_arch = "x86_64"))] - let output = { - let mut output = output; - output.probes.hyperlight_available = hyperlight_common::is_whp_available(); - output - }; - match appcontainer_common::probe::to_json_pretty(&output) { - Ok(s) => println!("{s}"), - Err(e) => { - eprintln!("Error: probe serialization failed: {e}"); - process::exit(1); - } - } - return; - } - - // Initialize COM/WinRT for backends that use WinRT APIs (Isolation Session). - // COINIT_MULTITHREADED is benign for backends that don't use COM. - // - // SAFETY: `CoInitializeEx` is sound to call once at process start before - // any WinRT or COM activation. `pvReserved` must be `None` per the API - // contract. The return value is intentionally ignored — repeat-init - // outcomes (`S_FALSE`, `RPC_E_CHANGED_MODE`) are benign here. - let _ = unsafe { - windows::Win32::System::Com::CoInitializeEx( - None, - windows::Win32::System::Com::COINIT_MULTITHREADED, - ) - }; - - // Install the Ctrl-C / Ctrl-Break handler that drops any parked - // DaclManager on signal. Cheap and idempotent. Windows-only: the parked - // DACL manager it drains lives behind `#[cfg(target_os = "windows")]`. - #[cfg(target_os = "windows")] - install_dacl_ctrl_handler(); - - // Stack-owned witness so a panic anywhere below — between - // `park_dacl_for_cleanup` and the explicit `drop(take_parked_dacl())` - // near the end of `main` — still drains the slot and runs - // `restore()` during unwind. Without it the manager is parked in - // a static and unwinding skips destructors of static-owned values. - #[cfg(target_os = "windows")] - let _dacl_guard = ParkedDaclGuard; - - // --setup-hyperlight: warm up the snapshot and exit. Runs before - // config parsing so the user doesn't need a JSON file on disk - // just to install. - if cli.setup_hyperlight { - #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] - { - // WHP is delay-loaded; check before pyhl::install warms a VM. - #[cfg(target_os = "windows")] - if !hyperlight_common::is_whp_available() { - eprintln!( - "Error: --setup-hyperlight requires Windows Hypervisor Platform (WHP). \ - Enable the HypervisorPlatform optional feature and reboot." - ); - process::exit(1); - } - - let mut logger = Logger::new(if cli.debug { - Mode::Console - } else { - Mode::Buffer - }); - match hyperlight_common::setup(cli.force, &mut logger) { - Ok(snap) => { - eprintln!("hyperlight setup: snapshot ready at {:?}", snap); - process::exit(0); - } - Err(msg) => { - eprintln!("hyperlight setup failed: {msg}"); - process::exit(1); - } - } - } - #[cfg(not(all(feature = "hyperlight", target_arch = "x86_64")))] - { - eprintln!("Error: --setup-hyperlight requires x86_64 (Hyperlight needs KVM or WHP)"); - process::exit(1); - } - } - - // --setup-wslc: pre-pull a WSLC image into the local cache and exit. - // Runs before config parsing so the user doesn't need a JSON file just - // to populate images. Clap enforces that `--image` is present. - if cli.setup_wslc { - #[cfg(feature = "wslc")] - { - let image = match cli.image.as_deref() { - Some(s) if !s.is_empty() => s, - _ => { - eprintln!("Error: --setup-wslc requires --image "); - process::exit(1); - } - }; - let mut logger = Logger::new(if cli.debug { - Mode::Console - } else { - Mode::Buffer - }); - // SAFETY: setup_pull_image is the canonical SDK-loading entry - // point for this subcommand and is called exactly once before - // process exit. It owns the COM init contract documented on - // `init_and_load_sdk`. - let result = unsafe { - wslc_common::wsl_container_runner::WSLContainerRunner::setup_pull_image( - image, - cli.storage_path.as_deref(), - &mut logger, - ) - }; - // Flush logger buffer to stderr regardless of outcome so the - // user can see what happened. - let buf = logger.get_buffer().to_string(); - if !buf.is_empty() { - eprint!("{}", buf); - } - match result { - Ok(()) => process::exit(0), - Err(msg) => { - eprintln!("wslc setup failed: {msg}"); - process::exit(1); - } - } - } - #[cfg(not(feature = "wslc"))] - { - eprintln!("Error: WSLC backend not compiled. Rebuild with --features wslc."); - process::exit(1); - } - } - - // --probe is handled at the top of `main` (before COM init) for - // SDK first-call latency. See note there. - - // Determine config input. In delete mode the config is optional; every - // other path requires it. `decoded_config` above already read the source - // once — if it's populated, unpack the decoded JSON (or surface the - // decode error). If it's absent, either accept the empty state for - // delete mode or report the missing-config error. - let config_json: Option = match decoded_config { - Some(Ok(json)) => Some(json), - Some(Err(error)) => { - eprintln!("Request error"); - eprintln!("{error}"); - process::exit(1); - } - None => { - if !cli.delete { - eprintln!( - "Error: No config provided. Use a positional path, --config, or --config-base64" - ); - process::exit(1); - } - None - } - }; - - let mut logger = Logger::new(if cli.debug { - Mode::Console - } else { - Mode::Buffer - }); - - if let Some(ref log_path) = cli.log_file { - if let Err(e) = logger.enable_file_sink(std::path::Path::new(log_path)) { - eprintln!("Warning: could not open log file '{}': {}", log_path, e); - } - } - - // Initialize the diagnostic console before parsing so early rejection - // records have an active sink. - #[cfg(target_os = "windows")] - let diag_config = DiagnosticConfig::from_environment(); - #[cfg(target_os = "windows")] - if diag_config.console_enabled { - logger.enable_diagnostics(&diag_config); - } - - // Delete mode - if cli.delete { - let name = match cli.containername { - Some(ref n) => n.as_str(), - None => { - eprintln!("Error: --containername is required with --delete"); - process::exit(1); - } - }; - let success = delete_app_container_profile(name, &mut logger); - print!("{}", logger.get_buffer()); - process::exit(if success { 0 } else { 1 }); - } - - // Non-delete paths always have a config JSON at this point (or exited - // above with the missing-config error). - let config_json = config_json.expect("config_json is Some on non-delete paths"); - - // Load request — discriminates state-aware (top-level `phase` field) from - // one-shot. State-aware failures emit a JSON envelope on stdout; one-shot - // and pre-discrimination failures keep the existing diagnostic-on-stderr - // convention. - let load_opts = LoadOptions { - is_base64: false, - cli_command: &cli.command, - }; - let parsed_request = wxc_common::config_parser::load_mxc_request_from_json_with_options( - &config_json, - &mut logger, - load_opts, - ); - let request = match parsed_request { - Ok(MxcRequest::OneShot(req)) => req, - Ok(MxcRequest::StateAware(mut parsed)) => { - let telemetry_active = parsed - .request - .telemetry - .as_ref() - .map(|config| telemetry::init(config, &mut logger)) - .unwrap_or(false); - // Mirror what the one-shot path does at the post-dispatch stage - // below: copy the CLI `--experimental` flag into the parsed - // request so backends that gate on it (e.g. Windows Sandbox - // experimental features) see the same value regardless of which - // dispatch branch the request entered through. Without this, the - // state-aware path runs without the gate -- a phase-envelope request - // could provision/start/exec experimental backends with no - // `--experimental` on the CLI. - parsed.request.experimental_enabled = cli.experimental; - parsed.request.dry_run = cli.dry_run; - run_state_aware_main(parsed, cli.dry_run, telemetry_active, &mut logger) - } - Err(error) => { - match &error { - ParseError::Decode(_) => { - // The payload could not even be decoded into JSON, so no - // backend or field path is known. - log_config_rejected( - &mut logger, - RejectionReason::MalformedJson, - UNKNOWN_BACKEND, - "", - "", - ); - } - ParseError::OneShotMalformed(error) => { - let message = error.to_string(); - log_config_rejected( - &mut logger, - RejectionReason::MalformedJson, - UNKNOWN_BACKEND, - offending_field_from_message(&message), - "", - ); - } - ParseError::OneShot(error) => { - let message = error.to_string(); - log_config_rejected( - &mut logger, - RejectionReason::SchemaViolation, - UNKNOWN_BACKEND, - offending_field_from_message(&message), - "", - ); - } - ParseError::StateAware(error) => { - log_config_rejected( - &mut logger, - rejection_reason_for(error), - UNKNOWN_BACKEND, - offending_field_from_message(&error.message), - "", - ); - } - } - match request_error_route(&error) { - RequestErrorRoute::Diagnostic => { - eprint!("Request error\n{}", logger.get_buffer()); - } - RequestErrorRoute::Envelope(e) => { - print_error_envelope(e); - eprint!("{}", logger.get_buffer()); - } - } - process::exit(1); - } - }; - - let mut request = request; - request.experimental_enabled = cli.experimental; - request.testing_features_enabled = cli.allow_testing_features; - request.dry_run = cli.dry_run; - - // ── Telemetry init ────────────────────────────────────────────── - let telemetry_active = request - .telemetry - .as_ref() - .map(|c| telemetry::init(c, &mut logger)) - .unwrap_or(false); - let requested_sandbox_kind = request - .telemetry - .as_ref() - .and_then(|config| config.requested_sandbox_kind); - - // Install a crash-telemetry panic hook once telemetry is active, chaining - // the previously-installed hook so the default stderr backtrace still - // prints (also satisfying the "always emit a diagnostic" contract for the - // panic case). The hook body is panic-free and emits no message text. - if telemetry_active { - telemetry::set_process_context_with_kind(&request.containment, requested_sandbox_kind); - telemetry::install_panic_hook(); - } - - // --audit injects permissiveLearningMode so denied operations are logged - // but allowed, and drives the WPR/ETW PLM trace pipeline below. This is the - // developer inner-loop flow. The capability names are reserved from direct - // config use, so --audit is the supported permissive entry point until the - // dedicated captureDenials mapping lands. - // Windows ProcessContainer only: reject every other resolved backend before - // mutating the policy or starting the PLM trace. - // - // Permissive mode is the effective mode when audit is requested, so remove - // deny-and-record before injecting it. Comparisons are case-insensitive - // because Windows derives capability SIDs case-insensitively. - #[cfg(target_os = "windows")] - let mut audit_context = None; - #[cfg(target_os = "windows")] - if cli.audit { - if let Err(message) = validate_audit_request(&request) { - log_config_rejected( - &mut logger, - RejectionReason::UnsupportedFieldForBackend, - request.containment.wire_name(), - "containment", - "", - ); - eprintln!("Error: {message}"); - telemetry::emit_early_exit_with_kind( - telemetry_active, - &request.containment, - requested_sandbox_kind, - telemetry::FailureReason::ConfigError, - ); - process::exit(1); - } - let injected = apply_permissive_learning_mode(&mut request.policy.capabilities); - let context = match audit::prepare_request(&mut request, config_file_path(&cli)) { - Ok(context) => context, - Err(message) => { - eprintln!("Error: {message}"); - telemetry::emit_early_exit_with_kind( - telemetry_active, - &request.containment, - requested_sandbox_kind, - telemetry::FailureReason::ConfigError, - ); - process::exit(1); - } - }; - audit_context = Some(context); - logger.log("WARNING: --audit enabled - AppContainer restrictions will NOT be enforced\n"); - if injected && cli.audit_verbose { - eprintln!( - "[mxc] permissiveLearningMode injected via --audit - AppContainer restrictions are NOT enforced" - ); - } - } - - // Final validation: a command line must come from somewhere. If neither - // the policy nor the CLI supplied one we cannot proceed. - if request.script_code.is_empty() { - log_config_rejected( - &mut logger, - RejectionReason::MissingCommand, - request.containment.wire_name(), - "process.commandLine", - "", - ); - eprintln!( - "Error: no command to run. Provide `process.commandLine` in the policy or pass the command as arguments after the config path." - ); - eprint!("{}", logger.get_buffer()); - telemetry::emit_early_exit_with_kind( - telemetry_active, - &request.containment, - requested_sandbox_kind, - telemetry::FailureReason::ConfigError, - ); - process::exit(1); - } - - // Inject learningModeLogging capability when diagnostic console is enabled. - let learning_mode_requested = { - #[cfg(target_os = "windows")] - { - DiagnosticConfig::force_learning_mode() - } - #[cfg(not(target_os = "windows"))] - { - false - } - }; - let learning_mode_injected = if learning_mode_requested - && !request.policy.capabilities.iter().any(|c| { - c.eq_ignore_ascii_case("learningModeLogging") - || c.eq_ignore_ascii_case("permissiveLearningMode") - }) { - request - .policy - .capabilities - .push("learningModeLogging".to_string()); - true - } else { - false - }; - - // Emit the diagnostic preamble after the request is available. - #[cfg(target_os = "windows")] - { - if diag_config.console_enabled { - // Log the preamble - let exe_path = std::env::current_exe() - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_else(|_| "unknown".to_string()); - let parent_info = wxc_common::diagnostic::get_parent_process_info(); - let _ = writeln!( - logger, - "wxc-exec v{} (PID {})", - env!("CARGO_PKG_VERSION"), - std::process::id() - ); - let _ = writeln!(logger, "\tpath: {}", exe_path); - let _ = writeln!(logger, "\tparent: {}", parent_info); - - // Log if we're injecting Learning Mode - if learning_mode_injected { - let _ = writeln!( - logger, - "WARNING: injected 'learningModeLogging' capability via ForceLearningMode registry key" - ); - } - } - - // Log the decoded input only when a diagnostic sink is attached. - // Reuse the already-decoded value so one-shot input sources are not - // read twice, and redact secret-bearing fields before writing it. - if logger.has_diagnostic_sink() { - let _ = writeln!(logger, "SECTION: JSON Config (redacted)"); - let _ = writeln!( - logger, - "{}", - wxc_common::diagnostic::redact_raw_config_json(config_json.trim()) - ); - } - } - - let _ = writeln!(logger, "SECTION: Request simplified"); - log_request(&request, &mut logger); - - #[cfg(target_os = "windows")] - { - // Emit the full (redacted) request policy for diagnostics. Gated on - // an attached sink for the same reason as the raw-config block above: - // rendering the whole request is comparatively expensive and would - // otherwise run unconditionally only to be discarded. - if logger.has_diagnostic_sink() { - let _ = writeln!( - logger, - "SECTION: Full `ExecutionRequest` configuration (redacted)" - ); - let _ = writeln!( - logger, - "{}", - wxc_common::diagnostic::redacted_request_json(&request) - ); - } - } - - // Run script in the selected containment backend. Backend selection and - // runner construction — including the ProcessContainer BaseContainer / - // AppContainer (BFS / DACL) fallback tiers and every experimental backend — - // live in `mxc_engine::resolve_runner`, the single home for one-shot backend - // dispatch. The DACL guard it returns for the fallback tiers is parked into - // the global slot (see `dacl_cleanup_slot`) so the Ctrl-C handler can drop - // it on signal as well as the normal-exit path below; it is `None` when no - // DACL augmentation was required. Tier-selection warnings and the selected - // tier are logged to `logger` by the engine. - let resolved_runner = mxc_engine::resolve_runner(&request, &mut logger); - let mut runner: Box = match resolved_runner { - Ok(resolved) => { - #[cfg(target_os = "windows")] - if let Some(mgr) = resolved.dacl_manager { - park_dacl_for_cleanup(mgr); - } - resolved.runner - } - Err(e) => { - log_config_rejected( - &mut logger, - RejectionReason::RunnerUnavailable, - request.containment.wire_name(), - "containment", - "", - ); - eprintln!("error: {}", e.message); - eprint!("{}", logger.get_buffer()); - telemetry::emit_early_exit_with_kind( - telemetry_active, - &request.containment, - requested_sandbox_kind, - telemetry::FailureReason::InitError, - ); - process::exit(1); - } - }; - - #[cfg(target_os = "windows")] - let exe_dir = std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(std::path::Path::to_path_buf)) - .unwrap_or_else(|| std::path::PathBuf::from(".")); - - let run_start = Instant::now(); - let mut response = runner.run(&request, &mut logger); - let run_elapsed = run_start.elapsed(); - let _ = writeln!(logger, "Runner completed in {}ms", run_elapsed.as_millis()); - - #[cfg(target_os = "windows")] - if let Some(context) = audit_context.as_ref() { - if !cli.dry_run { - match audit::finalize(&mut response, context, &exe_dir, cli.audit_verbose) { - Ok(cleanup_warnings) => { - for warning in cleanup_warnings { - let message = format!("[audit] warning: {warning}"); - let _ = writeln!(logger, "{message}"); - eprintln!("{message}"); - } - eprintln!("[audit] artifacts written to {}", context.log_dir.display()); - } - Err(error) => { - let message = format!("audit finalization failed: {error}"); - let _ = writeln!(logger, "[audit] {message}"); - eprintln!("error: {message}"); - response.exit_code = -1; - response.error_message = match response.error_message.is_empty() { - true => message, - false => format!("{}; {message}", response.error_message), - }; - } - } - } - } - - // Explicitly drop the runner before retrieving the parked DACL - // manager so any runner-internal resources holding child handles - // release first; then drop the manager so its `restore()` runs. - // (process::exit below skips destructors, so we must do this - // manually for prompt cleanup on the normal path. The Ctrl-C - // handler covers the abnormal path; recover_orphaned_state on the - // next startup covers everything else.) - // - // The parked-DACL machinery is Windows-only: `DaclManager` lives behind - // `#[cfg(target_os = "windows")]` in `wxc_common::filesystem_dacl`, so the - // whole extract/park/take/cleanup lifecycle is gated to match. - drop(runner); - #[cfg(target_os = "windows")] - drop(take_parked_dacl()); - - // Surface security warnings (e.g. permissiveLearningMode relaxing - // deny-by-default under --audit). The logger only records these rather than - // writing them itself, so that `mxc_engine` embedders don't get unannounced - // writes to a terminal they own. wxc-exec *does* own its terminal, so it - // opts in here. Messages carry their own banner; print them verbatim. - // Emitted before the dry-run branch below, which exits the process. - for warning in logger.warnings() { - eprintln!("{warning}"); - } - - #[cfg(target_os = "windows")] - if let Err(error) = mxc_engine::emit_verbose_telemetry( - telemetry_active, - &request.containment, - requested_sandbox_kind, - &response, - ) { - logger.log_diagnostic_line(&format!( - "telemetry: captureDenials verbose artifact was not emitted: {error}" - )); - } - - telemetry::emit_completion_with_kind( - telemetry_active, - &request.containment, - requested_sandbox_kind, - &response, - run_elapsed, - ); - - if cli.dry_run { - handle_dry_run_exit(&response, &mut logger); - } - - display_script_results(&response, &mut logger); - - // Close diagnostic pipe. - logger.close_diagnostics(); - - // Output was already relayed to the console by pipe threads. - // Only print captured output if present (e.g. from error paths). - if !response.standard_out.is_empty() { - print!("{}", response.standard_out); - } - if !response.standard_err.is_empty() { - eprint!("{}", response.standard_err); - } - if let Some(pointer) = response - .output_metadata - .as_ref() - .and_then(|metadata| metadata.capture_denials.as_ref()) - { - match serde_json::to_string(pointer) { - Ok(line) => eprintln!("{line}"), - Err(error) => eprintln!("failed to serialize captureDenials output pointer: {error}"), - } - } - - // Emit a structured JSON error envelope on stderr for SDK/caller consumption - // when the runner produced an error message (one-shot flows only). - // In PTY mode stderr is merged into the PTY output stream, so the envelope - // appears inline -- callers (e.g. copilot) can parse it from the output. - wxc_common::script_runner::emit_backend_error_envelope(&response); - - process::exit(response.exit_code); -} - -#[cfg(test)] -mod tests { - use super::*; - - use clap::{CommandFactory, Parser}; - use wxc_common::cmdline::{cmdline_from_argv_for_context, CommandLineContext}; - use wxc_common::config_parser::load_mxc_request_with_options; - use wxc_common::encoding::base64_encode; - use wxc_common::error::WxcError; - use wxc_common::logger::Mode; - use wxc_common::state_aware_request::MxcRequest; - use wxc_common::telemetry::correlation_state::test_support::StoreDirGuard; - - const CLI_OVERRIDE_PROGRAM: &str = "cli-app.exe"; - const CLI_OVERRIDE_FLAG: &str = "--from-cli"; - const CLI_OVERRIDE_ARGV: &[&str] = &[ - "wxc-exec", - "policy.json", - "--", - CLI_OVERRIDE_PROGRAM, - CLI_OVERRIDE_FLAG, - ]; - - fn cli_override_command() -> String { - format!("{CLI_OVERRIDE_PROGRAM} {CLI_OVERRIDE_FLAG}") - } - - fn parse_cli(argv: &[&str]) -> Cli { - Cli::try_parse_from(argv) - .unwrap() - .normalize_named_config_command() - } - - fn encoded_policy(json: &str) -> String { - base64_encode(json.as_bytes()) - } - - fn test_logger() -> Logger { - Logger::new(Mode::Buffer) - } - - /// Every rejection reason must come from the error's own closed `code`, not - /// from matching its message text — the message is prose that can embed - /// paths and is not a stable vocabulary. - #[test] - fn rejection_reason_is_driven_by_the_error_code() { - let cases = [ - ( - MxcError::malformed_request("x"), - RejectionReason::SchemaViolation, - ), - ( - MxcError::malformed_id("x"), - RejectionReason::IdentityShapeInvalid, - ), - ( - MxcError::policy_validation("x"), - RejectionReason::UnsupportedFieldForBackend, - ), - ( - MxcError::unsupported_containment("x"), - RejectionReason::UnsupportedContainment, - ), - ( - MxcError::unsupported_phase("x"), - RejectionReason::UnsupportedPhase, - ), - ( - MxcError::backend_unavailable("x"), - RejectionReason::RunnerUnavailable, - ), - (MxcError::stale_id("x"), RejectionReason::RunnerUnavailable), - ( - MxcError::not_provisioned("x"), - RejectionReason::RunnerUnavailable, - ), - ( - MxcError::not_started("x"), - RejectionReason::RunnerUnavailable, - ), - ( - MxcError::already_started("x"), - RejectionReason::RunnerUnavailable, - ), - ( - MxcError::already_stopped("x"), - RejectionReason::RunnerUnavailable, - ), - ( - MxcError::backend_error("x"), - RejectionReason::RunnerUnavailable, - ), - ]; - for (error, expected) in cases { - assert_eq!( - rejection_reason_for(&error), - expected, - "code {:?} mapped to the wrong reason", - error.code - ); - } - } - - #[derive(Debug)] - struct ResolveError { - message: String, - envelope_routed: bool, - } - - impl ResolveError { - fn from_parse(e: ParseError) -> Self { - let envelope_routed = matches!(request_error_route(&e), RequestErrorRoute::Envelope(_)); - - let message = match e { - ParseError::Decode(err) - | ParseError::OneShot(err) - | ParseError::OneShotMalformed(err) => err.to_string(), - ParseError::StateAware(err) => err.to_string(), - }; - - Self { - message, - envelope_routed, - } - } - } - - #[test] - fn offending_field_is_extracted_without_error_text() { - assert_eq!( - offending_field_from_message( - "Configuration parse error: Invalid configuration at `process.timeout`: invalid number" - ), - "process.timeout" - ); - assert_eq!( - offending_field_from_message("Invalid JSON syntax: expected value at line 1 column 2"), - "" - ); - } - - /// The record must carry the bounded reason and the field *path* — never - /// the offending value, and never the rich error text. - #[test] - fn config_rejected_record_carries_no_free_form_text() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("audit.log"); - let mut logger = test_logger(); - logger.enable_file_sink(&path).expect("file sink"); - - log_config_rejected( - &mut logger, - RejectionReason::MissingCommand, - "processcontainer", - "process.commandLine", - "", - ); - drop(logger); - - let contents = std::fs::read_to_string(&path).expect("read log"); - assert!( - contents.contains(r#""reason":"missing_command""#), - "got: {contents}" - ); - assert!( - contents.contains(r#""offending_field":"process.commandLine""#), - "got: {contents}" - ); - // A one-shot run has no lifecycle phase, so the field is omitted rather - // than emitted as a meaningless empty string. - assert!(!contents.contains("\"phase\""), "got: {contents}"); - } - - #[test] - fn identity_record_reads_the_minted_id_out_of_a_provision_envelope() { - let outcome = Ok(DispatchOutcome::Envelope( - serde_json::json!({"result": {"sandboxId": "iso:wxc-abcd1234"}}), - )); - assert_eq!( - sandbox_id_for_identity_record(&outcome, None).as_deref(), - Some("iso:wxc-abcd1234") - ); - } - - #[test] - fn identity_record_falls_back_to_the_inbound_id_for_later_phases() { - // Later phases return an envelope with no `sandboxId` (the client - // already has it), so the inbound id is the one to report. - let outcome = Ok(DispatchOutcome::Envelope(serde_json::json!({"result": {}}))); - assert_eq!( - sandbox_id_for_identity_record(&outcome, Some("iso:wxc-abcd1234")).as_deref(), - Some("iso:wxc-abcd1234") - ); - - // Exec completes without an envelope at all. - let exec = Ok(DispatchOutcome::ExecCompleted { exit_code: 0 }); - assert_eq!( - sandbox_id_for_identity_record(&exec, Some("iso:wxc-abcd1234")).as_deref(), - Some("iso:wxc-abcd1234") - ); - } - - #[test] - fn no_identity_record_for_a_failed_phase() { - // A failed dispatch produced no sandbox to identify; claiming one would - // be a lie. - let outcome = Err(MxcError::backend_unavailable("nope")); - assert!(sandbox_id_for_identity_record(&outcome, Some("iso:wxc-abcd1234")).is_none()); - } - - #[test] - fn entra_provision_ids_are_never_logged_verbatim() { - // `state_aware.rs::provision` sets `provision_id = user.upn` for Entra - // sandboxes, so the sandboxId tail is a real user identifier. It must not - // reach a log file in any recoverable form. - let outcome = Ok(DispatchOutcome::Envelope( - serde_json::json!({"result": {"sandboxId": "iso:alice@contoso.com"}}), - )); - let id = sandbox_id_for_identity_record(&outcome, None).expect("id"); - let rendered = wxc_common::policy_identity::redact_identity(&id); - assert!(!rendered.contains("alice"), "got: {rendered}"); - assert!(!rendered.contains('@'), "got: {rendered}"); - assert_eq!( - rendered, - wxc_common::policy_identity::ENTRA_UPN_MARKER, - "got: {rendered}" - ); - } - - #[test] - fn request_error_route_matches_the_output_conventions() { - let decode = ParseError::Decode(WxcError::ConfigParse("decode".to_string())); - assert!(matches!( - request_error_route(&decode), - RequestErrorRoute::Diagnostic - )); - - let oneshot = ParseError::OneShot(WxcError::ConfigParse("oneshot".to_string())); - assert!(matches!( - request_error_route(&oneshot), - RequestErrorRoute::Diagnostic - )); - - let stateaware = ParseError::StateAware(MxcError::malformed_request("stateaware")); - assert!(matches!( - request_error_route(&stateaware), - RequestErrorRoute::Envelope(_) - )); - } - - fn resolve_with_cli( - argv: &[&str], - policy_json: &str, - ) -> (Result, String) { - let cli = parse_cli(argv); - let mut logger = test_logger(); - let opts = LoadOptions { - is_base64: true, - cli_command: &cli.command, - }; - - let result = load_mxc_request_with_options(&encoded_policy(policy_json), &mut logger, opts) - .map(|r| match r { - MxcRequest::OneShot(q) => q, - MxcRequest::StateAware(p) => p.request, - }) - .map_err(ResolveError::from_parse); - (result, logger.get_buffer().to_string()) - } - - #[test] - fn audit_mode_replaces_deny_and_record_capability() { - let mut capabilities = vec![ - "internetClient".to_string(), - "LearningModeLogging".to_string(), - ]; - - assert!(apply_permissive_learning_mode(&mut capabilities)); - assert!(capabilities - .iter() - .any(|capability| capability == "permissiveLearningMode")); - assert!(!capabilities - .iter() - .any(|capability| capability.eq_ignore_ascii_case("learningModeLogging"))); - assert!(capabilities - .iter() - .any(|capability| capability == "internetClient")); - assert!(!apply_permissive_learning_mode(&mut capabilities)); - } - - #[test] - fn audit_mode_only_accepts_process_container() { - let mut request = ExecutionRequest { - containment: ContainmentBackend::ProcessContainer, - ..Default::default() - }; - assert!(validate_audit_request(&request).is_ok()); - - for containment in [ - ContainmentBackend::WindowsSandbox, - ContainmentBackend::Wslc, - ContainmentBackend::IsolationSession, - ContainmentBackend::MicroVm, - ] { - let containment_name = containment.wire_name(); - request.containment = containment; - let error = validate_audit_request(&request) - .expect_err("non-ProcessContainer backend must reject --audit"); - assert!(error.contains(containment_name)); - } - } - - #[test] - fn audit_mode_rejects_both_capture_denials_modes() { - use wxc_common::models::{CaptureDenialsConfig, CaptureDenialsMode}; - - for mode in [CaptureDenialsMode::Block, CaptureDenialsMode::Allow] { - let mut request = ExecutionRequest { - containment: ContainmentBackend::ProcessContainer, - ..Default::default() - }; - request.policy.capture_denials = Some(CaptureDenialsConfig { - mode, - output_path: None, - retain_etl: false, - }); - - let error = validate_audit_request(&request) - .expect_err("--audit and captureDenials must be mutually exclusive"); - assert_eq!(error, AUDIT_CAPTURE_DENIALS_CONFLICT_MSG); - assert!(error.contains(r#"mode: "allow""#)); - } - } - - #[test] - fn state_aware_dispatch_errors_use_only_auxiliary_diagnostic_sinks() { - let directory = tempfile::tempdir().unwrap(); - let log_path = directory.path().join("mxc.log"); - let mut logger = test_logger(); - logger.enable_file_sink(&log_path).unwrap(); - let error = MxcError::malformed_request( - "Invalid configuration at `experimental.wslc.start.portMappings[0].windowsPort`", - ); - - log_state_aware_dispatch_error(&mut logger, &error); - - assert!( - logger.get_buffer().is_empty(), - "the JSON envelope must retain primary output ownership" - ); - drop(logger); - let log = std::fs::read_to_string(log_path).unwrap(); - assert_eq!( - log.matches("experimental.wslc.start.portMappings[0].windowsPort") - .count(), - 1 - ); - } - - #[test] - fn config_rejection_reason_classifies_only_validation_errors() { - for code in [ - MxcErrorCode::MalformedRequest, - MxcErrorCode::UnsupportedContainment, - MxcErrorCode::UnsupportedPhase, - MxcErrorCode::MalformedId, - MxcErrorCode::PolicyValidation, - ] { - assert!( - config_rejection_reason_for(&MxcError::new(code, "validation")).is_some(), - "{code} should emit configuration rejection telemetry" - ); - } - for code in [ - MxcErrorCode::BackendUnavailable, - MxcErrorCode::StaleId, - MxcErrorCode::NotProvisioned, - MxcErrorCode::NotStarted, - MxcErrorCode::AlreadyStarted, - MxcErrorCode::AlreadyStopped, - MxcErrorCode::BackendError, - ] { - assert!( - config_rejection_reason_for(&MxcError::new(code, "runtime")).is_none(), - "{code} should remain a runtime error" - ); - } - } - - #[test] - fn state_aware_policy_identity_uses_sandbox_join_key() { - assert_eq!(state_aware_policy_identity(None), "CLI"); - // A caller-supplied sandbox id is not yet backend-verified at this - // logging call site, so it must not be echoed back even when it - // happens to match the shape of an MXC-minted id. - assert_eq!( - state_aware_policy_identity(Some("wsb:0123abcd")), - "unverified" - ); - assert_eq!( - state_aware_policy_identity(Some("some-caller-chosen-string")), - "unverified" - ); - } - - #[test] - fn cli_parses_flags_after_config_path_without_command_override() { - let cli = parse_cli(&["wxc-exec", "policy.json", "--experimental", "--debug"]); - - assert_eq!(cli.config_path.as_deref(), Some("policy.json")); - assert!(cli.experimental); - assert!(cli.debug); - assert!(cli.command.is_empty()); - } - - #[test] - fn cli_captures_command_after_separator() { - let cli = parse_cli(&["wxc-exec", "policy.json", "--", "python", "--version"]); - - assert_eq!(cli.config_path.as_deref(), Some("policy.json")); - assert_eq!( - cli.command, - vec!["python".to_string(), "--version".to_string()] - ); - assert_eq!( - cmdline_from_argv_for_context(&cli.command, CommandLineContext::WindowsCreateProcess) - .unwrap(), - "python --version" - ); - } - - #[test] - fn cli_captures_command_after_named_config() { - let cli = parse_cli(&[ - "wxc-exec", - "--config", - "policy.json", - "--", - "python", - "--version", - ]); - - assert_eq!(cli.config.as_deref(), Some("policy.json")); - assert_eq!(cli.config_path.as_deref(), None); - assert_eq!( - cli.command, - vec!["python".to_string(), "--version".to_string()] - ); - assert_eq!( - cmdline_from_argv_for_context(&cli.command, CommandLineContext::WindowsCreateProcess) - .unwrap(), - "python --version" - ); - } - - #[test] - fn cli_captures_command_after_named_config_separator() { - let cli = parse_cli(&[ - "wxc-exec", - "--config", - "policy.json", - "--experimental", - "--", - "python", - "--version", - ]); - - assert_eq!(cli.config.as_deref(), Some("policy.json")); - assert!(cli.experimental); - assert_eq!(cli.config_path.as_deref(), None); - assert_eq!( - cli.command, - vec!["python".to_string(), "--version".to_string()] - ); - assert_eq!( - cmdline_from_argv_for_context(&cli.command, CommandLineContext::WindowsCreateProcess) - .unwrap(), - "python --version" - ); - } - - #[test] - fn cli_captures_command_after_config_base64() { - let encoded = encoded_policy(r#"{ "process": { "commandLine": "policy" } }"#); - let cli = parse_cli(&[ - "wxc-exec", - "--config-base64", - &encoded, - "--", - "python", - "--version", - ]); - - assert_eq!(cli.config_base64.as_deref(), Some(encoded.as_str())); - assert_eq!(cli.config_path.as_deref(), None); - assert_eq!( - cli.command, - vec!["python".to_string(), "--version".to_string()] - ); - assert_eq!( - cmdline_from_argv_for_context(&cli.command, CommandLineContext::WindowsCreateProcess) - .unwrap(), - "python --version" - ); - } - - #[test] - fn cli_parses_dedicated_telemetry_consent_request() { - let cli = parse_cli(&[ - "wxc-exec", - "--telemetry-consent", - "request", - "--telemetry-consent-locale", - "en-US", - ]); - - assert_eq!( - cli.telemetry_consent, - Some(telemetry::consent_cli::ConsentAction::Request) - ); - assert_eq!(cli.telemetry_consent_locale.as_deref(), Some("en-US")); - } - - #[test] - fn cli_rejects_execution_config_with_telemetry_consent() { - let error = Cli::try_parse_from([ - "wxc-exec", - "--telemetry-consent", - "status", - "--config", - "policy.json", - ]) - .err() - .unwrap(); - - assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); - } - - #[test] - fn cli_rejects_audit_with_telemetry_consent() { - for audit_option in ["--audit", "--audit-verbose"] { - let error = - Cli::try_parse_from(["wxc-exec", "--telemetry-consent", "status", audit_option]) - .err() - .unwrap(); - - assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); - } - } - - #[test] - fn cli_rejects_consent_locale_without_action() { - let error = Cli::try_parse_from(["wxc-exec", "--telemetry-consent-locale", "en-US"]) - .err() - .unwrap(); - - assert_eq!( - error.kind(), - clap::error::ErrorKind::MissingRequiredArgument - ); - } - - #[test] - fn cli_rejects_removed_telemetry_consent_status_flag() { - let error = Cli::try_parse_from(["wxc-exec", "--telemetry-consent-status"]) - .err() - .unwrap(); - - assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument); - } - - #[test] - fn cli_captures_hyphenated_command_after_separator() { - let cli = parse_cli(&[ - "wxc-exec", - "--config", - "policy.json", - "--", - "-command", - "value", - ]); - - assert_eq!(cli.config.as_deref(), Some("policy.json")); - assert_eq!(cli.config_path.as_deref(), None); - assert_eq!( - cli.command, - vec!["-command".to_string(), "value".to_string()] - ); - assert_eq!( - cmdline_from_argv_for_context(&cli.command, CommandLineContext::WindowsCreateProcess) - .unwrap(), - "-command value" - ); - } - - #[test] - fn help_text_documents_cli_command_override() { - let mut command = Cli::command(); - let help = command.render_long_help().to_string(); - - assert!(help.contains("Windows Container Executor")); - assert!(help.contains("COMMAND")); - assert!(help.contains("process.commandLine")); - assert!(help.contains("wxc-exec policy.json -- python --version")); - } - - #[test] - fn windows_sandbox_cli_command_uses_cmd_context() { - assert_eq!( - CommandLineContext::for_backend(&ContainmentBackend::WindowsSandbox), - CommandLineContext::WindowsCommandProcessor - ); - } - - #[test] - fn cli_command_overrides_policy_command_line_in_resolved_request() { - let policy = r#"{ - "process": { - "commandLine": "policy-app.exe --from-policy", - "cwd": "C:\\workspace" - }, - "filesystem": { - "readwritePaths": ["C:\\workspace"] - } - }"#; - - let (resolved_request, log) = resolve_with_cli(CLI_OVERRIDE_ARGV, policy); - - let request = resolved_request.unwrap(); - let expected_command = cli_override_command(); - - assert_eq!(request.script_code, expected_command); - assert_eq!(request.working_directory, "C:\\workspace"); - assert_eq!(request.policy.readwrite_paths, vec!["C:\\workspace"]); - assert!(log.contains(&format!( - "Overriding policy process.commandLine with CLI command: {expected_command}" - ))); - } - - #[test] - fn cli_command_fills_absent_policy_command_line_without_override_log() { - let policy = r#"{ - "process": { - "cwd": "C:\\workspace" - }, - "filesystem": { - "readwritePaths": ["C:\\workspace"] - } - }"#; - - let (resolved_request, log) = resolve_with_cli(CLI_OVERRIDE_ARGV, policy); - - let request = resolved_request.unwrap(); - - assert_eq!(request.script_code, cli_override_command()); - assert_eq!(request.working_directory, "C:\\workspace"); - assert_eq!(request.policy.readwrite_paths, vec!["C:\\workspace"]); - assert!( - !log.contains("Overriding policy process.commandLine"), - "no override log should be emitted when the policy command line is absent" - ); - } - - #[test] - fn policy_command_line_survives_without_cli_command() { - let argv = &["wxc-exec", "policy.json"]; - let policy = r#"{ - "process": { - "commandLine": "policy-app.exe --from-policy", - "cwd": "C:\\workspace" - } - }"#; - - let (resolved_request, log) = resolve_with_cli(argv, policy); - - let request = resolved_request.unwrap(); - - assert_eq!(request.script_code, "policy-app.exe --from-policy"); - assert!( - !log.contains("Overriding policy process.commandLine"), - "no override log should be emitted when no CLI command is supplied" - ); - } - - // The three quoting contexts are distinguished by a single argument - // containing `&`: CreateProcess leaves it bare, cmd.exe quotes it because - // `&` is a command separator, and a POSIX shell single-quotes it. These - // assert the value that reaches `script_code` after the whole pipeline, - // unlike the tests above that assert what `command_override_from_cli` - // renders in isolation. - const QUOTING_ARGV: &[&str] = &["wxc-exec", "policy.json", "--", "app.exe", "a&b"]; - - #[test] - fn cli_command_quoting_for_windows_create_process_in_resolved_request() { - let policy = r#"{ - "containment": "processcontainer", - "process": {} - }"#; - - let (resolved_request, _log) = resolve_with_cli(QUOTING_ARGV, policy); - - assert_eq!(resolved_request.unwrap().script_code, "app.exe a&b"); - } - - #[test] - fn cli_command_quoting_for_command_processor_in_resolved_request() { - let policy = r#"{ - "containment": "windows_sandbox", - "process": {} - }"#; - - let (resolved_request, _log) = resolve_with_cli(QUOTING_ARGV, policy); - - assert_eq!(resolved_request.unwrap().script_code, "app.exe \"a&b\""); - } - - #[test] - fn cli_command_quoting_for_posix_shell_in_resolved_request() { - let policy = r#"{ - "containment": "bubblewrap", - "process": {} - }"#; - - let (resolved_request, _log) = resolve_with_cli(QUOTING_ARGV, policy); - - assert_eq!(resolved_request.unwrap().script_code, "app.exe 'a&b'"); - } - - #[test] - fn non_object_process_section_is_rejected() { - let argv = &["wxc-exec", "policy.json", "--", "echo", "hi"]; - let policy = r#"{ - "process": 42 - }"#; - - let (result, _log) = resolve_with_cli(argv, policy); - - let err = result.unwrap_err(); - assert!( - !err.envelope_routed, - "one-shot failures use the stderr diagnostic convention" - ); - assert!( - err.message.contains("process"), - "error should name the offending section: {}", - err.message - ); - } - - #[test] - fn malformed_policy_json_is_rejected_before_splicing() { - let argv = &["wxc-exec", "policy.json", "--", "echo", "hi"]; - let policy = r#"{ "process": "#; - - let (result, _log) = resolve_with_cli(argv, policy); - - let err = result.unwrap_err(); - assert!( - !err.envelope_routed, - "an undiscriminated decode failure uses the stderr diagnostic convention" - ); - } - - #[test] - fn cli_command_matches_equivalent_policy_command_line() { - let cli = parse_cli(&[ - "wxc-exec", - "policy.json", - "--", - "cli-app.exe", - "--message", - "hello world", - ]); - let mut policy_logger = test_logger(); - let mut cli_logger = test_logger(); - let policy = r#"{ - "process": { - "commandLine": "cli-app.exe --message \"hello world\"", - "cwd": "C:\\workspace" - } - }"#; - let cli_policy = r#"{ - "process": { - "cwd": "C:\\workspace" - } - }"#; - - let policy_request = match load_mxc_request_with_options( - &encoded_policy(policy), - &mut policy_logger, - LoadOptions { - is_base64: true, - cli_command: &[], - }, - ) - .unwrap() - { - MxcRequest::OneShot(req) => req, - MxcRequest::StateAware(_) => panic!("expected one-shot"), - }; - let cli_request = match load_mxc_request_with_options( - &encoded_policy(cli_policy), - &mut cli_logger, - LoadOptions { - is_base64: true, - cli_command: &cli.command, - }, - ) - .unwrap() - { - MxcRequest::OneShot(req) => req, - MxcRequest::StateAware(_) => panic!("expected one-shot"), - }; - - assert_eq!(cli_request.script_code, policy_request.script_code); - assert_eq!( - cli_request.working_directory, - policy_request.working_directory - ); - } - - #[test] - fn isolation_session_cli_command_quotes_shell_metacharacters() { - let cli = parse_cli(&[ - "wxc-exec", - "policy.json", - "--", - "python", - "-c", - "if 5 < 10: print('hello')", - ]); - let command_override = cmdline_from_argv_for_context( - &cli.command, - CommandLineContext::WindowsCommandProcessor, - ) - .unwrap(); - - assert_eq!(command_override, "python -c \"if 5 < 10: print('hello')\""); - } - - #[test] - fn wslc_cli_command_uses_posix_shell_quoting() { - let cli = parse_cli(&["wxc-exec", "policy.json", "--", "echo", "safe&whoami"]); - let command_override = - cmdline_from_argv_for_context(&cli.command, CommandLineContext::PosixShell).unwrap(); - - assert_eq!(command_override, "echo 'safe&whoami'"); - } - - #[test] - fn state_aware_exec_cli_command_overrides_policy_command_line() { - let argv = &["wxc-exec", "policy.json", "--", "echo", "hi"]; - let policy = r#"{ - "phase": "exec", - "sandboxId": "iso:abcd1234", - "process": { - "commandLine": "echo from policy" - } - }"#; - - let (resolved_request, log) = resolve_with_cli(argv, policy); - - let request = resolved_request.unwrap(); - - assert_eq!(request.script_code, "echo hi"); - assert!( - log.contains("Overriding policy process.commandLine"), - "override log should be emitted when the policy command line is present" - ); - } - - #[test] - fn state_aware_exec_cli_command_fills_absent_policy_command_line_without_override_log() { - let argv = &["wxc-exec", "policy.json", "--", "echo", "hi"]; - let policy = r#"{ - "phase": "exec", - "sandboxId": "iso:abcd1234" - }"#; - - let (resolved_request, log) = resolve_with_cli(argv, policy); - - let request = resolved_request.unwrap(); - - assert_eq!(request.script_code, "echo hi"); - assert!( - !log.contains("Overriding policy process.commandLine"), - "no override log should be emitted when the policy command line is absent" - ); - } - - #[test] - fn state_aware_non_exec_cli_command_error_routes_to_envelope() { - let argv = &["wxc-exec", "policy.json", "--", "echo", "hi"]; - let policy = r#"{ - "phase": "start", - "sandboxId": "iso:abcd1234" - }"#; - - let (result, _log) = resolve_with_cli(argv, policy); - - let err = result.unwrap_err(); - assert!( - err.envelope_routed, - "state-aware failures emit an envelope on stdout" - ); - assert!(err - .message - .contains("only supported for state-aware exec requests")); - } - - #[test] - fn state_aware_exec_unconvertible_cli_command_routes_to_envelope() { - let argv = &[ - "wxc-exec", - "policy.json", - "--", - "app.exe", - "hidden\0payload", - ]; - let policy = r#"{ - "phase": "exec", - "sandboxId": "iso:abcd1234" - }"#; - - let (result, _log) = resolve_with_cli(argv, policy); - - let err = result.unwrap_err(); - assert!( - err.envelope_routed, - "state-aware command-rendering failures emit an envelope on stdout" - ); - assert!(err.message.contains("invalid CLI command override")); - } - - #[cfg(target_os = "windows")] - #[test] - fn cancel_then_cleanup_emits_before_cleanup() { - // Locks in the dacl_ctrl_handler ordering guarantee: cancellation - // telemetry must be emitted BEFORE the (up-to-5s, security-critical) - // DACL cleanup, so a cleanup that burns the OS shutdown budget can't - // swallow the telemetry. Drive the extracted helper with two spies that - // record their call order into a shared vec. - use std::sync::Mutex; - - let order: Mutex> = Mutex::new(Vec::new()); - cancel_then_cleanup( - || order.lock().unwrap().push("emit"), - || order.lock().unwrap().push("cleanup"), - ); - - assert_eq!(*order.lock().unwrap(), vec!["emit", "cleanup"]); - } - - #[cfg(target_os = "windows")] - #[test] - fn cancel_then_cleanup_runs_cleanup_even_if_emit_panics() { - // Locks in the cleanup-always guarantee: a panic in the cancellation - // emit (telemetry) must NEVER skip the security-critical DACL cleanup, - // and must not unwind across the extern "system" ctrl-handler boundary. - // Without the catch_unwind in cancel_then_cleanup, `cleanup` would never - // run and the panic would escape the handler (UB in production). - use std::sync::atomic::{AtomicBool, Ordering}; - - static CLEANED: AtomicBool = AtomicBool::new(false); - // Suppress the default panic hook's stderr backtrace for this - // intentional panic so the test output stays clean. - let prev = std::panic::take_hook(); - std::panic::set_hook(Box::new(|_| {})); - cancel_then_cleanup( - || panic!("telemetry emit blew up"), - || CLEANED.store(true, Ordering::SeqCst), - ); - std::panic::set_hook(prev); - - assert!( - CLEANED.load(Ordering::SeqCst), - "cleanup must run even when emit panics" - ); - } - - #[test] - fn pre_dispatch_vector_is_empty_when_telemetry_inactive() { - // Inactive telemetry does no RNG/clock work regardless of phase. - assert!(telemetry::correlation_state::pre_dispatch_vector(false, true, None).is_empty()); - assert!(telemetry::correlation_state::pre_dispatch_vector( - false, - false, - Some("wsb:12345678") - ) - .is_empty()); - } - - #[test] - fn provision_seeds_and_later_phase_recalls_the_persisted_root() { - let tmp = tempfile::tempdir().unwrap(); - let _guard = StoreDirGuard::set(tmp.path()); - let sandbox_id = "wsb:12345678"; - - // Active provision seeds a fresh valid vector. - let provisioned = telemetry::correlation_state::pre_dispatch_vector(true, true, None); - assert!(telemetry::correlation_vector::is_relayable(&provisioned)); - - // Persisting it (as `run_state_aware_main` does post-dispatch) lets a - // later phase recall it and spin a distinct child off the same base. - let outcome: Result = Ok(DispatchOutcome::Envelope( - serde_json::json!({ "result": { "sandboxId": sandbox_id } }), - )); - telemetry::correlation_state::on_provision_outcome(true, &provisioned, &outcome); - - let base_prefix = provisioned.split('.').next().unwrap(); - let spun = telemetry::correlation_state::pre_dispatch_vector(true, false, Some(sandbox_id)); - assert!(spun.starts_with(&format!("{base_prefix}.")), "{spun:?}"); - assert_ne!(spun, provisioned); - } - - #[test] - fn finalize_maps_envelope_to_stdout_exit_zero() { - let outcome: Result = - Ok(DispatchOutcome::Envelope(serde_json::json!({ "ok": true }))); - match finalize_state_aware_outcome(outcome) { - StateAwareExit::Envelope(json) => { - assert_eq!(json, r#"{"ok":true}"#); - } - other => panic!("expected Envelope, got {other:?}"), - } - } - - #[test] - fn finalize_maps_exec_completed_to_its_exit_code() { - let outcome: Result = - Ok(DispatchOutcome::ExecCompleted { exit_code: 7 }); - assert!(matches!( - finalize_state_aware_outcome(outcome), - StateAwareExit::ExecCode(7) - )); - } - - #[test] - fn finalize_maps_error_to_serialised_envelope() { - let outcome: Result = - Err(MxcError::malformed_request("boom".to_string())); - match finalize_state_aware_outcome(outcome) { - StateAwareExit::Error(json) => { - // Must be a parseable envelope carrying the error code so consumers - // that key off `error.code` still work. - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert!(parsed["error"]["code"].is_string(), "{json}"); - } - other => panic!("expected Error, got {other:?}"), - } - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(target_os = "windows")] +mod audit; +#[cfg(target_os = "windows")] +use std::fmt::Write; +use std::process; +use std::sync::{Mutex, OnceLock}; +use std::time::Instant; + +use appcontainer_common::appcontainer_runner::delete_app_container_profile; +use clap::Parser; +use wxc_common::audit::RejectionReason; +use wxc_common::config_parser::{LoadOptions, ParseError}; +use wxc_common::config_rejection::{ + config_rejection_reason_for, log_config_rejected, offending_field_from_message, +}; +#[cfg(target_os = "windows")] +use wxc_common::diagnostic::DiagnosticConfig; +use wxc_common::logger::{Logger, Mode}; +use wxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; +use wxc_common::mxc_error::{MxcError, ResponseEnvelope}; +use wxc_common::script_runner::{handle_dry_run_exit, ScriptRunner}; +use wxc_common::state_aware_dispatch::DispatchOutcome; +use wxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest}; +use wxc_common::telemetry; + +#[derive(Parser)] +#[command(name = "wxc-exec", about = "Windows Container Executor")] +struct Cli { + /// Path to config JSON file (positional) + #[arg(value_name = "CONFIG_PATH")] + config_path: Option, + + /// Path to config JSON file + #[arg(long = "config")] + config: Option, + + /// Base64-encoded JSON config + #[arg(long = "config-base64")] + config_base64: Option, + + /// Enable debug/console output + #[arg(long)] + debug: bool, + + /// Delete container profile mode + #[arg(long)] + delete: bool, + + /// Container name (required with --delete) + #[arg(long = "containername")] + containername: Option, + + /// Enable experimental features + #[arg(long)] + experimental: bool, + + /// Allow testing-only features that must never run in production, currently + /// `network.proxy.builtinTestServer` (a bundled, deliberately-permissive + /// test HTTP proxy). Distinct from --experimental. + #[arg(long = "allow-testing-features")] + allow_testing_features: bool, + + /// Parse and validate config then exit without executing + #[arg(long = "dry-run")] + dry_run: bool, + + /// Path to diagnostic log file (appends, creates if missing) + #[arg(long = "log-file")] + log_file: Option, + + /// Install the warmed Hyperlight snapshot and exit. Pulls the + /// published kernel + initrd from GHCR (via docker or podman), + /// warms them up, and writes the snapshot into the default user + /// data dir (~/.local/share/pyhl on Linux, %LOCALAPPDATA%\pyhl on + /// Windows). $PYHL_HOME overrides the destination if set. Intended + /// for tool install hooks so first-run has zero warmup cost. + #[arg(long = "setup-hyperlight")] + setup_hyperlight: bool, + + /// Rebuild the snapshot even if one already exists. Use after + /// upgrading `kernel` or `initrd.cpio` so the warm state matches + /// the new bits. Requires --setup-hyperlight. + #[arg(long, requires = "setup_hyperlight")] + force: bool, + + /// Pre-pull a WSLC container image into the local image cache and exit. + /// MXC is an execution layer and does not pull images at run time; this + /// flag (or `scripts/setup-wslc.ps1`) is how operators populate the cache. + /// Requires `--image` to specify which image to pull. + #[arg(long = "setup-wslc")] + setup_wslc: bool, + + /// Image reference to pre-pull (e.g. `alpine:latest`, + /// `ghcr.io/owner/image:tag`). Required with `--setup-wslc`. + #[arg(long = "image", requires = "setup_wslc")] + image: Option, + + /// Optional WSLC storage path. When omitted the runner default is used + /// (`%TEMP%\mxc-wslc-sessions`). Pass the same value here that your + /// runtime configs set in `experimental.wslc.storagePath`, otherwise + /// the runner will not find the pulled image. Requires `--setup-wslc`. + #[arg(long = "storage-path", requires = "setup_wslc")] + storage_path: Option, + + /// Run the fallback detector and emit JSON, without spawning a sandbox. + #[arg(long)] + probe: bool, + + /// Manage telemetry consent without spawning a sandbox. + #[arg( + long = "telemetry-consent", + value_name = "ACTION", + conflicts_with_all = [ + "config_path", + "config", + "config_base64", + "command", + "delete", + "containername", + "experimental", + "allow_testing_features", + "dry_run", + "setup_hyperlight", + "force", + "setup_wslc", + "image", + "storage_path", + "probe", + "force_reclaim" + ] + )] + #[cfg_attr( + target_os = "windows", + arg(conflicts_with_all = ["audit", "audit_verbose"]) + )] + telemetry_consent: Option, + + /// Preferred BCP 47 locale for a telemetry consent request. + #[arg(long = "telemetry-consent-locale", requires = "telemetry_consent")] + telemetry_consent_locale: Option, + + /// Windows Sandbox: tear down a running WSB VM that mxc cannot prove it + /// launched, instead of refusing — clears a host wedged by an orphan left + /// after a launcher hard-kill. DANGER: proofless, so it may also kill a + /// foreign sandbox. Never overrides an active mxc run or an unreadable probe. + #[arg(long = "force-reclaim")] + force_reclaim: bool, + + /// Audit mode: run captureDenials in permissive allow mode with ETL + /// retention, then generate developer policy-authoring artifacts. Windows + /// ProcessContainer only. + #[cfg(target_os = "windows")] + #[arg(long)] + audit: bool, + + /// Print learned-policy post-processing details for `--audit`. + #[cfg(target_os = "windows")] + #[arg(long)] + audit_verbose: bool, + + /// Command to run inside the container, overriding `process.commandLine` + /// from the policy. The command must follow a `--` separator so normal + /// CLI flags remain usable after the config path. Examples: + /// wxc-exec policy.json -- python --version + /// When provided, `process.commandLine` in the policy file becomes + /// optional and is overridden. + #[arg(value_name = "COMMAND", last = true, allow_hyphen_values = true)] + command: Vec, +} + +impl Cli { + fn normalize_named_config_command(mut self) -> Self { + if self.config.is_some() || self.config_base64.is_some() { + if let Some(first) = self.config_path.take() { + self.command.insert(0, first); + } + } + self + } +} + +fn parse_cli() -> Cli { + match Cli::try_parse() { + Ok(cli) => cli, + Err(error) => { + if matches!( + error.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) { + error.exit(); + } + let is_consent_command = + telemetry::consent_cli::invocation_uses_consent_options(std::env::args_os()); + if is_consent_command { + let _ = error.print(); + process::exit(64); + } + error.exit(); + } + } +} + +fn log_request(request: &ExecutionRequest, logger: &mut Logger) { + if !request.container_id.is_empty() { + let _ = writeln!(logger, "Container ID: {}", request.container_id); + } + let _ = writeln!(logger, "Script code length: {}", request.script_code.len()); + let _ = writeln!(logger, "Working directory: {}", request.working_directory); + let _ = writeln!(logger, "Script timeout: {}", request.script_timeout); + let _ = writeln!( + logger, + "Container name: {}", + if request.container_id.is_empty() { + "CLI" + } else { + &request.container_id + } + ); +} + +fn display_script_results(response: &ScriptResponse, logger: &mut Logger) { + let code = response.exit_code; + let _ = writeln!(logger, "Exit code: {} (0x{:08X})", code, code as u32); + if !response.error_message.is_empty() { + let _ = writeln!(logger, "Error: {}", response.error_message); + } +} + +fn apply_permissive_learning_mode(capabilities: &mut Vec) -> bool { + capabilities.retain(|capability| !capability.eq_ignore_ascii_case("learningModeLogging")); + if capabilities + .iter() + .any(|capability| capability.eq_ignore_ascii_case("permissiveLearningMode")) + { + false + } else { + capabilities.push("permissiveLearningMode".to_string()); + true + } +} + +const AUDIT_CAPTURE_DENIALS_CONFLICT_MSG: &str = + "--audit cannot be combined with processContainer.captureDenials; use captureDenials.mode: \"allow\" for permissive capture"; + +fn validate_audit_request(request: &ExecutionRequest) -> Result<(), String> { + if request.containment != ContainmentBackend::ProcessContainer { + return Err(format!( + "--audit is only supported with the Windows ProcessContainer backend; \ + resolved containment is '{}'", + request.containment.wire_name() + )); + } + if request.policy.capture_denials.is_some() { + return Err(AUDIT_CAPTURE_DENIALS_CONFLICT_MSG.to_string()); + } + Ok(()) +} + +/// Read the request source (file path / base64 blob) once, returning the +/// decoded JSON. Reused by `--probe` and the normal request loader so a single +/// source is only read once per invocation. +fn decode_config_input_once(cli: &Cli) -> Option> { + let (input, is_base64) = config_input(cli)?; + Some(wxc_common::config_parser::decode_request_input( + &input, is_base64, + )) +} + +/// On a state-aware dispatch failure, record the error only on the auxiliary +/// diagnostic sinks (`--log-file` and the diagnostic pipe) via +/// [`Logger::log_diagnostic_line`]. It is deliberately kept out of the primary +/// console/buffer output so it never interleaves with the stdout response +/// envelope; the failure reason still reaches the client through the JSON error +/// envelope on stdout. +fn log_state_aware_dispatch_error(logger: &mut Logger, error: &MxcError) { + logger.log_diagnostic_line(&error.to_string()); +} + +/// Backend name for a rejection that happened before (or without) backend +/// resolution. +const UNKNOWN_BACKEND: &str = "unknown"; + +fn rejection_reason_for(error: &MxcError) -> RejectionReason { + config_rejection_reason_for(error).unwrap_or(RejectionReason::RunnerUnavailable) +} + +/// Drives the state-aware dispatch flow. On envelope success, writes the +/// JSON to stdout and exits 0. On exec success, exits with the script's +/// exit code (output already streamed). On failure, writes a JSON error +/// envelope to stdout and exits 1. Diagnostic logger output goes to stderr +/// regardless of mode (per design §7.3 stream protocol — stdout reserved +/// for the response envelope). +fn run_state_aware_main( + parsed: ParsedStateAwareRequest, + dry_run: bool, + telemetry_active: bool, + logger: &mut Logger, +) -> ! { + let outcome = + mxc_engine::run_state_aware_with_telemetry(parsed, dry_run, telemetry_active, logger); + + // On dispatch failure, route the error to the auxiliary diagnostic sinks + // only (log file / diagnostic pipe) — never the primary buffer/stderr — so + // the stdout error envelope written below stays the single client-facing + // channel and is not shadowed by a duplicate on stderr. + if let Err(error) = &outcome { + log_state_aware_dispatch_error(logger, error); + } + for warning in logger.take_warnings() { + eprintln!("{warning}"); + } + // Diagnostic buffer flushes to stderr regardless of success/failure so it + // never interleaves with the stdout envelope. + let buffered = logger.get_buffer().to_string(); + if !buffered.is_empty() { + eprint!("{}", buffered); + } + match finalize_state_aware_outcome(outcome) { + StateAwareExit::Envelope(json) => { + println!("{}", json); + process::exit(0); + } + StateAwareExit::ExecCode(exit_code) => process::exit(exit_code), + StateAwareExit::Error(json) => { + println!("{}", json); + process::exit(1); + } + } +} + +/// The terminal action [`run_state_aware_main`] takes for a dispatch outcome, +/// factored out of that `-> !` entry point so the outcome→exit mapping is +/// unit-testable without spawning a process or calling [`process::exit`]. +#[cfg_attr(test, derive(Debug))] +enum StateAwareExit { + /// Print this JSON success envelope to stdout, then exit 0. + Envelope(String), + /// Exit with this script exit code (its output was already streamed). + ExecCode(i32), + /// Print this JSON error envelope to stdout, then exit 1. + Error(String), +} + +/// Map a dispatch outcome to its terminal exit action. Pure — it neither prints +/// nor exits — so [`run_state_aware_main`]'s final branch can be exercised in a +/// unit test. The `-> !` caller performs the actual stdout write + exit. +fn finalize_state_aware_outcome(outcome: Result) -> StateAwareExit { + match outcome { + Ok(DispatchOutcome::Envelope(value)) => StateAwareExit::Envelope(value.to_string()), + Ok(DispatchOutcome::ExecCompleted { exit_code }) => StateAwareExit::ExecCode(exit_code), + Err(e) => StateAwareExit::Error(error_envelope_string(&e)), + } +} + +enum RequestErrorRoute<'a> { + Diagnostic, + Envelope(&'a MxcError), +} + +fn request_error_route(error: &ParseError) -> RequestErrorRoute<'_> { + match error { + ParseError::Decode(_) | ParseError::OneShot(_) | ParseError::OneShotMalformed(_) => { + RequestErrorRoute::Diagnostic + } + ParseError::StateAware(e) => RequestErrorRoute::Envelope(e), + } +} + +fn print_error_envelope(error: &MxcError) { + println!("{}", error_envelope_string(error)); +} + +/// Serialise `error` to its JSON response-envelope string. Split out of +/// [`print_error_envelope`] so [`finalize_state_aware_outcome`] can build the +/// error-exit envelope without printing, and so both the print path and the +/// state-aware exit path share one serialisation (including the last-resort +/// fallback when the envelope itself fails to serialise). +fn error_envelope_string(error: &MxcError) -> String { + let envelope: ResponseEnvelope<()> = ResponseEnvelope::from_error(error); + serde_json::to_string(&envelope).unwrap_or_else(|_| { + // Last-resort path: serialisation of the envelope itself failed — emit a + // minimal structurally-valid envelope so consumers that require + // `error.code` still parse something. + r#"{"error":{"code":"backend_error","message":"failed to serialise error envelope"}}"# + .to_string() + }) +} + +fn config_input(cli: &Cli) -> Option<(String, bool)> { + if let Some(b64) = cli.config_base64.as_ref() { + Some((b64.clone(), true)) + } else if let Some(p) = cli.config.as_ref() { + Some((p.clone(), false)) + } else { + cli.config_path.as_ref().map(|p| (p.clone(), false)) + } +} + +/// Resolve the on-disk config path passed to `wxc-exec`, if any. Returns +/// `None` when the config was supplied as `--config-base64` (no file path) +/// or not at all. Used by `--audit` to thread `plm stop --config-path` so +/// findings can be merged back into the source policy. +#[cfg(target_os = "windows")] +fn config_file_path(cli: &Cli) -> Option { + if cli.config_base64.is_some() { + return None; + } + cli.config + .as_ref() + .or(cli.config_path.as_ref()) + .map(std::path::PathBuf::from) +} + +// --------------------------------------------------------------------------- +// Graceful-exit DACL cleanup +// --------------------------------------------------------------------------- +// +// `DaclManager`'s `Drop` is the only thing that restores host ACEs we +// applied during a Tier 2 / Tier 3 run. We need that `Drop` to fire on +// every code path that can leave main, including the abnormal ones. +// There are three: +// +// 1. **Normal exit / `process::exit`** — destructors of stack-owned +// values run on the former and are SKIPPED on the latter. We deal +// with this by explicitly `drop(take_parked_dacl())`-ing before any +// `process::exit` site below. +// 2. **Panic unwind** — destructors of stack-owned values run; the +// release profile uses the default `unwind` strategy (see +// `src/Cargo.toml`). But the `DaclManager` we extract from +// `Dispatched` lives inside a process-global static (so the Ctrl-C +// handler can reach it), and statics are NOT touched by unwinding. +// To restore on panic we install a stack-owned `ParkedDaclGuard` in +// `main` whose `Drop` calls `take_parked_dacl()` and drops the +// manager. The guard sits at function scope so the unwind path +// threads through it. +// 3. **Ctrl-C / Ctrl-Break / console close / logoff / shutdown** — the +// default Windows handler calls `ExitProcess` directly, skipping +// every Rust destructor. We install a `SetConsoleCtrlHandler` that +// takes-and-drops the parked manager before yielding to the +// default handler. +// +// The mutex in the slot serializes the Ctrl-C handler and the guard +// against each other, so the manager is taken (and therefore +// `Drop`'d) at most once. +// +// Parent-process kill (`TerminateProcess`) still bypasses every +// handler; any leak there is reaped by `recover_orphaned_state` on +// the next wxc-exec startup (which we already run at the top of +// `main`). + +#[cfg(target_os = "windows")] +static DACL_CLEANUP_SLOT: OnceLock>> = + OnceLock::new(); + +#[cfg(target_os = "windows")] +fn dacl_cleanup_slot() -> &'static Mutex> { + DACL_CLEANUP_SLOT.get_or_init(|| Mutex::new(None)) +} + +/// Park the DACL manager in the global slot so the Ctrl-C handler can +/// drop it if a signal arrives before the normal-exit path runs. +#[cfg(target_os = "windows")] +fn park_dacl_for_cleanup(mgr: wxc_common::filesystem_dacl::DaclManager) { + let slot = dacl_cleanup_slot(); + let mut guard = slot.lock().unwrap_or_else(|p| p.into_inner()); + *guard = Some(mgr); +} + +/// Take the parked DACL manager (if any) so the caller can drop it, +/// triggering ACE restore. Returns `None` if either nothing was parked +/// or another path (the Ctrl-C handler) already took it. +/// +/// Recovers from `PoisonError` the same way [`park_dacl_for_cleanup`] +/// does (`into_inner`): a poisoned mutex must NOT silently swallow a +/// parked manager — that would leak ACEs until the next-startup +/// recovery scan reaps them. +#[cfg(target_os = "windows")] +fn take_parked_dacl() -> Option { + DACL_CLEANUP_SLOT.get().and_then(|slot| { + let mut guard = slot.lock().unwrap_or_else(|p| p.into_inner()); + guard.take() + }) +} + +/// Stack-owned witness that ensures `take_parked_dacl()` runs on every +/// path out of `main`, including panic unwind. The parked +/// `DaclManager` lives in a process-global static (so the Ctrl-C +/// handler can reach it), and Rust's unwinder doesn't touch statics — +/// without this guard, a panic between `park_dacl_for_cleanup` and +/// the explicit `drop(take_parked_dacl())` near the end of `main` +/// would leave host ACEs in place until the next startup's recovery +/// scan. +/// +/// `Drop` is a no-op if nothing was ever parked or if the Ctrl-C +/// handler already drained the slot. +#[cfg(target_os = "windows")] +struct ParkedDaclGuard; + +#[cfg(target_os = "windows")] +impl Drop for ParkedDaclGuard { + fn drop(&mut self) { + drop(take_parked_dacl()); + } +} + +/// Windows console-control handler. Called by the OS on Ctrl-C, Ctrl-Break, +/// console close, logoff, and shutdown. Takes the parked DACL manager and +/// drops it — `Drop` runs `restore()` which removes the ACEs we applied. +/// Returns `FALSE` so the default handler still runs (which terminates +/// the process). +/// +/// Acquires the slot with a bounded wait +/// (`plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT`), not `try_lock`. +/// If the main thread is mid-`Drop` on the same manager — which can be +/// doing a `SetNamedSecurityInfoW` — returning FALSE immediately lets +/// the default handler call `ExitProcess`, terminating that drop mid- +/// Win32 and leaving the host DACL in an inconsistent state. The +/// bounded wait blocks the default handler until either main finishes +/// (lock released) or the shared timeout elapses — whichever comes +/// first. On timeout we proceed anyway; the recovery scan on the next +/// `wxc-exec` startup reaps anything left behind. +#[cfg(target_os = "windows")] +unsafe extern "system" fn dacl_ctrl_handler(_ctrl_type: u32) -> windows::core::BOOL { + // Ordering guarantee: best-effort cancellation telemetry runs STRICTLY + // BEFORE the up-to-5s DACL cleanup loop, which can consume the OS + // shutdown-handler budget. Factored through `cancel_then_cleanup` so a + // future edit that reorders these two would fail a unit test rather than + // silently drop cancellation telemetry when the cleanup times out. + cancel_then_cleanup( + // Emit first. No-op unless telemetry is active; emits no message text + // and does not shut the provider down (the OS reclaims it at exit). + telemetry::emit_cancellation, + // Then the security-critical host DACL cleanup. + || { + if let Some(slot) = DACL_CLEANUP_SLOT.get() { + use std::time::{Duration, Instant}; + // CTRL_CLOSE_EVENT / CTRL_LOGOFF / CTRL_SHUTDOWN have a hard + // ~5s OS-imposed kill budget. The persistent elevated PLM + // child handles trace cleanup after this owner terminates. + let deadline = Instant::now() + plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT; + loop { + if let Ok(mut guard) = slot.try_lock() { + // Either main already took the manager (guard is None) + // or it never parked one; dropping `Option::take` is + // a no-op in both cases. Either way, the contract — no + // restore thread running concurrently with the default + // handler's ExitProcess — is satisfied. + drop(guard.take()); + break; + } + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + } + }, + ); + // FALSE = "I did not fully handle this; run the next handler in the + // chain (i.e. the default handler that calls ExitProcess)". + windows::core::BOOL(0) +} + +/// Runs the cancellation `emit` strictly before the (possibly slow, +/// security-critical) `cleanup`, and guarantees `cleanup` runs even if `emit` +/// panics. Factored out of [`dacl_ctrl_handler`] so both invariants are lockable +/// by a unit test: +/// +/// 1. **Ordering** — `emit` runs before `cleanup`. The cleanup can burn the OS +/// shutdown-handler budget and never return, so emitting first ensures the +/// cancellation telemetry is not lost. +/// 2. **Cleanup-always** — `emit` is wrapped in [`catch_unwind`] so a panic in +/// telemetry can never skip the DACL restore (which would leave host ACEs in +/// place) and can never unwind across the `extern "system"` `dacl_ctrl_handler` +/// boundary (which is undefined behaviour). +/// +/// A caught panic is intentionally swallowed: this runs on the OS +/// shutdown-handler path where there is nothing left to propagate it to, and the +/// security-critical `cleanup` is what must not be skipped. +#[cfg(target_os = "windows")] +fn cancel_then_cleanup(emit: impl FnOnce(), cleanup: impl FnOnce()) { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(emit)); + cleanup(); +} + +/// Install the console-control handler. Idempotent — calling twice +/// registers the same handler twice, which is harmless because the +/// take-and-drop is `Option::take`-based. +#[cfg(target_os = "windows")] +fn install_dacl_ctrl_handler() { + use windows::Win32::System::Console::SetConsoleCtrlHandler; + // SAFETY: `dacl_ctrl_handler` has the correct ABI; the `Add=TRUE` + // call merely appends to the OS handler chain. + let _ = unsafe { SetConsoleCtrlHandler(Some(dacl_ctrl_handler), true) }; +} + +fn main() { + let cli = parse_cli().normalize_named_config_command(); + + if let Some(action) = cli.telemetry_consent { + let outcome = telemetry::consent_cli::handle_consent_command( + action, + cli.telemetry_consent_locale.as_deref(), + ); + process::exit(outcome.emit()); + } + // Decode the request source (file path / base64) once, up front. + let decoded_config: Option> = + decode_config_input_once(&cli); + + // Propagate --force-reclaim via the environment so it reaches both the + // in-process one-shot reconcile and the detached daemon. Set before any + // backend dispatch or daemon spawn. + if cli.force_reclaim { + std::env::set_var("WXC_WSB_FORCE_RECLAIM", "1"); + } + + // Best-effort: reap any orphaned DACL state files left behind by + // crashed prior MXC runs. Runs BEFORE the `--probe` arm because + // `wxc-exec --probe` is the canonical recovery trigger consumers + // (WinProcessContainer-Tests Phase 6, SDK warm-start) rely on. Errors here + // are non-fatal and only surface via stderr. On a healthy host + // with zero state files this is sub-millisecond. + match wxc_common::filesystem_dacl::recover_orphaned_state() { + Ok(report) => { + if report.files_processed > 0 || !report.errors.is_empty() { + eprintln!( + "DACL recovery: {} file(s), {} ACE(s) restored, {} pruned (missing), {} error(s)", + report.files_processed, + report.aces_restored, + report.aces_pruned_missing, + report.errors.len() + ); + for e in &report.errors { + eprintln!(" {e}"); + } + } + } + Err(e) => eprintln!("DACL recovery failed: {e}"), + } + + // --probe is a detection-only fast path used by SDK + // `getPlatformSupport()` on every first call. It does not spawn a + // sandbox and never parks a DaclManager. + // + // It does activate WinRT: `isolation_session_available()` resolves the + // IsolationSession activation factory. That probe owns and releases its + // own COM apartment, so it neither needs nor disturbs the process-wide + // init below — this ordering is not a claim that the probe is COM-free. + // + // Run it AFTER recovery (so consumers that rely on `--probe`-as- + // reaper still get it) but BEFORE COM init / SetConsoleCtrlHandler + // (which probe doesn't need; deferring them shaves cold-start cost + // off the SDK warm path). + if cli.probe { + let policy = if let Some(decoded) = decoded_config.as_ref() { + // Parse using the existing pipeline but route logger output to + // an in-memory buffer that we discard — the probe must not + // emit anything other than its JSON line on stdout. + let mut probe_logger = Logger::new(Mode::Buffer); + match decoded { + Ok(json) => { + match wxc_common::config_parser::load_request_from_json(json, &mut probe_logger) + { + Ok(r) => r.policy, + Err(_) => { + eprintln!("Error: failed to load probe config"); + eprint!("{}", probe_logger.get_buffer()); + process::exit(1); + } + } + } + Err(_) => { + eprintln!("Error: failed to load probe config"); + process::exit(1); + } + } + } else { + wxc_common::models::ContainerPolicy::default() + }; + let output = appcontainer_common::probe::run_probe(&policy); + // appcontainer_common has no dependency on the isolation-session + // backend, so it reports `isolationSessionAvailable` as `false`. When + // the backend is compiled in, override it with a read-only activation + // probe of the in-proc service. + #[cfg(all(target_os = "windows", feature = "isolation_session"))] + let output = { + let mut output = output; + output.probes.isolation_session_available = mxc_engine::isolation_session_available(); + output + }; + // WHP is delay-loaded; check before pyhl::install warms a VM. + #[cfg(all(target_os = "windows", feature = "hyperlight", target_arch = "x86_64"))] + let output = { + let mut output = output; + output.probes.hyperlight_available = hyperlight_common::is_whp_available(); + output + }; + match appcontainer_common::probe::to_json_pretty(&output) { + Ok(s) => println!("{s}"), + Err(e) => { + eprintln!("Error: probe serialization failed: {e}"); + process::exit(1); + } + } + return; + } + + // Initialize COM/WinRT for backends that use WinRT APIs (Isolation Session). + // COINIT_MULTITHREADED is benign for backends that don't use COM. + // + // SAFETY: `CoInitializeEx` is sound to call once at process start before + // any WinRT or COM activation. `pvReserved` must be `None` per the API + // contract. The return value is intentionally ignored — repeat-init + // outcomes (`S_FALSE`, `RPC_E_CHANGED_MODE`) are benign here. + let _ = unsafe { + windows::Win32::System::Com::CoInitializeEx( + None, + windows::Win32::System::Com::COINIT_MULTITHREADED, + ) + }; + + // Install the Ctrl-C / Ctrl-Break handler that drops any parked + // DaclManager on signal. Cheap and idempotent. Windows-only: the parked + // DACL manager it drains lives behind `#[cfg(target_os = "windows")]`. + #[cfg(target_os = "windows")] + install_dacl_ctrl_handler(); + + // Stack-owned witness so a panic anywhere below — between + // `park_dacl_for_cleanup` and the explicit `drop(take_parked_dacl())` + // near the end of `main` — still drains the slot and runs + // `restore()` during unwind. Without it the manager is parked in + // a static and unwinding skips destructors of static-owned values. + #[cfg(target_os = "windows")] + let _dacl_guard = ParkedDaclGuard; + + // --setup-hyperlight: warm up the snapshot and exit. Runs before + // config parsing so the user doesn't need a JSON file on disk + // just to install. + if cli.setup_hyperlight { + #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] + { + // WHP is delay-loaded; check before pyhl::install warms a VM. + #[cfg(target_os = "windows")] + if !hyperlight_common::is_whp_available() { + eprintln!( + "Error: --setup-hyperlight requires Windows Hypervisor Platform (WHP). \ + Enable the HypervisorPlatform optional feature and reboot." + ); + process::exit(1); + } + + let mut logger = Logger::new(if cli.debug { + Mode::Console + } else { + Mode::Buffer + }); + match hyperlight_common::setup(cli.force, &mut logger) { + Ok(snap) => { + eprintln!("hyperlight setup: snapshot ready at {:?}", snap); + process::exit(0); + } + Err(msg) => { + eprintln!("hyperlight setup failed: {msg}"); + process::exit(1); + } + } + } + #[cfg(not(all(feature = "hyperlight", target_arch = "x86_64")))] + { + eprintln!("Error: --setup-hyperlight requires x86_64 (Hyperlight needs KVM or WHP)"); + process::exit(1); + } + } + + // --setup-wslc: pre-pull a WSLC image into the local cache and exit. + // Runs before config parsing so the user doesn't need a JSON file just + // to populate images. Clap enforces that `--image` is present. + if cli.setup_wslc { + #[cfg(feature = "wslc")] + { + let image = match cli.image.as_deref() { + Some(s) if !s.is_empty() => s, + _ => { + eprintln!("Error: --setup-wslc requires --image "); + process::exit(1); + } + }; + let mut logger = Logger::new(if cli.debug { + Mode::Console + } else { + Mode::Buffer + }); + // SAFETY: setup_pull_image is the canonical SDK-loading entry + // point for this subcommand and is called exactly once before + // process exit. It owns the COM init contract documented on + // `init_and_load_sdk`. + let result = unsafe { + wslc_common::wsl_container_runner::WSLContainerRunner::setup_pull_image( + image, + cli.storage_path.as_deref(), + &mut logger, + ) + }; + // Flush logger buffer to stderr regardless of outcome so the + // user can see what happened. + let buf = logger.get_buffer().to_string(); + if !buf.is_empty() { + eprint!("{}", buf); + } + match result { + Ok(()) => process::exit(0), + Err(msg) => { + eprintln!("wslc setup failed: {msg}"); + process::exit(1); + } + } + } + #[cfg(not(feature = "wslc"))] + { + eprintln!("Error: WSLC backend not compiled. Rebuild with --features wslc."); + process::exit(1); + } + } + + // --probe is handled at the top of `main` (before COM init) for + // SDK first-call latency. See note there. + + // Determine config input. In delete mode the config is optional; every + // other path requires it. `decoded_config` above already read the source + // once — if it's populated, unpack the decoded JSON (or surface the + // decode error). If it's absent, either accept the empty state for + // delete mode or report the missing-config error. + let config_json: Option = match decoded_config { + Some(Ok(json)) => Some(json), + Some(Err(error)) => { + eprintln!("Request error"); + eprintln!("{error}"); + process::exit(1); + } + None => { + if !cli.delete { + eprintln!( + "Error: No config provided. Use a positional path, --config, or --config-base64" + ); + process::exit(1); + } + None + } + }; + + let mut logger = Logger::new(if cli.debug { + Mode::Console + } else { + Mode::Buffer + }); + + if let Some(ref log_path) = cli.log_file { + if let Err(e) = logger.enable_file_sink(std::path::Path::new(log_path)) { + eprintln!("Warning: could not open log file '{}': {}", log_path, e); + } + } + + // Initialize the diagnostic console before parsing so early rejection + // records have an active sink. + #[cfg(target_os = "windows")] + let diag_config = DiagnosticConfig::from_environment(); + #[cfg(target_os = "windows")] + if diag_config.console_enabled { + logger.enable_diagnostics(&diag_config); + } + + // Delete mode + if cli.delete { + let name = match cli.containername { + Some(ref n) => n.as_str(), + None => { + eprintln!("Error: --containername is required with --delete"); + process::exit(1); + } + }; + let success = delete_app_container_profile(name, &mut logger); + print!("{}", logger.get_buffer()); + process::exit(if success { 0 } else { 1 }); + } + + // Non-delete paths always have a config JSON at this point (or exited + // above with the missing-config error). + let config_json = config_json.expect("config_json is Some on non-delete paths"); + + // Load request — discriminates state-aware (top-level `phase` field) from + // one-shot. State-aware failures emit a JSON envelope on stdout; one-shot + // and pre-discrimination failures keep the existing diagnostic-on-stderr + // convention. + let load_opts = LoadOptions { + is_base64: false, + cli_command: &cli.command, + }; + let parsed_request = wxc_common::config_parser::load_mxc_request_from_json_with_options( + &config_json, + &mut logger, + load_opts, + ); + let request = match parsed_request { + Ok(MxcRequest::OneShot(req)) => req, + Ok(MxcRequest::StateAware(mut parsed)) => { + let telemetry_active = parsed + .request + .telemetry + .as_ref() + .map(|config| telemetry::init(config, &mut logger)) + .unwrap_or(false); + // Mirror what the one-shot path does at the post-dispatch stage + // below: copy the CLI `--experimental` flag into the parsed + // request so backends that gate on it (e.g. Windows Sandbox + // experimental features) see the same value regardless of which + // dispatch branch the request entered through. Without this, the + // state-aware path runs without the gate -- a phase-envelope request + // could provision/start/exec experimental backends with no + // `--experimental` on the CLI. + parsed.request.experimental_enabled = cli.experimental; + parsed.request.dry_run = cli.dry_run; + run_state_aware_main(parsed, cli.dry_run, telemetry_active, &mut logger) + } + Err(error) => { + match &error { + ParseError::Decode(_) => { + // The payload could not even be decoded into JSON, so no + // backend or field path is known. + log_config_rejected( + &mut logger, + RejectionReason::MalformedJson, + UNKNOWN_BACKEND, + "", + "", + ); + } + ParseError::OneShotMalformed(error) => { + let message = error.to_string(); + log_config_rejected( + &mut logger, + RejectionReason::MalformedJson, + UNKNOWN_BACKEND, + offending_field_from_message(&message), + "", + ); + } + ParseError::OneShot(error) => { + let message = error.to_string(); + log_config_rejected( + &mut logger, + RejectionReason::SchemaViolation, + UNKNOWN_BACKEND, + offending_field_from_message(&message), + "", + ); + } + ParseError::StateAware(error) => { + log_config_rejected( + &mut logger, + rejection_reason_for(error), + UNKNOWN_BACKEND, + offending_field_from_message(&error.message), + "", + ); + } + } + match request_error_route(&error) { + RequestErrorRoute::Diagnostic => { + eprint!("Request error\n{}", logger.get_buffer()); + } + RequestErrorRoute::Envelope(e) => { + print_error_envelope(e); + eprint!("{}", logger.get_buffer()); + } + } + process::exit(1); + } + }; + + let mut request = request; + request.experimental_enabled = cli.experimental; + request.testing_features_enabled = cli.allow_testing_features; + request.dry_run = cli.dry_run; + + // ── Telemetry init ────────────────────────────────────────────── + let telemetry_active = request + .telemetry + .as_ref() + .map(|c| telemetry::init(c, &mut logger)) + .unwrap_or(false); + let requested_sandbox_kind = request + .telemetry + .as_ref() + .and_then(|config| config.requested_sandbox_kind); + + // Install a crash-telemetry panic hook once telemetry is active, chaining + // the previously-installed hook so the default stderr backtrace still + // prints (also satisfying the "always emit a diagnostic" contract for the + // panic case). The hook body is panic-free and emits no message text. + if telemetry_active { + telemetry::set_process_context_with_kind(&request.containment, requested_sandbox_kind); + telemetry::install_panic_hook(); + } + + // --audit injects permissiveLearningMode so denied operations are logged + // but allowed, and drives the WPR/ETW PLM trace pipeline below. This is the + // developer inner-loop flow. The capability names are reserved from direct + // config use, so --audit is the supported permissive entry point until the + // dedicated captureDenials mapping lands. + // Windows ProcessContainer only: reject every other resolved backend before + // mutating the policy or starting the PLM trace. + // + // Permissive mode is the effective mode when audit is requested, so remove + // deny-and-record before injecting it. Comparisons are case-insensitive + // because Windows derives capability SIDs case-insensitively. + #[cfg(target_os = "windows")] + let mut audit_context = None; + #[cfg(target_os = "windows")] + if cli.audit { + if let Err(message) = validate_audit_request(&request) { + log_config_rejected( + &mut logger, + RejectionReason::UnsupportedFieldForBackend, + request.containment.wire_name(), + "containment", + "", + ); + eprintln!("Error: {message}"); + telemetry::emit_early_exit_with_kind( + telemetry_active, + &request.containment, + requested_sandbox_kind, + telemetry::FailureReason::ConfigError, + ); + process::exit(1); + } + let injected = apply_permissive_learning_mode(&mut request.policy.capabilities); + let context = match audit::prepare_request(&mut request, config_file_path(&cli)) { + Ok(context) => context, + Err(message) => { + eprintln!("Error: {message}"); + telemetry::emit_early_exit_with_kind( + telemetry_active, + &request.containment, + requested_sandbox_kind, + telemetry::FailureReason::ConfigError, + ); + process::exit(1); + } + }; + audit_context = Some(context); + logger.log("WARNING: --audit enabled - AppContainer restrictions will NOT be enforced\n"); + if injected && cli.audit_verbose { + eprintln!( + "[mxc] permissiveLearningMode injected via --audit - AppContainer restrictions are NOT enforced" + ); + } + } + + // Final validation: a command line must come from somewhere. If neither + // the policy nor the CLI supplied one we cannot proceed. + if request.script_code.is_empty() { + log_config_rejected( + &mut logger, + RejectionReason::MissingCommand, + request.containment.wire_name(), + "process.commandLine", + "", + ); + eprintln!( + "Error: no command to run. Provide `process.commandLine` in the policy or pass the command as arguments after the config path." + ); + eprint!("{}", logger.get_buffer()); + telemetry::emit_early_exit_with_kind( + telemetry_active, + &request.containment, + requested_sandbox_kind, + telemetry::FailureReason::ConfigError, + ); + process::exit(1); + } + + // Inject learningModeLogging capability when diagnostic console is enabled. + let learning_mode_requested = { + #[cfg(target_os = "windows")] + { + DiagnosticConfig::force_learning_mode() + } + #[cfg(not(target_os = "windows"))] + { + false + } + }; + let learning_mode_injected = if learning_mode_requested + && !request.policy.capabilities.iter().any(|c| { + c.eq_ignore_ascii_case("learningModeLogging") + || c.eq_ignore_ascii_case("permissiveLearningMode") + }) { + request + .policy + .capabilities + .push("learningModeLogging".to_string()); + true + } else { + false + }; + + // Emit the diagnostic preamble after the request is available. + #[cfg(target_os = "windows")] + { + if diag_config.console_enabled { + // Log the preamble + let exe_path = std::env::current_exe() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "unknown".to_string()); + let parent_info = wxc_common::diagnostic::get_parent_process_info(); + let _ = writeln!( + logger, + "wxc-exec v{} (PID {})", + env!("CARGO_PKG_VERSION"), + std::process::id() + ); + let _ = writeln!(logger, "\tpath: {}", exe_path); + let _ = writeln!(logger, "\tparent: {}", parent_info); + + // Log if we're injecting Learning Mode + if learning_mode_injected { + let _ = writeln!( + logger, + "WARNING: injected 'learningModeLogging' capability via ForceLearningMode registry key" + ); + } + } + + // Log the decoded input only when a diagnostic sink is attached. + // Reuse the already-decoded value so one-shot input sources are not + // read twice, and redact secret-bearing fields before writing it. + if logger.has_diagnostic_sink() { + let _ = writeln!(logger, "SECTION: JSON Config (redacted)"); + let _ = writeln!( + logger, + "{}", + wxc_common::diagnostic::redact_raw_config_json(config_json.trim()) + ); + } + } + + let _ = writeln!(logger, "SECTION: Request simplified"); + log_request(&request, &mut logger); + + #[cfg(target_os = "windows")] + { + // Emit the full (redacted) request policy for diagnostics. Gated on + // an attached sink for the same reason as the raw-config block above: + // rendering the whole request is comparatively expensive and would + // otherwise run unconditionally only to be discarded. + if logger.has_diagnostic_sink() { + let _ = writeln!( + logger, + "SECTION: Full `ExecutionRequest` configuration (redacted)" + ); + let _ = writeln!( + logger, + "{}", + wxc_common::diagnostic::redacted_request_json(&request) + ); + } + } + + // Run script in the selected containment backend. Backend selection and + // runner construction — including the ProcessContainer BaseContainer / + // AppContainer (BFS / DACL) fallback tiers and every experimental backend — + // live in `mxc_engine::resolve_runner`, the single home for one-shot backend + // dispatch. The DACL guard it returns for the fallback tiers is parked into + // the global slot (see `dacl_cleanup_slot`) so the Ctrl-C handler can drop + // it on signal as well as the normal-exit path below; it is `None` when no + // DACL augmentation was required. Tier-selection warnings and the selected + // tier are logged to `logger` by the engine. + let resolved_runner = mxc_engine::resolve_runner(&request, &mut logger); + let mut runner: Box = match resolved_runner { + Ok(resolved) => { + #[cfg(target_os = "windows")] + if let Some(mgr) = resolved.dacl_manager { + park_dacl_for_cleanup(mgr); + } + resolved.runner + } + Err(e) => { + log_config_rejected( + &mut logger, + RejectionReason::RunnerUnavailable, + request.containment.wire_name(), + "containment", + "", + ); + eprintln!("error: {}", e.message); + eprint!("{}", logger.get_buffer()); + telemetry::emit_early_exit_with_kind( + telemetry_active, + &request.containment, + requested_sandbox_kind, + telemetry::FailureReason::InitError, + ); + process::exit(1); + } + }; + + #[cfg(target_os = "windows")] + let exe_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(std::path::Path::to_path_buf)) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + + let run_start = Instant::now(); + let mut response = runner.run(&request, &mut logger); + let run_elapsed = run_start.elapsed(); + let _ = writeln!(logger, "Runner completed in {}ms", run_elapsed.as_millis()); + + #[cfg(target_os = "windows")] + if let Some(context) = audit_context.as_ref() { + if !cli.dry_run { + match audit::finalize(&mut response, context, &exe_dir, cli.audit_verbose) { + Ok(cleanup_warnings) => { + for warning in cleanup_warnings { + let message = format!("[audit] warning: {warning}"); + let _ = writeln!(logger, "{message}"); + eprintln!("{message}"); + } + eprintln!("[audit] artifacts written to {}", context.log_dir.display()); + } + Err(error) => { + let message = format!("audit finalization failed: {error}"); + let _ = writeln!(logger, "[audit] {message}"); + eprintln!("error: {message}"); + response.exit_code = -1; + response.error_message = match response.error_message.is_empty() { + true => message, + false => format!("{}; {message}", response.error_message), + }; + } + } + } + } + + // Explicitly drop the runner before retrieving the parked DACL + // manager so any runner-internal resources holding child handles + // release first; then drop the manager so its `restore()` runs. + // (process::exit below skips destructors, so we must do this + // manually for prompt cleanup on the normal path. The Ctrl-C + // handler covers the abnormal path; recover_orphaned_state on the + // next startup covers everything else.) + // + // The parked-DACL machinery is Windows-only: `DaclManager` lives behind + // `#[cfg(target_os = "windows")]` in `wxc_common::filesystem_dacl`, so the + // whole extract/park/take/cleanup lifecycle is gated to match. + drop(runner); + #[cfg(target_os = "windows")] + drop(take_parked_dacl()); + + // Surface security warnings (e.g. permissiveLearningMode relaxing + // deny-by-default under --audit). The logger only records these rather than + // writing them itself, so that `mxc_engine` embedders don't get unannounced + // writes to a terminal they own. wxc-exec *does* own its terminal, so it + // opts in here. Messages carry their own banner; print them verbatim. + // Emitted before the dry-run branch below, which exits the process. + for warning in logger.warnings() { + eprintln!("{warning}"); + } + + #[cfg(target_os = "windows")] + if let Err(error) = mxc_engine::emit_verbose_telemetry( + telemetry_active, + &request.containment, + requested_sandbox_kind, + &response, + ) { + logger.log_diagnostic_line(&format!( + "telemetry: captureDenials verbose artifact was not emitted: {error}" + )); + } + + telemetry::emit_completion_with_kind( + telemetry_active, + &request.containment, + requested_sandbox_kind, + &response, + run_elapsed, + ); + + if cli.dry_run { + handle_dry_run_exit(&response, &mut logger); + } + + display_script_results(&response, &mut logger); + + // Close diagnostic pipe. + logger.close_diagnostics(); + + // Output was already relayed to the console by pipe threads. + // Only print captured output if present (e.g. from error paths). + if !response.standard_out.is_empty() { + print!("{}", response.standard_out); + } + if !response.standard_err.is_empty() { + eprint!("{}", response.standard_err); + } + if let Some(pointer) = response + .output_metadata + .as_ref() + .and_then(|metadata| metadata.capture_denials.as_ref()) + { + match serde_json::to_string(pointer) { + Ok(line) => eprintln!("{line}"), + Err(error) => eprintln!("failed to serialize captureDenials output pointer: {error}"), + } + } + + // Emit a structured JSON error envelope on stderr for SDK/caller consumption + // when the runner produced an error message (one-shot flows only). + // In PTY mode stderr is merged into the PTY output stream, so the envelope + // appears inline -- callers (e.g. copilot) can parse it from the output. + wxc_common::script_runner::emit_backend_error_envelope(&response); + + process::exit(response.exit_code); +} + +#[cfg(test)] +mod tests { + use super::*; + + use clap::{CommandFactory, Parser}; + use wxc_common::cmdline::{cmdline_from_argv_for_context, CommandLineContext}; + use wxc_common::config_parser::load_mxc_request_with_options; + use wxc_common::encoding::base64_encode; + use wxc_common::error::WxcError; + use wxc_common::logger::Mode; + use wxc_common::mxc_error::MxcErrorCode; + use wxc_common::state_aware_request::MxcRequest; + use wxc_common::telemetry::correlation_state::test_support::StoreDirGuard; + + const CLI_OVERRIDE_PROGRAM: &str = "cli-app.exe"; + const CLI_OVERRIDE_FLAG: &str = "--from-cli"; + const CLI_OVERRIDE_ARGV: &[&str] = &[ + "wxc-exec", + "policy.json", + "--", + CLI_OVERRIDE_PROGRAM, + CLI_OVERRIDE_FLAG, + ]; + + fn cli_override_command() -> String { + format!("{CLI_OVERRIDE_PROGRAM} {CLI_OVERRIDE_FLAG}") + } + + fn parse_cli(argv: &[&str]) -> Cli { + Cli::try_parse_from(argv) + .unwrap() + .normalize_named_config_command() + } + + fn encoded_policy(json: &str) -> String { + base64_encode(json.as_bytes()) + } + + fn test_logger() -> Logger { + Logger::new(Mode::Buffer) + } + + /// Every rejection reason must come from the error's own closed `code`, not + /// from matching its message text — the message is prose that can embed + /// paths and is not a stable vocabulary. + #[test] + fn rejection_reason_is_driven_by_the_error_code() { + let cases = [ + ( + MxcError::malformed_request("x"), + RejectionReason::SchemaViolation, + ), + ( + MxcError::malformed_id("x"), + RejectionReason::IdentityShapeInvalid, + ), + ( + MxcError::policy_validation("x"), + RejectionReason::UnsupportedFieldForBackend, + ), + ( + MxcError::unsupported_containment("x"), + RejectionReason::UnsupportedContainment, + ), + ( + MxcError::unsupported_phase("x"), + RejectionReason::UnsupportedPhase, + ), + ( + MxcError::backend_unavailable("x"), + RejectionReason::RunnerUnavailable, + ), + (MxcError::stale_id("x"), RejectionReason::RunnerUnavailable), + ( + MxcError::not_provisioned("x"), + RejectionReason::RunnerUnavailable, + ), + ( + MxcError::not_started("x"), + RejectionReason::RunnerUnavailable, + ), + ( + MxcError::already_started("x"), + RejectionReason::RunnerUnavailable, + ), + ( + MxcError::already_stopped("x"), + RejectionReason::RunnerUnavailable, + ), + ( + MxcError::backend_error("x"), + RejectionReason::RunnerUnavailable, + ), + ]; + for (error, expected) in cases { + assert_eq!( + rejection_reason_for(&error), + expected, + "code {:?} mapped to the wrong reason", + error.code + ); + } + } + + #[derive(Debug)] + struct ResolveError { + message: String, + envelope_routed: bool, + } + + impl ResolveError { + fn from_parse(e: ParseError) -> Self { + let envelope_routed = matches!(request_error_route(&e), RequestErrorRoute::Envelope(_)); + + let message = match e { + ParseError::Decode(err) + | ParseError::OneShot(err) + | ParseError::OneShotMalformed(err) => err.to_string(), + ParseError::StateAware(err) => err.to_string(), + }; + + Self { + message, + envelope_routed, + } + } + } + + #[test] + fn offending_field_is_extracted_without_error_text() { + assert_eq!( + offending_field_from_message( + "Configuration parse error: Invalid configuration at `process.timeout`: invalid number" + ), + "process.timeout" + ); + assert_eq!( + offending_field_from_message("Invalid JSON syntax: expected value at line 1 column 2"), + "" + ); + } + + /// The record must carry the bounded reason and the field *path* — never + /// the offending value, and never the rich error text. + #[test] + fn config_rejected_record_carries_no_free_form_text() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("audit.log"); + let mut logger = test_logger(); + logger.enable_file_sink(&path).expect("file sink"); + + log_config_rejected( + &mut logger, + RejectionReason::MissingCommand, + "processcontainer", + "process.commandLine", + "", + ); + drop(logger); + + let contents = std::fs::read_to_string(&path).expect("read log"); + assert!( + contents.contains(r#""reason":"missing_command""#), + "got: {contents}" + ); + assert!( + contents.contains(r#""offending_field":"process.commandLine""#), + "got: {contents}" + ); + // A one-shot run has no lifecycle phase, so the field is omitted rather + // than emitted as a meaningless empty string. + assert!(!contents.contains("\"phase\""), "got: {contents}"); + } + + #[test] + fn request_error_route_matches_the_output_conventions() { + let decode = ParseError::Decode(WxcError::ConfigParse("decode".to_string())); + assert!(matches!( + request_error_route(&decode), + RequestErrorRoute::Diagnostic + )); + + let oneshot = ParseError::OneShot(WxcError::ConfigParse("oneshot".to_string())); + assert!(matches!( + request_error_route(&oneshot), + RequestErrorRoute::Diagnostic + )); + + let stateaware = ParseError::StateAware(MxcError::malformed_request("stateaware")); + assert!(matches!( + request_error_route(&stateaware), + RequestErrorRoute::Envelope(_) + )); + } + + fn resolve_with_cli( + argv: &[&str], + policy_json: &str, + ) -> (Result, String) { + let cli = parse_cli(argv); + let mut logger = test_logger(); + let opts = LoadOptions { + is_base64: true, + cli_command: &cli.command, + }; + + let result = load_mxc_request_with_options(&encoded_policy(policy_json), &mut logger, opts) + .map(|r| match r { + MxcRequest::OneShot(q) => q, + MxcRequest::StateAware(p) => p.request, + }) + .map_err(ResolveError::from_parse); + (result, logger.get_buffer().to_string()) + } + + #[test] + fn audit_mode_replaces_deny_and_record_capability() { + let mut capabilities = vec![ + "internetClient".to_string(), + "LearningModeLogging".to_string(), + ]; + + assert!(apply_permissive_learning_mode(&mut capabilities)); + assert!(capabilities + .iter() + .any(|capability| capability == "permissiveLearningMode")); + assert!(!capabilities + .iter() + .any(|capability| capability.eq_ignore_ascii_case("learningModeLogging"))); + assert!(capabilities + .iter() + .any(|capability| capability == "internetClient")); + assert!(!apply_permissive_learning_mode(&mut capabilities)); + } + + #[test] + fn audit_mode_only_accepts_process_container() { + let mut request = ExecutionRequest { + containment: ContainmentBackend::ProcessContainer, + ..Default::default() + }; + assert!(validate_audit_request(&request).is_ok()); + + for containment in [ + ContainmentBackend::WindowsSandbox, + ContainmentBackend::Wslc, + ContainmentBackend::IsolationSession, + ContainmentBackend::MicroVm, + ] { + let containment_name = containment.wire_name(); + request.containment = containment; + let error = validate_audit_request(&request) + .expect_err("non-ProcessContainer backend must reject --audit"); + assert!(error.contains(containment_name)); + } + } + + #[test] + fn audit_mode_rejects_both_capture_denials_modes() { + use wxc_common::models::{CaptureDenialsConfig, CaptureDenialsMode}; + + for mode in [CaptureDenialsMode::Block, CaptureDenialsMode::Allow] { + let mut request = ExecutionRequest { + containment: ContainmentBackend::ProcessContainer, + ..Default::default() + }; + request.policy.capture_denials = Some(CaptureDenialsConfig { + mode, + output_path: None, + retain_etl: false, + }); + + let error = validate_audit_request(&request) + .expect_err("--audit and captureDenials must be mutually exclusive"); + assert_eq!(error, AUDIT_CAPTURE_DENIALS_CONFLICT_MSG); + assert!(error.contains(r#"mode: "allow""#)); + } + } + + #[test] + fn state_aware_dispatch_errors_use_only_auxiliary_diagnostic_sinks() { + let directory = tempfile::tempdir().unwrap(); + let log_path = directory.path().join("mxc.log"); + let mut logger = test_logger(); + logger.enable_file_sink(&log_path).unwrap(); + let error = MxcError::malformed_request( + "Invalid configuration at `experimental.wslc.start.portMappings[0].windowsPort`", + ); + + log_state_aware_dispatch_error(&mut logger, &error); + + assert!( + logger.get_buffer().is_empty(), + "the JSON envelope must retain primary output ownership" + ); + drop(logger); + let log = std::fs::read_to_string(log_path).unwrap(); + assert_eq!( + log.matches("experimental.wslc.start.portMappings[0].windowsPort") + .count(), + 1 + ); + } + + #[test] + fn config_rejection_reason_classifies_only_validation_errors() { + for code in [ + MxcErrorCode::MalformedRequest, + MxcErrorCode::UnsupportedContainment, + MxcErrorCode::UnsupportedPhase, + MxcErrorCode::MalformedId, + MxcErrorCode::PolicyValidation, + ] { + assert!( + config_rejection_reason_for(&MxcError::new(code, "validation")).is_some(), + "{code} should emit configuration rejection telemetry" + ); + } + for code in [ + MxcErrorCode::BackendUnavailable, + MxcErrorCode::StaleId, + MxcErrorCode::NotProvisioned, + MxcErrorCode::NotStarted, + MxcErrorCode::AlreadyStarted, + MxcErrorCode::AlreadyStopped, + MxcErrorCode::BackendError, + ] { + assert!( + config_rejection_reason_for(&MxcError::new(code, "runtime")).is_none(), + "{code} should remain a runtime error" + ); + } + } + + #[test] + fn cli_parses_flags_after_config_path_without_command_override() { + let cli = parse_cli(&["wxc-exec", "policy.json", "--experimental", "--debug"]); + + assert_eq!(cli.config_path.as_deref(), Some("policy.json")); + assert!(cli.experimental); + assert!(cli.debug); + assert!(cli.command.is_empty()); + } + + #[test] + fn cli_captures_command_after_separator() { + let cli = parse_cli(&["wxc-exec", "policy.json", "--", "python", "--version"]); + + assert_eq!(cli.config_path.as_deref(), Some("policy.json")); + assert_eq!( + cli.command, + vec!["python".to_string(), "--version".to_string()] + ); + assert_eq!( + cmdline_from_argv_for_context(&cli.command, CommandLineContext::WindowsCreateProcess) + .unwrap(), + "python --version" + ); + } + + #[test] + fn cli_captures_command_after_named_config() { + let cli = parse_cli(&[ + "wxc-exec", + "--config", + "policy.json", + "--", + "python", + "--version", + ]); + + assert_eq!(cli.config.as_deref(), Some("policy.json")); + assert_eq!(cli.config_path.as_deref(), None); + assert_eq!( + cli.command, + vec!["python".to_string(), "--version".to_string()] + ); + assert_eq!( + cmdline_from_argv_for_context(&cli.command, CommandLineContext::WindowsCreateProcess) + .unwrap(), + "python --version" + ); + } + + #[test] + fn cli_captures_command_after_named_config_separator() { + let cli = parse_cli(&[ + "wxc-exec", + "--config", + "policy.json", + "--experimental", + "--", + "python", + "--version", + ]); + + assert_eq!(cli.config.as_deref(), Some("policy.json")); + assert!(cli.experimental); + assert_eq!(cli.config_path.as_deref(), None); + assert_eq!( + cli.command, + vec!["python".to_string(), "--version".to_string()] + ); + assert_eq!( + cmdline_from_argv_for_context(&cli.command, CommandLineContext::WindowsCreateProcess) + .unwrap(), + "python --version" + ); + } + + #[test] + fn cli_captures_command_after_config_base64() { + let encoded = encoded_policy(r#"{ "process": { "commandLine": "policy" } }"#); + let cli = parse_cli(&[ + "wxc-exec", + "--config-base64", + &encoded, + "--", + "python", + "--version", + ]); + + assert_eq!(cli.config_base64.as_deref(), Some(encoded.as_str())); + assert_eq!(cli.config_path.as_deref(), None); + assert_eq!( + cli.command, + vec!["python".to_string(), "--version".to_string()] + ); + assert_eq!( + cmdline_from_argv_for_context(&cli.command, CommandLineContext::WindowsCreateProcess) + .unwrap(), + "python --version" + ); + } + + #[test] + fn cli_parses_dedicated_telemetry_consent_request() { + let cli = parse_cli(&[ + "wxc-exec", + "--telemetry-consent", + "request", + "--telemetry-consent-locale", + "en-US", + ]); + + assert_eq!( + cli.telemetry_consent, + Some(telemetry::consent_cli::ConsentAction::Request) + ); + assert_eq!(cli.telemetry_consent_locale.as_deref(), Some("en-US")); + } + + #[test] + fn cli_rejects_execution_config_with_telemetry_consent() { + let error = Cli::try_parse_from([ + "wxc-exec", + "--telemetry-consent", + "status", + "--config", + "policy.json", + ]) + .err() + .unwrap(); + + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn cli_rejects_audit_with_telemetry_consent() { + for audit_option in ["--audit", "--audit-verbose"] { + let error = + Cli::try_parse_from(["wxc-exec", "--telemetry-consent", "status", audit_option]) + .err() + .unwrap(); + + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + } + + #[test] + fn cli_rejects_consent_locale_without_action() { + let error = Cli::try_parse_from(["wxc-exec", "--telemetry-consent-locale", "en-US"]) + .err() + .unwrap(); + + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn cli_rejects_removed_telemetry_consent_status_flag() { + let error = Cli::try_parse_from(["wxc-exec", "--telemetry-consent-status"]) + .err() + .unwrap(); + + assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument); + } + + #[test] + fn cli_captures_hyphenated_command_after_separator() { + let cli = parse_cli(&[ + "wxc-exec", + "--config", + "policy.json", + "--", + "-command", + "value", + ]); + + assert_eq!(cli.config.as_deref(), Some("policy.json")); + assert_eq!(cli.config_path.as_deref(), None); + assert_eq!( + cli.command, + vec!["-command".to_string(), "value".to_string()] + ); + assert_eq!( + cmdline_from_argv_for_context(&cli.command, CommandLineContext::WindowsCreateProcess) + .unwrap(), + "-command value" + ); + } + + #[test] + fn help_text_documents_cli_command_override() { + let mut command = Cli::command(); + let help = command.render_long_help().to_string(); + + assert!(help.contains("Windows Container Executor")); + assert!(help.contains("COMMAND")); + assert!(help.contains("process.commandLine")); + assert!(help.contains("wxc-exec policy.json -- python --version")); + } + + #[test] + fn windows_sandbox_cli_command_uses_cmd_context() { + assert_eq!( + CommandLineContext::for_backend(&ContainmentBackend::WindowsSandbox), + CommandLineContext::WindowsCommandProcessor + ); + } + + #[test] + fn cli_command_overrides_policy_command_line_in_resolved_request() { + let policy = r#"{ + "process": { + "commandLine": "policy-app.exe --from-policy", + "cwd": "C:\\workspace" + }, + "filesystem": { + "readwritePaths": ["C:\\workspace"] + } + }"#; + + let (resolved_request, log) = resolve_with_cli(CLI_OVERRIDE_ARGV, policy); + + let request = resolved_request.unwrap(); + let expected_command = cli_override_command(); + + assert_eq!(request.script_code, expected_command); + assert_eq!(request.working_directory, "C:\\workspace"); + assert_eq!(request.policy.readwrite_paths, vec!["C:\\workspace"]); + assert!(log.contains(&format!( + "Overriding policy process.commandLine with CLI command: {expected_command}" + ))); + } + + #[test] + fn cli_command_fills_absent_policy_command_line_without_override_log() { + let policy = r#"{ + "process": { + "cwd": "C:\\workspace" + }, + "filesystem": { + "readwritePaths": ["C:\\workspace"] + } + }"#; + + let (resolved_request, log) = resolve_with_cli(CLI_OVERRIDE_ARGV, policy); + + let request = resolved_request.unwrap(); + + assert_eq!(request.script_code, cli_override_command()); + assert_eq!(request.working_directory, "C:\\workspace"); + assert_eq!(request.policy.readwrite_paths, vec!["C:\\workspace"]); + assert!( + !log.contains("Overriding policy process.commandLine"), + "no override log should be emitted when the policy command line is absent" + ); + } + + #[test] + fn policy_command_line_survives_without_cli_command() { + let argv = &["wxc-exec", "policy.json"]; + let policy = r#"{ + "process": { + "commandLine": "policy-app.exe --from-policy", + "cwd": "C:\\workspace" + } + }"#; + + let (resolved_request, log) = resolve_with_cli(argv, policy); + + let request = resolved_request.unwrap(); + + assert_eq!(request.script_code, "policy-app.exe --from-policy"); + assert!( + !log.contains("Overriding policy process.commandLine"), + "no override log should be emitted when no CLI command is supplied" + ); + } + + // The three quoting contexts are distinguished by a single argument + // containing `&`: CreateProcess leaves it bare, cmd.exe quotes it because + // `&` is a command separator, and a POSIX shell single-quotes it. These + // assert the value that reaches `script_code` after the whole pipeline, + // unlike the tests above that assert what `command_override_from_cli` + // renders in isolation. + const QUOTING_ARGV: &[&str] = &["wxc-exec", "policy.json", "--", "app.exe", "a&b"]; + + #[test] + fn cli_command_quoting_for_windows_create_process_in_resolved_request() { + let policy = r#"{ + "containment": "processcontainer", + "process": {} + }"#; + + let (resolved_request, _log) = resolve_with_cli(QUOTING_ARGV, policy); + + assert_eq!(resolved_request.unwrap().script_code, "app.exe a&b"); + } + + #[test] + fn cli_command_quoting_for_command_processor_in_resolved_request() { + let policy = r#"{ + "containment": "windows_sandbox", + "process": {} + }"#; + + let (resolved_request, _log) = resolve_with_cli(QUOTING_ARGV, policy); + + assert_eq!(resolved_request.unwrap().script_code, "app.exe \"a&b\""); + } + + #[test] + fn cli_command_quoting_for_posix_shell_in_resolved_request() { + let policy = r#"{ + "containment": "bubblewrap", + "process": {} + }"#; + + let (resolved_request, _log) = resolve_with_cli(QUOTING_ARGV, policy); + + assert_eq!(resolved_request.unwrap().script_code, "app.exe 'a&b'"); + } + + #[test] + fn non_object_process_section_is_rejected() { + let argv = &["wxc-exec", "policy.json", "--", "echo", "hi"]; + let policy = r#"{ + "process": 42 + }"#; + + let (result, _log) = resolve_with_cli(argv, policy); + + let err = result.unwrap_err(); + assert!( + !err.envelope_routed, + "one-shot failures use the stderr diagnostic convention" + ); + assert!( + err.message.contains("process"), + "error should name the offending section: {}", + err.message + ); + } + + #[test] + fn malformed_policy_json_is_rejected_before_splicing() { + let argv = &["wxc-exec", "policy.json", "--", "echo", "hi"]; + let policy = r#"{ "process": "#; + + let (result, _log) = resolve_with_cli(argv, policy); + + let err = result.unwrap_err(); + assert!( + !err.envelope_routed, + "an undiscriminated decode failure uses the stderr diagnostic convention" + ); + } + + #[test] + fn cli_command_matches_equivalent_policy_command_line() { + let cli = parse_cli(&[ + "wxc-exec", + "policy.json", + "--", + "cli-app.exe", + "--message", + "hello world", + ]); + let mut policy_logger = test_logger(); + let mut cli_logger = test_logger(); + let policy = r#"{ + "process": { + "commandLine": "cli-app.exe --message \"hello world\"", + "cwd": "C:\\workspace" + } + }"#; + let cli_policy = r#"{ + "process": { + "cwd": "C:\\workspace" + } + }"#; + + let policy_request = match load_mxc_request_with_options( + &encoded_policy(policy), + &mut policy_logger, + LoadOptions { + is_base64: true, + cli_command: &[], + }, + ) + .unwrap() + { + MxcRequest::OneShot(req) => req, + MxcRequest::StateAware(_) => panic!("expected one-shot"), + }; + let cli_request = match load_mxc_request_with_options( + &encoded_policy(cli_policy), + &mut cli_logger, + LoadOptions { + is_base64: true, + cli_command: &cli.command, + }, + ) + .unwrap() + { + MxcRequest::OneShot(req) => req, + MxcRequest::StateAware(_) => panic!("expected one-shot"), + }; + + assert_eq!(cli_request.script_code, policy_request.script_code); + assert_eq!( + cli_request.working_directory, + policy_request.working_directory + ); + } + + #[test] + fn isolation_session_cli_command_quotes_shell_metacharacters() { + let cli = parse_cli(&[ + "wxc-exec", + "policy.json", + "--", + "python", + "-c", + "if 5 < 10: print('hello')", + ]); + let command_override = cmdline_from_argv_for_context( + &cli.command, + CommandLineContext::WindowsCommandProcessor, + ) + .unwrap(); + + assert_eq!(command_override, "python -c \"if 5 < 10: print('hello')\""); + } + + #[test] + fn wslc_cli_command_uses_posix_shell_quoting() { + let cli = parse_cli(&["wxc-exec", "policy.json", "--", "echo", "safe&whoami"]); + let command_override = + cmdline_from_argv_for_context(&cli.command, CommandLineContext::PosixShell).unwrap(); + + assert_eq!(command_override, "echo 'safe&whoami'"); + } + + #[test] + fn state_aware_exec_cli_command_overrides_policy_command_line() { + let argv = &["wxc-exec", "policy.json", "--", "echo", "hi"]; + let policy = r#"{ + "phase": "exec", + "sandboxId": "iso:abcd1234", + "process": { + "commandLine": "echo from policy" + } + }"#; + + let (resolved_request, log) = resolve_with_cli(argv, policy); + + let request = resolved_request.unwrap(); + + assert_eq!(request.script_code, "echo hi"); + assert!( + log.contains("Overriding policy process.commandLine"), + "override log should be emitted when the policy command line is present" + ); + } + + #[test] + fn state_aware_exec_cli_command_fills_absent_policy_command_line_without_override_log() { + let argv = &["wxc-exec", "policy.json", "--", "echo", "hi"]; + let policy = r#"{ + "phase": "exec", + "sandboxId": "iso:abcd1234" + }"#; + + let (resolved_request, log) = resolve_with_cli(argv, policy); + + let request = resolved_request.unwrap(); + + assert_eq!(request.script_code, "echo hi"); + assert!( + !log.contains("Overriding policy process.commandLine"), + "no override log should be emitted when the policy command line is absent" + ); + } + + #[test] + fn state_aware_non_exec_cli_command_error_routes_to_envelope() { + let argv = &["wxc-exec", "policy.json", "--", "echo", "hi"]; + let policy = r#"{ + "phase": "start", + "sandboxId": "iso:abcd1234" + }"#; + + let (result, _log) = resolve_with_cli(argv, policy); + + let err = result.unwrap_err(); + assert!( + err.envelope_routed, + "state-aware failures emit an envelope on stdout" + ); + assert!(err + .message + .contains("only supported for state-aware exec requests")); + } + + #[test] + fn state_aware_exec_unconvertible_cli_command_routes_to_envelope() { + let argv = &[ + "wxc-exec", + "policy.json", + "--", + "app.exe", + "hidden\0payload", + ]; + let policy = r#"{ + "phase": "exec", + "sandboxId": "iso:abcd1234" + }"#; + + let (result, _log) = resolve_with_cli(argv, policy); + + let err = result.unwrap_err(); + assert!( + err.envelope_routed, + "state-aware command-rendering failures emit an envelope on stdout" + ); + assert!(err.message.contains("invalid CLI command override")); + } + + #[cfg(target_os = "windows")] + #[test] + fn cancel_then_cleanup_emits_before_cleanup() { + // Locks in the dacl_ctrl_handler ordering guarantee: cancellation + // telemetry must be emitted BEFORE the (up-to-5s, security-critical) + // DACL cleanup, so a cleanup that burns the OS shutdown budget can't + // swallow the telemetry. Drive the extracted helper with two spies that + // record their call order into a shared vec. + use std::sync::Mutex; + + let order: Mutex> = Mutex::new(Vec::new()); + cancel_then_cleanup( + || order.lock().unwrap().push("emit"), + || order.lock().unwrap().push("cleanup"), + ); + + assert_eq!(*order.lock().unwrap(), vec!["emit", "cleanup"]); + } + + #[cfg(target_os = "windows")] + #[test] + fn cancel_then_cleanup_runs_cleanup_even_if_emit_panics() { + // Locks in the cleanup-always guarantee: a panic in the cancellation + // emit (telemetry) must NEVER skip the security-critical DACL cleanup, + // and must not unwind across the extern "system" ctrl-handler boundary. + // Without the catch_unwind in cancel_then_cleanup, `cleanup` would never + // run and the panic would escape the handler (UB in production). + use std::sync::atomic::{AtomicBool, Ordering}; + + static CLEANED: AtomicBool = AtomicBool::new(false); + // Suppress the default panic hook's stderr backtrace for this + // intentional panic so the test output stays clean. + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + cancel_then_cleanup( + || panic!("telemetry emit blew up"), + || CLEANED.store(true, Ordering::SeqCst), + ); + std::panic::set_hook(prev); + + assert!( + CLEANED.load(Ordering::SeqCst), + "cleanup must run even when emit panics" + ); + } + + #[test] + fn pre_dispatch_vector_is_empty_when_telemetry_inactive() { + // Inactive telemetry does no RNG/clock work regardless of phase. + assert!(telemetry::correlation_state::pre_dispatch_vector(false, true, None).is_empty()); + assert!(telemetry::correlation_state::pre_dispatch_vector( + false, + false, + Some("wsb:12345678") + ) + .is_empty()); + } + + #[test] + fn provision_seeds_and_later_phase_recalls_the_persisted_root() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + let sandbox_id = "wsb:12345678"; + + // Active provision seeds a fresh valid vector. + let provisioned = telemetry::correlation_state::pre_dispatch_vector(true, true, None); + assert!(telemetry::correlation_vector::is_relayable(&provisioned)); + + // Persisting it (as `run_state_aware_main` does post-dispatch) lets a + // later phase recall it and spin a distinct child off the same base. + let outcome: Result = Ok(DispatchOutcome::Envelope( + serde_json::json!({ "result": { "sandboxId": sandbox_id } }), + )); + telemetry::correlation_state::on_provision_outcome(true, &provisioned, &outcome); + + let base_prefix = provisioned.split('.').next().unwrap(); + let spun = telemetry::correlation_state::pre_dispatch_vector(true, false, Some(sandbox_id)); + assert!(spun.starts_with(&format!("{base_prefix}.")), "{spun:?}"); + assert_ne!(spun, provisioned); + } + + #[test] + fn finalize_maps_envelope_to_stdout_exit_zero() { + let outcome: Result = + Ok(DispatchOutcome::Envelope(serde_json::json!({ "ok": true }))); + match finalize_state_aware_outcome(outcome) { + StateAwareExit::Envelope(json) => { + assert_eq!(json, r#"{"ok":true}"#); + } + other => panic!("expected Envelope, got {other:?}"), + } + } + + #[test] + fn finalize_maps_exec_completed_to_its_exit_code() { + let outcome: Result = + Ok(DispatchOutcome::ExecCompleted { exit_code: 7 }); + assert!(matches!( + finalize_state_aware_outcome(outcome), + StateAwareExit::ExecCode(7) + )); + } + + #[test] + fn finalize_maps_error_to_serialised_envelope() { + let outcome: Result = + Err(MxcError::malformed_request("boom".to_string())); + match finalize_state_aware_outcome(outcome) { + StateAwareExit::Error(json) => { + // Must be a parseable envelope carrying the error code so consumers + // that key off `error.code` still work. + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(parsed["error"]["code"].is_string(), "{json}"); + } + other => panic!("expected Error, got {other:?}"), + } + } +} diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs index 2b8395231..29bdcacb7 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs @@ -269,6 +269,7 @@ fn convert_experimental(value: contract::OneShotExperimental) -> wire::Experimen windows_sandbox: windows_sandbox.into_option().map(convert_windows_sandbox), wslc: wslc.into_option().map(convert_wslc), isolation_session: None, + lxc: None, seatbelt: None, } } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs index 55462e641..5b2b20e8e 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs @@ -64,6 +64,22 @@ const WSLC_REQUEST_JSON: &str = r#"{ } }"#; +const LXC_PROVISION_REQUEST_JSON: &str = r#"{ + "version": "0.9.0-alpha", + "containment": "lxc", + "process": { + "commandLine": "echo hello" + }, + "experimental": { + "lxc": { + "provision": { + "distribution": "alpine", + "release": "3.23" + } + } + } +}"#; + #[test] fn windows_sandbox_maps_expected_wire_fields() { let wire = adapt(WINDOWS_SANDBOX_REQUEST_JSON); @@ -89,6 +105,7 @@ fn windows_sandbox_maps_expected_wire_fields() { assert!(experimental.test.is_none()); assert!(experimental.wslc.is_none()); assert!(experimental.isolation_session.is_none()); + assert!(experimental.lxc.is_none()); assert!(experimental.seatbelt.is_none()); assert!(wire.telemetry.is_none()); } @@ -108,6 +125,7 @@ fn test_feature_and_telemetry_map_expected_wire_fields() { assert!(experimental.windows_sandbox.is_none()); assert!(experimental.wslc.is_none()); assert!(experimental.isolation_session.is_none()); + assert!(experimental.lxc.is_none()); assert!(experimental.seatbelt.is_none()); } @@ -154,10 +172,25 @@ fn wslc_maps_expected_wire_fields() { assert!(experimental.test.is_none()); assert!(experimental.windows_sandbox.is_none()); assert!(experimental.isolation_session.is_none()); + assert!(experimental.lxc.is_none()); assert!(experimental.seatbelt.is_none()); assert!(wire.telemetry.is_none()); } +#[test] +fn lxc_is_rejected_as_a_one_shot_experimental_section() { + // A one-shot run never executes the provision phase this section configures, + // so accepting it would take a container image the caller asked for and + // silently drop it. + let error = serde_json::from_str::(LXC_PROVISION_REQUEST_JSON) + .expect_err("experimental.lxc must not be accepted on a one-shot request"); + + assert!( + error.to_string().contains("lxc"), + "the rejection should name the offending field, got: {error}" + ); +} + #[test] fn windows_sandbox_matches_current_wire_deserialization() { assert_matches_current_wire_deserialization(WINDOWS_SANDBOX_REQUEST_JSON); diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs index 2ea035cc8..713cf95bf 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs @@ -51,6 +51,7 @@ fn convert_isolation_session_provision_experimental( isolation_session: isolation_session .into_option() .map(convert_state_aware_isolation_session), + lxc: None, seatbelt: None, } } @@ -81,6 +82,7 @@ fn convert_windows_sandbox_provision_experimental( windows_sandbox: None, wslc: None, isolation_session: None, + lxc: None, seatbelt: None, } } @@ -120,6 +122,7 @@ fn convert_wslc_provision_experimental( windows_sandbox: None, wslc: wslc.into_option().map(convert_state_aware_wslc), isolation_session: None, + lxc: None, seatbelt: None, } } @@ -131,6 +134,7 @@ fn convert_start_experimental(value: contract::StartExperimental) -> wire::Exper windows_sandbox: None, wslc: None, isolation_session: None, + lxc: None, seatbelt: None, } } @@ -142,6 +146,7 @@ fn convert_exec_experimental(value: contract::ExecExperimental) -> wire::Experim windows_sandbox: None, wslc: None, isolation_session: None, + lxc: None, seatbelt: None, } } @@ -153,6 +158,7 @@ fn convert_stop_experimental(value: contract::StopExperimental) -> wire::Experim windows_sandbox: None, wslc: None, isolation_session: None, + lxc: None, seatbelt: None, } } @@ -166,6 +172,7 @@ fn convert_deprovision_experimental( windows_sandbox: None, wslc: None, isolation_session: None, + lxc: None, seatbelt: None, } } diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 1895eb281..c84407e6a 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -561,7 +561,8 @@ const CURRENT_SCHEMA_VERSION: &str = "0.9.0-alpha"; /// experimental backend sections that don't match the selected /// `containment`. Add a new entry when promoting a backend to a top-level /// section or graduating one from experimental. -const KNOWN_EXPERIMENTAL_BACKENDS: &[&str] = &["windows_sandbox", "wslc", "isolation_session"]; +const KNOWN_EXPERIMENTAL_BACKENDS: &[&str] = + &["windows_sandbox", "wslc", "isolation_session", "lxc"]; /// Validate that the schema version (semver) is supported by this binary. /// Compares major.minor only — patch and pre-release labels are ignored. @@ -743,6 +744,9 @@ fn present_backend_sections(cfg: &wire::MxcConfig) -> Vec<&'static str> { if experimental.isolation_session.is_some() { push(ContainmentBackend::IsolationSession); } + if experimental.lxc.is_some() { + sections.push("experimental.lxc"); + } } sections } @@ -794,8 +798,8 @@ fn validate_experimental_backend_keys( }; let matching_key = containment - .and_then(|c| c.section_path()) - .and_then(|path| path.strip_prefix("experimental.")); + .map(|c| c.wire_name()) + .filter(|name| KNOWN_EXPERIMENTAL_BACKENDS.contains(name)); let present: Vec<&'static str> = KNOWN_EXPERIMENTAL_BACKENDS .iter() @@ -1340,30 +1344,6 @@ fn convert_wire_config( return Err(WxcError::ConfigParse(msg.to_string())); } - // LXC is the inverse of the guard above: it *does* have a - // privileged packet-filter layer, and that layer is the only thing that - // makes the proxy an exception rather than a suggestion. Under the - // default `Capabilities` mode `apply_firewall_rules` installs nothing, - // so the runner would inject HTTP(S)_PROXY while leaving direct egress - // wide open -- a config that reads as deny-all-except-proxy and - // enforces neither half. Reject it rather than auto-promoting, so the - // user's stated enforcement is never silently rewritten. - if containment == ContainmentBackend::Lxc - && policy.network_proxy.is_enabled() - && !matches!( - policy.network_enforcement_mode, - NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both - ) - { - let msg = "LXC: network.proxy requires network.enforcementMode='firewall' \ - or 'both'. Under the default 'capabilities' mode no iptables \ - rules are installed, so the proxy environment variables would be \ - injected while direct egress stayed unrestricted -- any client \ - that ignores HTTP_PROXY would bypass the proxy entirely."; - logger.log_line(msg); - return Err(WxcError::ConfigParse(msg.to_string())); - } - // A proxy URL may carry `user:pass@` userinfo, and neither LXC nor // Bubblewrap keeps that value out of process argv: LXC turns each env // entry into an `lxc-attach --set-var=KEY=VALUE` argument, and @@ -4361,11 +4341,10 @@ mod tests { #[test] fn proxy_accepted_with_lxc() { - // LXC requires a routable proxy host: localhost/127.0.0.1 is the - // container loopback and unreachable, so use network.proxy.url. - // A firewall mode is required, because that is what makes the proxy an - // exception to deny-all rather than an unenforced suggestion. - let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080"},"enforcementMode":"firewall"}}"#; + // A proxy needs a routable host: 127.0.0.1 is the container's own + // loopback. The proxy alone makes the policy require the firewall; no + // enforcementMode is needed. + let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4377,48 +4356,28 @@ mod tests { } #[test] - fn proxy_with_lxc_accepts_both_mode() { - // 'both' also installs the iptables rules, so it satisfies the guard. - let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080"},"enforcementMode":"both"}}"#; - let encoded = base64_encode(json.as_bytes()); - let mut logger = test_logger(); - - let req = load_request(&encoded, &mut logger, true).unwrap(); - assert!(req.policy.network_proxy.is_enabled()); - } - - #[test] - fn proxy_with_lxc_and_omitted_enforcement_mode_is_rejected() { - // enforcementMode defaults to 'capabilities', under which - // apply_firewall_rules installs nothing. Accepting this config would - // inject HTTP(S)_PROXY while leaving direct egress unrestricted, so - // anything ignoring the environment variables bypasses the proxy. - let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080"}}}"#; - let encoded = base64_encode(json.as_bytes()); - let mut logger = test_logger(); - - let err = load_request(&encoded, &mut logger, true).unwrap_err(); - assert!( - format!("{}", err).contains("network.proxy requires network.enforcementMode"), - "expected the LXC enforcement-mode rejection, got: {}", - err - ); - } - - #[test] - fn proxy_with_lxc_and_explicit_capabilities_mode_is_rejected() { - // Stating 'capabilities' explicitly is the same fail-open as omitting - // it, so it must be rejected identically rather than read as consent. - let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080"},"enforcementMode":"capabilities"}}"#; - let encoded = base64_encode(json.as_bytes()); - let mut logger = test_logger(); + fn proxy_with_lxc_is_accepted_whatever_the_enforcement_mode() { + for mode in [ + r#""enforcementMode":"capabilities","#, + r#""enforcementMode":"firewall","#, + r#""enforcementMode":"both","#, + "", + ] { + let json = format!( + r#"{{"process":{{"commandLine":"x"}},"containment":"lxc","network":{{{}"proxy":{{"url":"http://proxy.example.com:8080"}}}}}}"#, + mode + ); + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); - let err = load_request(&encoded, &mut logger, true).unwrap_err(); - assert!( - format!("{}", err).contains("network.proxy requires network.enforcementMode"), - "expected the LXC enforcement-mode rejection, got: {}", - err - ); + let req = load_request(&encoded, &mut logger, true).unwrap_or_else(|err| { + panic!("an LXC proxy must be accepted (mode fragment {mode:?}), got: {err}") + }); + assert!( + req.policy.network_proxy.is_enabled(), + "mode fragment {mode:?}: the proxy must be enabled" + ); + } } // The credential guard runs after `convert_wire_proxy`, so a @@ -7018,10 +6977,56 @@ mod tests { ); } + #[test] + fn state_aware_lxc_experimental_backend_key_is_accepted() { + let json = r#"{ + "phase": "provision", + "containment": "lxc", + "experimental": { + "lxc": {"provision": {"distribution": "alpine", "release": "3.20"}} + } + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let req = load_mxc_request(&encoded, &mut logger, true) + .expect("state-aware lxc config should parse"); + match req { + MxcRequest::StateAware(p) => { + assert_eq!(p.phase, Phase::Provision); + assert_eq!(p.containment, Some(ContainmentBackend::Lxc)); + assert!(p + .experimental_raw + .as_ref() + .and_then(|v| v.get("lxc")) + .is_some()); + } + other => panic!("expected state-aware request, got {other:?}"), + } + } + + #[test] + fn one_shot_foreign_experimental_wslc_rejected() { + assert_multi_backend_rejected( + "process", + r#""experimental": {"wslc": {"image": "alpine:latest"}}"#, + "experimental.wslc", + ); + } + + // A one-shot request naming `experimental.lxc` would lose the caller's + // distribution and release in the conversion. + #[test] + fn one_shot_foreign_experimental_lxc_rejected() { + assert_multi_backend_rejected( + "process", + r#""experimental": {"lxc": {"provision": {"distribution": "alpine", "release": "3.20"}}}"#, + "experimental.lxc", + ); + } + // ---- Abstract-intent coverage ---- // Backend sections paired with `containment: "process"` / "vm" must be // accepted iff the intent resolves to the owning backend on this OS. - #[cfg(target_os = "windows")] #[test] fn abstract_process_with_process_container_accepted_on_windows() { diff --git a/src/core/wxc_common/src/config_rejection.rs b/src/core/wxc_common/src/config_rejection.rs new file mode 100644 index 000000000..a4a229211 --- /dev/null +++ b/src/core/wxc_common/src/config_rejection.rs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Shared config-rejection reporting for the executors. +//! +//! Every executor that refuses a configuration reports the refusal the same +//! way: one telemetry event and one audit record, keyed on the same bounded +//! reason code. Keeping the mapping and the emit here is what stops `wxc` and +//! `lxc` from naming the same rejection differently on the same dashboard. + +use crate::audit::{process_correlation_id, AuditEvent, AuditEventName, RejectionReason}; +use crate::logger::Logger; +use crate::mxc_error::{MxcError, MxcErrorCode}; + +/// Report a refused configuration to telemetry and to the audit sink, so the +/// same rejection is machine-readable without parsing prose. +/// +/// Unlike the ETW path this replaces, there is no initialisation-ordering +/// problem: `Logger` is constructed from CLI flags before any config is read, +/// so *every* rejection site — including "the input was not JSON at all" — can +/// reach it. +pub fn log_config_rejected( + logger: &mut Logger, + reason: RejectionReason, + backend: &str, + offending_field: &str, + phase: &str, +) { + let correlation_id = process_correlation_id(); + crate::telemetry::log_config_rejected( + correlation_id, + backend, + reason.as_str(), + offending_field, + phase, + ); + let record = AuditEvent::new(AuditEventName::ConfigRejected) + // No sandbox exists yet — the config was refused — so M-ETW-7's + // "correlation id (or identity if assigned)" resolves to the + // correlation id here. It groups every rejection from one invocation + // on a sink that concurrent sandboxes share. + .str("correlation_id", correlation_id) + .str("backend", backend) + .str("reason", reason.as_str()) + .str_opt("offending_field", offending_field) + .str_opt("phase", phase); + logger.log_audit_event(&record); +} + +/// Extract the bounded field path already rendered by the config parser. +/// +/// Parse failures intentionally keep their rich human-readable message for +/// stderr or the state-aware response envelope. The audit event only needs the +/// path between the parser's backticks, never the surrounding error text. +pub fn offending_field_from_message(message: &str) -> &str { + const PREFIX: &str = "Invalid configuration at `"; + let Some(start) = message.find(PREFIX) else { + return ""; + }; + let field = &message[start + PREFIX.len()..]; + field.split('`').next().unwrap_or("") +} + +/// Map an [`MxcError`] to its bounded [`RejectionReason`]. +/// +/// Driven by the error's own `code`, which is already an exhaustive closed set — +/// no message-text matching. +pub fn config_rejection_reason_for(error: &MxcError) -> Option { + match error.code { + MxcErrorCode::MalformedRequest => Some(RejectionReason::SchemaViolation), + MxcErrorCode::MalformedId => Some(RejectionReason::IdentityShapeInvalid), + MxcErrorCode::PolicyValidation => Some(RejectionReason::UnsupportedFieldForBackend), + MxcErrorCode::UnsupportedContainment => Some(RejectionReason::UnsupportedContainment), + MxcErrorCode::UnsupportedPhase => Some(RejectionReason::UnsupportedPhase), + MxcErrorCode::BackendUnavailable + | MxcErrorCode::StaleId + | MxcErrorCode::NotProvisioned + | MxcErrorCode::NotStarted + | MxcErrorCode::AlreadyStarted + | MxcErrorCode::AlreadyStopped + | MxcErrorCode::BackendError => None, + } +} + +/// Report a dispatch failure that was a configuration refusal, and do nothing +/// for a failure that was not one. +/// +/// Both executors call this on the same terminal path, so a refusal reaches the +/// audit sink whether the lifecycle ran on Windows or Linux. +pub fn log_config_rejection_for_outcome( + logger: &mut Logger, + outcome: &Result, + backend: &str, + phase: &str, +) { + let Err(error) = outcome else { + return; + }; + let Some(reason) = config_rejection_reason_for(error) else { + return; + }; + let offending_field = offending_field_from_message(error.message.as_str()); + log_config_rejected(logger, reason, backend, offending_field, phase); +} diff --git a/src/core/wxc_common/src/exec_stream.rs b/src/core/wxc_common/src/exec_stream.rs index 4dcabf895..233bbf542 100644 --- a/src/core/wxc_common/src/exec_stream.rs +++ b/src/core/wxc_common/src/exec_stream.rs @@ -665,9 +665,10 @@ fn wrap_read(handle: PipeHandle) -> Option> { /// [`dup_fd_to_file`] alone would: `InterruptibleReader` sets `O_NONBLOCK`, and /// that is a property of the open file description, which every `dup` shares. /// The backend's fd therefore becomes non-blocking too. Harmless while no -/// state-aware backend is non-Windows — all three are Windows — but a Unix one -/// that later reads or re-hands its own fd would see `EAGAIN` where it expected -/// a blocking read. +/// non-Windows state-aware backend hands back streaming exec — the current +/// Linux backend (Lxc) relays its own stdio rather than returning streams, so +/// it never reaches this path — but a Unix backend that later reads or +/// re-hands its own fd would see `EAGAIN` where it expected a blocking read. #[cfg(not(target_os = "windows"))] fn wrap_cancellable_read(handle: PipeHandle) -> Option { let file = dup_fd_to_file(handle)?; diff --git a/src/core/wxc_common/src/lib.rs b/src/core/wxc_common/src/lib.rs index f8cc247c4..51a87562b 100644 --- a/src/core/wxc_common/src/lib.rs +++ b/src/core/wxc_common/src/lib.rs @@ -7,6 +7,7 @@ pub mod audit; pub mod cmdline; mod config_deserialize; pub mod config_parser; +pub mod config_rejection; pub mod encoding; pub mod error; pub mod exec_stream; diff --git a/src/core/wxc_common/src/logger.rs b/src/core/wxc_common/src/logger.rs index f482b0941..44be4acff 100644 --- a/src/core/wxc_common/src/logger.rs +++ b/src/core/wxc_common/src/logger.rs @@ -550,6 +550,19 @@ impl Logger { &self.buffer } + /// Switch to console logging, handing back whatever was buffered first. + /// + /// The mode has to be chosen before the request is parsed, but only the + /// parsed request says whether stdout is a free-form debug stream (one-shot) + /// or a strict JSON channel (state-aware). Starting buffered and promoting + /// once the answer is known keeps a debug run's diagnostics off stdout until + /// it is known to be safe to put them there; the returned buffer is what the + /// caller must emit so nothing logged before the switch is lost. + pub fn promote_to_console(&mut self) -> String { + self.mode = Mode::Console; + std::mem::take(&mut self.buffer) + } + // ----------------------------------------------------------------------- // Diagnostic sink internals // ----------------------------------------------------------------------- @@ -639,6 +652,24 @@ mod tests { use super::*; use crate::audit::{AuditEvent, AuditEventName}; + #[test] + fn promotion_to_console_hands_back_everything_buffered_first() { + // The mode is chosen before the request is parsed, so a debug run + // buffers until the phase is known. Whatever accumulated in the + // meantime is the caller's to emit -- dropping it would lose the + // parser's warnings, and leaving it would print them twice. + let mut logger = Logger::new(Mode::Buffer); + logger.log_line("a parser warning"); + + let carried = logger.promote_to_console(); + + assert_eq!(carried, "a parser warning\n"); + assert!( + logger.get_buffer().is_empty(), + "the buffer must be emptied so the switch cannot duplicate output" + ); + } + #[test] fn security_warnings_are_retained_outside_the_debug_buffer() { let mut logger = Logger::new(Mode::Buffer); diff --git a/src/core/wxc_common/src/state_aware_dispatch.rs b/src/core/wxc_common/src/state_aware_dispatch.rs index 4f49eef45..eed5c5b3b 100644 --- a/src/core/wxc_common/src/state_aware_dispatch.rs +++ b/src/core/wxc_common/src/state_aware_dispatch.rs @@ -186,6 +186,7 @@ pub fn resolve_backend(parsed: &ParsedStateAwareRequest) -> Result Result { match prefix { "iso" => Ok(ContainmentBackend::IsolationSession), + "lxc" => Ok(ContainmentBackend::Lxc), "wsb" => Ok(ContainmentBackend::WindowsSandbox), "wslc" => Ok(ContainmentBackend::Wslc), // Future state-aware backends extend this list. @@ -1098,6 +1099,19 @@ mod tests { ); } + #[test] + fn resolve_backend_for_lxc_prefix_returns_lxc() { + let p = ParsedStateAwareRequest { + request: ExecutionRequest::default(), + phase: Phase::Start, + containment: None, + sandbox_id: Some("lxc:mxc-abcd1234".into()), + experimental_raw: None, + source_text: None, + }; + assert_eq!(resolve_backend(&p).unwrap(), ContainmentBackend::Lxc); + } + #[test] fn resolve_backend_for_wsb_prefix_returns_windows_sandbox() { let p = ParsedStateAwareRequest { diff --git a/src/core/wxc_common/src/unix_proxy_coordinator.rs b/src/core/wxc_common/src/unix_proxy_coordinator.rs index bf840db49..ef368c415 100644 --- a/src/core/wxc_common/src/unix_proxy_coordinator.rs +++ b/src/core/wxc_common/src/unix_proxy_coordinator.rs @@ -368,8 +368,8 @@ impl UnixProxyCoordinator { impl Drop for UnixProxyCoordinator { /// Silent best-effort cleanup if the coordinator is still active at /// drop time. **Never** writes to stderr or `Logger` because the drop - /// may run during panic unwinding and we must not corrupt the JSON - /// envelope on `lxc-exec`'s stderr. + /// may run during panic unwinding and we must not corrupt `lxc-exec`'s + /// JSON envelope. fn drop(&mut self) { if let Some(mut tp) = self.test_proxy.take() { let pid = tp.child.id(); diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index ea0ffa789..facb7d0b5 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -577,6 +577,8 @@ pub struct Experimental { pub wslc: Option, /// IsolationSession backend config (Windows). pub isolation_session: Option, + /// LXC backend config (Linux). + pub lxc: Option, /// Seatbelt backend config (pre-promotion alias). #[serde(alias = "macos_sandbox")] pub seatbelt: Option, @@ -726,6 +728,28 @@ pub struct IsolationSessionProvisionPhase { pub app_id: Option, } +/// State-aware LXC configuration. Only provision takes a config payload. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct LxcExperimental { + /// State-aware provision-phase configuration. + pub provision: Option, +} + +/// Provision-phase LXC configuration, separate from the stable top-level `lxc` +/// section because this surface is experimental. Filesystem mounts and network +/// policy come from the top-level `filesystem` and `network` sections. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct LxcProvisionPhase { + /// Distribution image (e.g. `alpine`). + pub distribution: Option, + /// Distribution release (e.g. `3.23`). + pub release: Option, +} + /// JSON Schema generation from the wire model, gated behind `schema-gen` so /// production builds don't carry `schemars`. The single public entry point is /// re-exported below as `generate_config_schema_json`. diff --git a/src/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs index 9f48ed826..8f2851c07 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -111,7 +111,7 @@ impl MxcStateAwareResult { /// same reason. /// /// `experimental` is non-zero to opt in to the experimental backends -/// (WindowsSandbox, IsolationSession, WSLc); with zero they are refused with +/// (WindowsSandbox, IsolationSession, WSLc, Lxc); with zero they are refused with /// `backend_unavailable` before any work is done. /// /// # Safety diff --git a/tests/configs/lxc_capabilities_no_posture_control.json b/tests/configs/lxc_capabilities_no_posture_control.json new file mode 100644 index 000000000..d72eed4b9 --- /dev/null +++ b/tests/configs/lxc_capabilities_no_posture_control.json @@ -0,0 +1,15 @@ +{ + "version": "0.7.0-alpha", + "containerId": "CLI-LXC-Capabilities-No-Posture-Control", + "containment": "lxc", + "process": { + "commandLine": "echo MXC_WORKLOAD_RAN" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + } +} diff --git a/tests/configs/lxc_network_invalid_cidr.json b/tests/configs/lxc_network_invalid_cidr.json index 9e985f495..42b5f792c 100644 --- a/tests/configs/lxc_network_invalid_cidr.json +++ b/tests/configs/lxc_network_invalid_cidr.json @@ -3,7 +3,7 @@ "containerId": "CLI-LXC-Network-Invalid-CIDR", "containment": "lxc", "process": { - "commandLine": "true" + "commandLine": "echo MXC_WORKLOAD_RAN" }, "lifecycle": { "destroyOnExit": true diff --git a/tests/configs/lxc_refuse_malformed_env.json b/tests/configs/lxc_refuse_malformed_env.json new file mode 100644 index 000000000..ea3d869b3 --- /dev/null +++ b/tests/configs/lxc_refuse_malformed_env.json @@ -0,0 +1,16 @@ +{ + "version": "0.7.0-alpha", + "containerId": "CLI-LXC-Refuse-Malformed-Env", + "containment": "lxc", + "process": { + "commandLine": "echo MXC_WORKLOAD_RAN", + "env": ["MXC_TEST_OK=fine", "NOT_AN_ASSIGNMENT"] + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + } +} diff --git a/tests/configs/lxc_refuse_proxy_with_allowed_hosts.json b/tests/configs/lxc_refuse_proxy_with_allowed_hosts.json new file mode 100644 index 000000000..eb80b54bd --- /dev/null +++ b/tests/configs/lxc_refuse_proxy_with_allowed_hosts.json @@ -0,0 +1,21 @@ +{ + "version": "0.7.0-alpha", + "containerId": "CLI-LXC-Refuse-Proxy-With-Allowed-Hosts", + "containment": "lxc", + "process": { + "commandLine": "echo MXC_WORKLOAD_RAN" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "proxy": { "url": "http://10.0.3.1:3128" }, + "allowedHosts": ["93.184.216.34"] + } +} diff --git a/tests/configs/lxc_refuse_unresolvable_allowed_host.json b/tests/configs/lxc_refuse_unresolvable_allowed_host.json new file mode 100644 index 000000000..dd7862463 --- /dev/null +++ b/tests/configs/lxc_refuse_unresolvable_allowed_host.json @@ -0,0 +1,20 @@ +{ + "version": "0.7.0-alpha", + "containerId": "CLI-LXC-Refuse-Unresolvable-Allowed-Host", + "containment": "lxc", + "process": { + "commandLine": "echo MXC_WORKLOAD_RAN" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "allowedHosts": ["no-such-host.invalid"] + } +} diff --git a/tests/configs/lxc_state_aware_provision.json b/tests/configs/lxc_state_aware_provision.json new file mode 100644 index 000000000..825ad167d --- /dev/null +++ b/tests/configs/lxc_state_aware_provision.json @@ -0,0 +1,10 @@ +{ + "version": "0.8.0-alpha", + "phase": "provision", + "containment": "lxc", + "experimental": { + "lxc": { + "provision": { "distribution": "alpine", "release": "3.23" } + } + } +} diff --git a/tests/configs/lxc_state_aware_provision_conflicting_backend_rejected.json b/tests/configs/lxc_state_aware_provision_conflicting_backend_rejected.json new file mode 100644 index 000000000..08a47ea9d --- /dev/null +++ b/tests/configs/lxc_state_aware_provision_conflicting_backend_rejected.json @@ -0,0 +1,13 @@ +{ + "version": "0.8.0-alpha", + "phase": "provision", + "containment": "lxc", + "experimental": { + "lxc": { + "provision": { "distribution": "alpine", "release": "3.23" } + }, + "wslc": { + "provision": { "image": "ubuntu-24.04" } + } + } +} diff --git a/tests/configs/lxc_state_aware_provision_missing_fields_rejected.json b/tests/configs/lxc_state_aware_provision_missing_fields_rejected.json new file mode 100644 index 000000000..c6792d9cc --- /dev/null +++ b/tests/configs/lxc_state_aware_provision_missing_fields_rejected.json @@ -0,0 +1,10 @@ +{ + "version": "0.8.0-alpha", + "phase": "provision", + "containment": "lxc", + "experimental": { + "lxc": { + "provision": { "distribution": "alpine" } + } + } +} diff --git a/tests/configs/lxc_state_aware_start_allow_local_network.json b/tests/configs/lxc_state_aware_start_allow_local_network.json new file mode 100644 index 000000000..aedd51ae6 --- /dev/null +++ b/tests/configs/lxc_state_aware_start_allow_local_network.json @@ -0,0 +1,9 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "allowLocalNetwork": true, + "enforcementMode": "firewall" + } +} diff --git a/tests/configs/lxc_state_aware_start_allowed_hosts_firewall.json b/tests/configs/lxc_state_aware_start_allowed_hosts_firewall.json new file mode 100644 index 000000000..33d321dbd --- /dev/null +++ b/tests/configs/lxc_state_aware_start_allowed_hosts_firewall.json @@ -0,0 +1,9 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "allowedHosts": ["example.com"], + "enforcementMode": "firewall" + } +} diff --git a/tests/configs/lxc_state_aware_start_blocked_hosts_capabilities.json b/tests/configs/lxc_state_aware_start_blocked_hosts_capabilities.json new file mode 100644 index 000000000..39c39fa79 --- /dev/null +++ b/tests/configs/lxc_state_aware_start_blocked_hosts_capabilities.json @@ -0,0 +1,8 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "blockedHosts": ["evil.example.com"] + } +} diff --git a/tests/configs/lxc_state_aware_start_default_allow_capabilities.json b/tests/configs/lxc_state_aware_start_default_allow_capabilities.json new file mode 100644 index 000000000..5d9375757 --- /dev/null +++ b/tests/configs/lxc_state_aware_start_default_allow_capabilities.json @@ -0,0 +1,8 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "defaultPolicy": "allow" + } +} diff --git a/tests/configs/lxc_state_aware_start_default_block_both.json b/tests/configs/lxc_state_aware_start_default_block_both.json new file mode 100644 index 000000000..edb8008a3 --- /dev/null +++ b/tests/configs/lxc_state_aware_start_default_block_both.json @@ -0,0 +1,9 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "defaultPolicy": "block", + "enforcementMode": "both" + } +} diff --git a/tests/configs/lxc_state_aware_start_default_block_capabilities.json b/tests/configs/lxc_state_aware_start_default_block_capabilities.json new file mode 100644 index 000000000..c917d6188 --- /dev/null +++ b/tests/configs/lxc_state_aware_start_default_block_capabilities.json @@ -0,0 +1,8 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "defaultPolicy": "block" + } +} diff --git a/tests/configs/lxc_state_aware_start_default_block_firewall.json b/tests/configs/lxc_state_aware_start_default_block_firewall.json new file mode 100644 index 000000000..aaf05d23f --- /dev/null +++ b/tests/configs/lxc_state_aware_start_default_block_firewall.json @@ -0,0 +1,9 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall" + } +} diff --git a/tests/configs/lxc_state_aware_start_empty_allowed_hosts.json b/tests/configs/lxc_state_aware_start_empty_allowed_hosts.json new file mode 100644 index 000000000..9d730b7b6 --- /dev/null +++ b/tests/configs/lxc_state_aware_start_empty_allowed_hosts.json @@ -0,0 +1,8 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "allowedHosts": [] + } +} diff --git a/tests/configs/lxc_state_aware_start_filesystem_at_provision_rejected.json b/tests/configs/lxc_state_aware_start_filesystem_at_provision_rejected.json new file mode 100644 index 000000000..fd33f0be2 --- /dev/null +++ b/tests/configs/lxc_state_aware_start_filesystem_at_provision_rejected.json @@ -0,0 +1,18 @@ +{ + "version": "0.8.0-alpha", + "phase": "provision", + "containment": "lxc", + "filesystem": { + "readonlyPaths": [ + "/tmp/mxc_lxc_sa_ro" + ] + }, + "experimental": { + "lxc": { + "provision": { + "distribution": "alpine", + "release": "3.23" + } + } + } +} diff --git a/tests/configs/lxc_state_aware_start_filesystem_paths.json b/tests/configs/lxc_state_aware_start_filesystem_paths.json new file mode 100644 index 000000000..94005e4c9 --- /dev/null +++ b/tests/configs/lxc_state_aware_start_filesystem_paths.json @@ -0,0 +1,9 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "filesystem": { + "readonlyPaths": ["/mxc-e2e-ro"], + "deniedPaths": ["/mxc-e2e-denied"] + } +} diff --git a/tests/configs/lxc_state_aware_start_hostloopback_allow_rejected.json b/tests/configs/lxc_state_aware_start_hostloopback_allow_rejected.json new file mode 100644 index 000000000..c90e6a16b --- /dev/null +++ b/tests/configs/lxc_state_aware_start_hostloopback_allow_rejected.json @@ -0,0 +1,14 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "egress": { + "default": "allow" + }, + "ingress": { + "default": "deny", + "hostLoopback": "allow" + } + } +} diff --git a/tests/configs/lxc_state_aware_start_hostloopback_deny.json b/tests/configs/lxc_state_aware_start_hostloopback_deny.json new file mode 100644 index 000000000..c0398ff50 --- /dev/null +++ b/tests/configs/lxc_state_aware_start_hostloopback_deny.json @@ -0,0 +1,14 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "egress": { + "default": "allow" + }, + "ingress": { + "default": "deny", + "hostLoopback": "deny" + } + } +} diff --git a/tests/configs/lxc_state_aware_start_network_at_provision_rejected.json b/tests/configs/lxc_state_aware_start_network_at_provision_rejected.json new file mode 100644 index 000000000..5463acbbf --- /dev/null +++ b/tests/configs/lxc_state_aware_start_network_at_provision_rejected.json @@ -0,0 +1,16 @@ +{ + "version": "0.8.0-alpha", + "phase": "provision", + "containment": "lxc", + "network": { + "defaultPolicy": "block" + }, + "experimental": { + "lxc": { + "provision": { + "distribution": "alpine", + "release": "3.23" + } + } + } +} diff --git a/tests/configs/lxc_state_aware_start_no_network.json b/tests/configs/lxc_state_aware_start_no_network.json new file mode 100644 index 000000000..b33865e2c --- /dev/null +++ b/tests/configs/lxc_state_aware_start_no_network.json @@ -0,0 +1,5 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__" +} diff --git a/tests/configs/lxc_state_aware_start_nonempty_allowed_hosts_capabilities.json b/tests/configs/lxc_state_aware_start_nonempty_allowed_hosts_capabilities.json new file mode 100644 index 000000000..40e457d0a --- /dev/null +++ b/tests/configs/lxc_state_aware_start_nonempty_allowed_hosts_capabilities.json @@ -0,0 +1,8 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "allowedHosts": ["example.com"] + } +} diff --git a/tests/configs/lxc_state_aware_start_proxy.json b/tests/configs/lxc_state_aware_start_proxy.json new file mode 100644 index 000000000..8d1596b87 --- /dev/null +++ b/tests/configs/lxc_state_aware_start_proxy.json @@ -0,0 +1,8 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "proxy": { "builtinTestServer": true } + } +} \ No newline at end of file diff --git a/tests/configs/lxc_state_aware_start_proxy_external.json b/tests/configs/lxc_state_aware_start_proxy_external.json new file mode 100644 index 000000000..ddf99d786 --- /dev/null +++ b/tests/configs/lxc_state_aware_start_proxy_external.json @@ -0,0 +1,8 @@ +{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "__SANDBOX_ID__", + "network": { + "proxy": { "url": "http://127.0.0.1:3128" } + } +} \ No newline at end of file diff --git a/tests/scripts/run_lxc_all_tests.sh b/tests/scripts/run_lxc_all_tests.sh index 01278669a..6d087869d 100644 --- a/tests/scripts/run_lxc_all_tests.sh +++ b/tests/scripts/run_lxc_all_tests.sh @@ -117,6 +117,7 @@ disabled_test "LXC Network" \ "needs container DNS + outbound HTTPS to api.github.com; the runner does not reliably provide either (wget: bad address). Fix the DNS dependency and re-enable." run_test "LXC Network IPv6+CIDR" "$SCRIPT_DIR/run_lxc_network_ipv6_cidr_test.sh" run_test "LXC Network Invalid CIDR" "$SCRIPT_DIR/run_lxc_network_invalid_cidr_test.sh" +run_test "LXC Config Refusal" "$SCRIPT_DIR/run_lxc_config_refusal_test.sh" run_test "LXC Network Dual-Stack Hostname" "$SCRIPT_DIR/run_lxc_network_dualstack_test.sh" run_test "LXC Network CIDR Boundary" "$SCRIPT_DIR/run_lxc_network_cidr_boundary_test.sh" run_test "LXC Network Enforcement" "$SCRIPT_DIR/run_lxc_network_enforcement_test.sh" @@ -132,6 +133,14 @@ run_test "LXC Network Bridge Fail-Closed" "$SCRIPT_DIR/run_lxc_network_bridge_fa run_test "LXC Inbound Default-Deny" "$SCRIPT_DIR/run_lxc_inbound_deny_test.sh" run_test "LXC Timeout" "$SCRIPT_DIR/run_lxc_timeout_test.sh" run_test "LXC Env+Cwd" "$SCRIPT_DIR/run_lxc_env_cwd_test.sh" +run_test "LXC State-Aware Lifecycle" "$SCRIPT_DIR/run_lxc_state_aware_test.sh" +run_test "LXC State-Aware Network Matrix" "$SCRIPT_DIR/run_lxc_state_aware_network_test.sh" +run_test "LXC State-Aware Version Floor" "$SCRIPT_DIR/run_lxc_state_aware_version_floor_test.sh" +run_test "LXC State-Aware Phase Ordering" "$SCRIPT_DIR/run_lxc_state_aware_phase_ordering_test.sh" +run_test "LXC State-Aware Routing" "$SCRIPT_DIR/run_lxc_state_aware_routing_test.sh" +run_test "LXC State-Aware Adopt-or-Create" "$SCRIPT_DIR/run_lxc_state_aware_adopt_test.sh" +run_test "LXC Conflicting Backends" "$SCRIPT_DIR/run_lxc_conflicting_backends_test.sh" +run_test "LXC Experimental Provision Fields" "$SCRIPT_DIR/run_lxc_experimental_provision_fields_test.sh" echo "================================" echo "Results: $PASSED passed, $FAILED failed, $SKIPPED skipped, $DISABLED disabled" diff --git a/tests/scripts/run_lxc_config_refusal_test.sh b/tests/scripts/run_lxc_config_refusal_test.sh new file mode 100644 index 000000000..081f948d8 --- /dev/null +++ b/tests/scripts/run_lxc_config_refusal_test.sh @@ -0,0 +1,180 @@ +#!/bin/bash +# LXC configuration refusal test +# +# Every case here states something LXC cannot deliver. The contract is that the +# run is refused and says why, rather than starting a container whose network or +# environment is not the one the configuration describes. +# +# Each case asserts three things: a non-zero exit, a message naming the specific +# problem, and that the workload never produced its sentinel. Exit code alone is +# not enough -- a fixture broken in some unrelated way also exits non-zero, and +# would pass a test that only checked the status. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +CONFIG_DIR="$REPO_DIR/tests/configs" + +SENTINEL="MXC_WORKLOAD_RAN" + +fail() { + echo "FAIL: $*" + exit 1 +} + +# An honest skip for a missing prerequisite: exit 77 so run_lxc_all_tests.sh +# records SKIPPED rather than PASS. A suite that could not run must not look green. +SKIP_EXIT=77 +skip() { + echo "SKIP: refusal behavior UNVERIFIED -- $1" + echo " (fixture drift guards still ran and passed)" + exit "$SKIP_EXIT" +} + +# --------------------------------------------------------------------------- +# Drift guards. Each fixture is only interesting while it still carries the +# thing being refused. Stripping that element would leave a config LXC accepts, +# and every assertion below would pass for the wrong reason. +# +# These run before the prerequisite checks so a host that cannot launch +# containers still reports fixture rot. +# --------------------------------------------------------------------------- +assert_fixture_carries() { + local fixture="$1" token="$2" why="$3" + local path="$CONFIG_DIR/$fixture" + [ -f "$path" ] || fail "fixture not found: $path" + grep -Fq "$token" "$path" \ + || fail "fixture $fixture no longer contains '$token' -- $why" +} + +assert_fixture_carries "lxc_refuse_proxy_with_allowed_hosts.json" '"proxy"' \ + "without a proxy there is nothing for the allow list to conflict with" +assert_fixture_carries "lxc_refuse_proxy_with_allowed_hosts.json" '"allowedHosts"' \ + "without an allow list the proxy config is accepted and nothing is refused" +assert_fixture_carries "lxc_refuse_unresolvable_allowed_host.json" "no-such-host.invalid" \ + "a resolvable allow entry is programmed normally and is not refused" +assert_fixture_carries "lxc_refuse_malformed_env.json" "NOT_AN_ASSIGNMENT" \ + "an env list of well-formed KEY=VALUE entries is accepted and nothing is refused" +assert_fixture_carries "lxc_capabilities_no_posture_control.json" '"lxc"' \ + "the control must remain a valid LXC config that states no network posture" + +# The control's whole point is that it names no network posture. A fixture that +# grew a network section would be refused like the others, and its PASS would +# then prove the opposite of what it claims. +if grep -Fq '"network"' "$CONFIG_DIR/lxc_capabilities_no_posture_control.json"; then + fail "the no-posture control fixture grew a network section; it no longer controls for anything" +fi + +echo "Fixture drift guards passed." + +# --------------------------------------------------------------------------- +# Prerequisites for the live cases. +# --------------------------------------------------------------------------- +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" +[ -f "$LXC_EXEC" ] || LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" + +[ "$(id -u)" -eq 0 ] || skip "requires root for iptables and LXC" +command -v iptables >/dev/null 2>&1 || skip "iptables is not installed" +command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed" +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first" + +# `release` is preferred over `debug`, so a stale release binary built before +# these refusals existed is picked ahead of a fresh debug one -- and it fails +# exactly as a genuine regression would. Naming the artifact and its age turns +# that into one line instead of an hour hunting a phantom regression. +echo "Using $LXC_EXEC (built $(date -r "$LXC_EXEC" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo 'unknown'))." + +# shellcheck source=lib/chain_name.sh +. "$SCRIPT_DIR/lib/chain_name.sh" + +CHAINS_BEFORE_V4="$(mxc_chains iptables)" +CHAINS_BEFORE_V6="$(mxc_chains ip6tables)" + +# Compared against the snapshot above, so chains left behind by an earlier +# failed run are not blamed on this one. +assert_no_new_mxc_chains() { + local tool="$1" before="$2" after="" leaked="" chain + if ! after="$(mxc_chains "$tool")"; then + fail "could not enumerate $tool chains, so cleanup was not verified." + fi + while IFS= read -r chain; do + [ -n "$chain" ] || continue + grep -Fxq "$chain" <<<"$before" || leaked="$leaked $chain" + done <<<"$after" + [ -z "$leaked" ] || fail "$tool chain(s) left behind after lxc-exec completed:$leaked" +} + +# --------------------------------------------------------------------------- +# One refused case: run it, require a non-zero exit, require the message to +# name this specific problem, and require the workload never to have spoken. +# --------------------------------------------------------------------------- +assert_refused() { + local label="$1" fixture="$2" expected="$3" + local out="" status=0 + + echo "--- $label ---" + out="$("$LXC_EXEC" --debug "$CONFIG_DIR/$fixture" 2>&1)" || status=$? + echo "$out" + + [ "$status" -ne 0 ] \ + || fail "$label: lxc-exec exited 0, so the configuration was accepted. If this is + unexpected, check that $LXC_EXEC is current -- a binary built before these + refusals existed fails here in exactly the same way as a regression." + + grep -Fq "$expected" <<<"$out" \ + || fail "$label: refused, but the message never named the problem (wanted '$expected')" + + # Without this the test would pass on a run that refused the policy after + # already handing the container its network. + if grep -Fq "$SENTINEL" <<<"$out"; then + fail "$label: the workload ran despite the refusal" + fi + + assert_no_new_mxc_chains iptables "$CHAINS_BEFORE_V4" + assert_no_new_mxc_chains ip6tables "$CHAINS_BEFORE_V6" + + echo "PASS: $label" +} + +echo "Running LXC configuration refusal test..." + +# The proxy carries every packet in proxy mode, so an allow list beside it names +# destinations that are never programmed into any chain. +assert_refused \ + "proxy combined with allowedHosts" \ + "lxc_refuse_proxy_with_allowed_hosts.json" \ + "network.proxy cannot be combined with allowedHosts" + +# An allow entry that resolves to nothing programs no rule, and under a blocking +# default the destination the configuration names stays unreachable. +assert_refused \ + "allowedHosts entry that resolves to no address" \ + "lxc_refuse_unresolvable_allowed_host.json" \ + "resolved to no address, so no rule can be programmed to reach it" + +# An env entry that is not KEY=VALUE cannot be exported. Dropping it would run +# the workload in an environment the configuration did not describe. +assert_refused \ + "env entry that is not KEY=VALUE" \ + "lxc_refuse_malformed_env.json" \ + "is not a valid environment entry" + +# --------------------------------------------------------------------------- +# Control: refusing must stay narrow. A config that names no network posture is +# not asking for enforcement, and it must still run. +# --------------------------------------------------------------------------- +echo "--- control: a config that states no network posture still runs ---" +CONTROL_OUT="$("$LXC_EXEC" --debug "$CONFIG_DIR/lxc_capabilities_no_posture_control.json" 2>&1 || true)" +echo "$CONTROL_OUT" + +# Container stdout arrives with a trailing carriage return, so an exact-line +# match with grep -Fx never fires here. +if ! grep -qE "^${SENTINEL}[[:space:]]*$" <<<"$CONTROL_OUT"; then + fail "the control config was not run to completion; the refusals above are rejecting configs they should accept" +fi + +assert_no_new_mxc_chains iptables "$CHAINS_BEFORE_V4" +assert_no_new_mxc_chains ip6tables "$CHAINS_BEFORE_V6" + +echo "PASS: a config that states no network posture still runs" +echo "LXC configuration refusal test complete." diff --git a/tests/scripts/run_lxc_conflicting_backends_test.sh b/tests/scripts/run_lxc_conflicting_backends_test.sh new file mode 100644 index 000000000..846fd4c32 --- /dev/null +++ b/tests/scripts/run_lxc_conflicting_backends_test.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# LXC conflicting containment backends test. +# +# Proves the single-backend-section rule for LXC: a request that names +# containment "lxc" but carries a second experimental backend section is +# rejected, rather than silently honoring one section and ignoring the other. +# +# The rule is documented in docs/versioning.md ("a backend section requires +# `containment` to be set, and the value must be either the concrete backend +# name or any abstract intent that resolves to it") and is enforced by the +# parser, not by the JSON schema -- the generated schema intentionally omits +# the cross-field clauses. So this is only observable by running the binary, +# which is why it is an E2E test and not a schema fixture. +# +# The fixture deliberately carries a *complete and individually valid* +# experimental.lxc section alongside the foreign one. A fixture with only the +# foreign section would be rejected too, but for the weaker reason that no +# matching section was found; requiring both proves the request is refused +# because two backends were named, which is the scenario under test. +# +# Unlike every other LXC E2E test, this one needs no root, no LXC runtime and +# no iptables: the request is refused during config parsing, before any +# privileged work. Only a missing binary can skip it. Note that +# run_lxc_all_tests.sh still requires root before it dispatches anything, so +# under the suite this runs on the same hosts as everything else; the property +# matters when the script is invoked directly. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +CONFIG="$REPO_DIR/tests/configs/lxc_state_aware_provision_conflicting_backend_rejected.json" +VALID_CONFIG="$REPO_DIR/tests/configs/lxc_state_aware_provision.json" + +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +# An honest skip for a missing prerequisite: exit 77 so run_lxc_all_tests.sh +# records SKIPPED rather than PASS. A suite that could not run must not look +# green. +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +fail() { + echo "FAIL: $1" + exit 1 +} + +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." +[ -f "$CONFIG" ] || fail "fixture is missing: $CONFIG" +[ -f "$VALID_CONFIG" ] || fail "control fixture is missing: $VALID_CONFIG" + +# The positive control provisions a real container, so this test owns its +# removal on every exit path rather than leaving it for the runner or the next +# test to trip over. +CONTROL_CONTAINER="" +cleanup() { + if [ -n "$CONTROL_CONTAINER" ]; then + lxc-destroy -n "$CONTROL_CONTAINER" -f >/dev/null 2>&1 + fi + return 0 +} +trap cleanup EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM + +# Asserting on the message text makes it part of the observable contract. It is +# quoted here exactly as the binary emits it; a reword is a breaking change for +# anyone matching on it and must fail this test rather than pass silently. +EXPECTED_CODE='"code":"malformed_request"' +EXPECTED_MESSAGE='Multiple containment backends configured' +EXPECTED_SECTION='experimental.wslc' +EXPECTED_REMEDY='Only one backend section is allowed' + +# Drift guard: the fixture and these assertions rot apart the moment someone +# edits one of them. jq and python3 are not guaranteed on an LXC test host, so +# these are plain text checks against the fixture. The backend-section checks +# are scoped to the text from "experimental" onward, because an unscoped +# grep for '"lxc"' is also satisfied by the `"containment": "lxc"` line and +# would pass even if the experimental.lxc section were deleted outright. +experimental_block() { + sed -n '/"experimental"/,$p' "$1" +} + +grep -Fq '"containment": "lxc"' "$CONFIG" \ + || fail "fixture no longer selects lxc containment; the test would prove nothing." +experimental_block "$CONFIG" | grep -Fq '"lxc"' \ + || fail "fixture no longer carries an experimental.lxc section." +experimental_block "$CONFIG" | grep -Fq '"wslc"' \ + || fail "fixture no longer carries the conflicting experimental.wslc section." + +echo "Running LXC conflicting containment backends test..." + +# The error envelope goes to stdout and diagnostics to stderr, so stdout is +# captured on its own -- asserting against a merged stream would let a stderr +# diagnostic satisfy an assertion about the envelope. +set +e +OUTPUT="$("$LXC_EXEC" --experimental "$CONFIG" 2>/dev/null)" +RC=$? +set -e +echo "$OUTPUT" + +[ "$RC" -ne 0 ] || fail "conflicting backend sections were accepted (exit 0); the request should be refused." + +echo "$OUTPUT" | grep -Fq "$EXPECTED_CODE" \ + || fail "rejection did not carry $EXPECTED_CODE." +echo "$OUTPUT" | grep -Fq "$EXPECTED_MESSAGE" \ + || fail "rejection did not explain that multiple backends were configured." +echo "$OUTPUT" | grep -Fq "$EXPECTED_SECTION" \ + || fail "rejection did not name the conflicting section ($EXPECTED_SECTION)." +echo "$OUTPUT" | grep -Fq "$EXPECTED_REMEDY" \ + || fail "rejection did not tell the caller how to fix it." + +# Positive control: the same binary and the same phase must accept a request +# that names exactly one backend. Without this, an lxc-exec that rejected every +# config would pass every assertion above while verifying nothing. The control +# is allowed to fail for an environmental reason -- no LXC runtime on the host +# -- which is not what is under test; it must not fail as a malformed request. +set +e +CONTROL_OUTPUT="$("$LXC_EXEC" --experimental "$VALID_CONFIG" 2>/dev/null)" +CONTROL_RC=$? +set -e +echo "$CONTROL_OUTPUT" + +# Recorded from the control's own result envelope so the trap can remove it. +CONTROL_CONTAINER="$(printf '%s' "$CONTROL_OUTPUT" | sed -n 's/.*"containerName":"\([^"]*\)".*/\1/p')" + +if echo "$CONTROL_OUTPUT" | grep -Fq "$EXPECTED_MESSAGE"; then + fail "the single-backend control config was also rejected as multi-backend; the assertions above do not discriminate." +fi +if echo "$CONTROL_OUTPUT" | grep -Fq "$EXPECTED_SECTION"; then + fail "the single-backend control config named $EXPECTED_SECTION as a conflict; the assertions above do not discriminate." +fi +if echo "$CONTROL_OUTPUT" | grep -Fq "$EXPECTED_CODE"; then + fail "the single-backend control config was rejected as $EXPECTED_CODE; a well-formed request must get past parsing." +fi +if [ "$CONTROL_RC" -eq 0 ]; then + echo "Control accepted (exit 0)." +else + echo "NOTE: control exited $CONTROL_RC without a parse rejection, which is an environmental failure and not what this test covers." +fi + +echo "PASS: a request naming two containment backends is refused, naming the conflicting section." +echo "LXC conflicting containment backends test complete." diff --git a/tests/scripts/run_lxc_experimental_provision_fields_test.sh b/tests/scripts/run_lxc_experimental_provision_fields_test.sh new file mode 100644 index 000000000..d30a61f54 --- /dev/null +++ b/tests/scripts/run_lxc_experimental_provision_fields_test.sh @@ -0,0 +1,234 @@ +#!/bin/bash +# LXC experimental.lxc.provision field contract test. +# +# Covers the two fields the provision phase reads out of its own sub-object in +# the config, experimental.lxc.provision.distribution and .release: +# +# cause effect +# -------------------------------------- --------------------------------- +# provision names only one of the two refused, naming both as required +# distribution is not a string refused, naming the exact path +# both present and well typed not refused for either reason +# +# Scope note: this pins the *runtime* contract. Both fields are optional in the +# wire model, as they are in the stable top-level lxc section, so the schema +# accepts a provision section that omits them and the backend is what refuses +# it -- the same layer every other state-aware backend rejects provision config +# at. The non-string case is written to a temp file rather than committed under +# tests/configs, because a committed fixture that fails schema validation would +# have to be registered in scripts/versioning/config-validation-exemptions.json, +# and no other backend has an entry there. +# +# Like the conflicting-backends test, every assertion here lands during config +# parsing, so the script itself needs no root, no LXC runtime and no iptables. +# run_lxc_all_tests.sh still requires root before it dispatches anything, so +# that property matters when the script is invoked directly. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +CONFIG_DIR="$REPO_DIR/tests/configs" + +MISSING_FIELD_CONFIG="$CONFIG_DIR/lxc_state_aware_provision_missing_fields_rejected.json" +VALID_CONFIG="$CONFIG_DIR/lxc_state_aware_provision.json" + +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +# An honest skip for a missing prerequisite: exit 77 so run_lxc_all_tests.sh +# records SKIPPED rather than PASS. +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +PASSED=0 +FAILED=0 + +fail() { + echo "FAIL: $1" + FAILED=$((FAILED + 1)) +} + +pass() { + echo "PASS: $1" + PASSED=$((PASSED + 1)) +} + +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." +for c in "$MISSING_FIELD_CONFIG" "$VALID_CONFIG"; do + [ -f "$c" ] || { echo "FAIL: fixture is missing: $c"; exit 1; } +done + +# Written here rather than committed: see the scope note above. +INVALID_TYPE_CONFIG="$(mktemp)" +cat > "$INVALID_TYPE_CONFIG" <<'JSON' +{ + "version": "0.8.0-alpha", + "phase": "provision", + "containment": "lxc", + "experimental": { + "lxc": { + "provision": { "distribution": 123, "release": "3.23" } + } + } +} +JSON + +# A well-formed request naming a release that does not exist passes validation +# and fails inside container creation, which is the cheapest normal-use route +# to a backend_error. +# Naming the container means the failed create can be cleaned up by name. Left +# to mint its own, the only way to find it afterwards is to diff `lxc-ls`, which +# on a shared host also catches containers other runs created. +# The name must fit the backend's 20-character containerId bound. Over it, the +# request is refused as malformed and never reaches container creation, which +# is the failure this control exists to produce. +BAD_RELEASE_CONTAINER="mxc-e2e-bad-release" +BAD_RELEASE_CONFIG="$(mktemp)" +cat > "$BAD_RELEASE_CONFIG" </dev/null 2>&1 || true + fi + lxc-destroy -n "$BAD_RELEASE_CONTAINER" -f >/dev/null 2>&1 || true + rm -f "$INVALID_TYPE_CONFIG" "$BAD_RELEASE_CONFIG" + return 0 +} +trap cleanup EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM + +# Quoted exactly as the binary emits them, which makes them part of the +# observable contract; a reword must fail here rather than pass silently. +REQUIRED_MESSAGE='LXC distribution and release are required' +INVALID_TYPE_PATH='experimental.lxc.provision.distribution' +INVALID_TYPE_DETAIL='expected a string' + +# Drift guard: these fixtures only prove what they claim if they still carry +# the shape the assertions assume. jq and python3 are not guaranteed on an LXC +# test host, so these are plain text checks. +grep -Fq '"distribution"' "$MISSING_FIELD_CONFIG" \ + || fail "missing-field fixture no longer supplies distribution; a rejection would no longer prove that the *missing* field is what was caught." +grep -Fq '"release"' "$MISSING_FIELD_CONFIG" \ + && fail "missing-field fixture now supplies release; it no longer tests a missing field." +grep -Fq '"distribution": 123' "$INVALID_TYPE_CONFIG" \ + || fail "the generated invalid-type config no longer carries a non-string distribution." +grep -Fq '"distribution": "alpine"' "$VALID_CONFIG" \ + || fail "control fixture no longer carries a well-typed distribution." + +# Run $1, leaving its stdout in OUT and its exit status in RUN_RC. The error +# envelope goes to stdout and diagnostics to stderr, so stdout is captured on +# its own. OUT is assigned here rather than echoed because a caller using +# command substitution would run this in a subshell and discard RUN_RC, which +# would let a rejection message printed alongside exit 0 satisfy the +# assertions below. +OUT="" +RUN_RC=0 +run_config() { + set +e + OUT="$("$LXC_EXEC" --experimental "$1" 2>/dev/null)" + RUN_RC=$? + set -e +} + +echo "Running LXC experimental.lxc.provision field contract test..." + +echo "=== provision naming only one of the two fields ===" +run_config "$MISSING_FIELD_CONFIG" +echo "$OUT" +if [ "$RUN_RC" -eq 0 ]; then + fail "a provision section missing 'release' was accepted (exit 0); the request should be refused." +elif echo "$OUT" | grep -Fq "$REQUIRED_MESSAGE"; then + pass "a provision section missing 'release' is refused, naming both fields as required" +else + fail "a provision section missing 'release' was not refused with '$REQUIRED_MESSAGE'" +fi + +echo "=== provision with a non-string distribution ===" +run_config "$INVALID_TYPE_CONFIG" +echo "$OUT" +if [ "$RUN_RC" -eq 0 ]; then + fail "a non-string distribution was accepted (exit 0); the request should be refused." +else + pass "a non-string distribution is refused with a non-zero exit status" +fi +if echo "$OUT" | grep -Fq "$INVALID_TYPE_PATH"; then + pass "a non-string distribution is refused, naming the exact path $INVALID_TYPE_PATH" +else + fail "a non-string distribution was not refused with the path $INVALID_TYPE_PATH" +fi +if echo "$OUT" | grep -Fq "$INVALID_TYPE_DETAIL"; then + pass "the type error says what was expected instead" +else + fail "the type error did not say '$INVALID_TYPE_DETAIL'" +fi + +# Positive control. Without it, an lxc-exec that refused every config would +# satisfy every assertion above while verifying nothing. This asserts only the +# absence of the two field diagnostics: the run is expected to fail later on a +# host with no LXC runtime, and that failure is not what is under test. +# A failed create can leave the container behind, and the trap removes it by +# the name the request gave it. +echo "=== provision naming a release that does not exist ===" +run_config "$BAD_RELEASE_CONFIG" +echo "$OUT" +if [ "$RUN_RC" -eq 0 ]; then + fail "a provision naming a nonexistent release was accepted (exit 0); the request should fail." +elif echo "$OUT" | grep -Fq 'backend_error'; then + pass "a container create that fails is reported as backend_error" +else + fail "a container create that fails was not reported as backend_error" +fi + +echo "=== control: both fields present and well typed ===" +run_config "$VALID_CONFIG" +echo "$OUT" +# Recorded from the control's own result envelope so the trap can remove it. +CONTROL_CONTAINER="$(printf '%s' "$OUT" | sed -n 's/.*"containerName":"\([^"]*\)".*/\1/p')" +if echo "$OUT" | grep -Fq "$REQUIRED_MESSAGE"; then + fail "the control config was also refused as missing fields; the assertions above do not discriminate." +elif echo "$OUT" | grep -Fq "$INVALID_TYPE_PATH"; then + fail "the control config also produced a distribution type error; the assertions above do not discriminate." +else + pass "a well-formed provision section produces neither field diagnostic" +fi +# The control may still fail for an environmental reason -- no LXC runtime on +# this host -- which is not what this test covers. It must not fail as a +# malformed request, because that is the class of failure under test. +if echo "$OUT" | grep -Fq '"code":"malformed_request"'; then + fail "the control config was rejected as malformed_request; a well-formed provision section must get past parsing." +fi +if [ "$RUN_RC" -ne 0 ]; then + echo "NOTE: control exited $RUN_RC without a malformed_request rejection, which is environmental and not what this test covers." +fi + +echo "================================" +echo "Results: $PASSED passed, $FAILED failed" +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi +echo "LXC experimental.lxc.provision field contract test complete." diff --git a/tests/scripts/run_lxc_network_invalid_cidr_test.sh b/tests/scripts/run_lxc_network_invalid_cidr_test.sh index 626af0386..cfc2a57e3 100644 --- a/tests/scripts/run_lxc_network_invalid_cidr_test.sh +++ b/tests/scripts/run_lxc_network_invalid_cidr_test.sh @@ -1,8 +1,10 @@ #!/bin/bash # LXC invalid CIDR network filtering test # -# Invalid CIDR entries should be reported as unresolved hosts and then skipped; -# they must not make firewall setup fail for the rest of the policy. +# An entry like 10.0.0.0/33 matches nothing on any host at any moment, so no +# rule can carry it. The run is refused and names the entry, rather than +# programming the rest of the policy and leaving the caller believing a +# destination they wrote down is being filtered. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -72,38 +74,42 @@ echo "Running LXC invalid CIDR network filtering test..." MXC_CHAINS_BEFORE_V4="$(mxc_chains iptables)" MXC_CHAINS_BEFORE_V6="$(mxc_chains ip6tables)" -# The process may fail because the default policy blocks egress; this test is -# only asserting firewall validation and setup behavior. -OUTPUT=$("$LXC_EXEC" --debug "$CONFIG" 2>&1 || true) +# The run must be refused. A zero exit means a policy naming an entry that can +# never match was applied and reported as success. +OUTPUT="" +STATUS=0 +OUTPUT=$("$LXC_EXEC" --debug "$CONFIG" 2>&1) || STATUS=$? echo "$OUTPUT" +[ "$STATUS" -ne 0 ] \ + || fail "lxc-exec exited 0, so a policy carrying an unmatchable entry was accepted. If + this is unexpected, check that $LXC_EXEC is current -- a binary built before + this rule existed fails here in exactly the same way as a regression." + +# Refused for this reason, not by coincidence: a fixture broken some other way +# also exits non-zero and would pass the check above on its own. +echo "$OUTPUT" | grep -Fq "is not a valid destination" \ + || fail "refused, but the message never said the destination was invalid." + +# The operator has to be told which entry to fix. Only the first is named -- +# the run stops at it -- so one match across the set is the contract, not three. +named=0 for host in "${INVALID_HOSTS[@]}"; do - if ! echo "$OUTPUT" | grep -Fq "Warning: could not resolve host '$host'"; then - fail "invalid host '$host' did not produce an unresolved-host warning." + if echo "$OUTPUT" | grep -Fq "$host"; then + named=$((named + 1)) fi done - -# Invalid CIDRs are warned about and omitted from rule generation; applying the -# remaining firewall policy should still succeed. -if echo "$OUTPUT" | grep -qE "^(ip6?tables) .* failed:|Firewall setup failed:"; then - fail "invalid CIDR entry caused firewall setup to fail." +if [ "$named" -eq 0 ]; then + fail "the refusal named none of the invalid entries, so it says nothing about what to fix." fi -if ! echo "$OUTPUT" | grep -q "Default network policy: DROP"; then - fail "default-deny policy was not applied." -fi - -# The FORWARD hook is what scopes the chain to this container's egress; a run -# that skipped it enforces nothing, so PASS must require it. Fail on the -# skipped-hook warning and require the positive install confirmation. -if echo "$OUTPUT" | grep -Fq "Skipping FORWARD hook"; then - fail "FORWARD hook was skipped; the container's veth interface was not discovered." -fi -if ! echo "$OUTPUT" | grep -Fq "FORWARD hook installed"; then - fail "FORWARD hook installation was not confirmed." +# The refusal has to land before the workload, or the container already ran +# under a policy that was never programmed. +if echo "$OUTPUT" | grep -Fq "MXC_WORKLOAD_RAN"; then + fail "the workload ran despite the refused policy." fi assert_firewall_chain_cleaned_up -echo "PASS: invalid CIDR entries were warned about without failing firewall setup." +echo "PASS: an unmatchable CIDR entry was refused, named, and rolled back." echo "LXC invalid CIDR network filtering test complete." diff --git a/tests/scripts/run_lxc_network_v07_schema_test.sh b/tests/scripts/run_lxc_network_v07_schema_test.sh index 3183a0b48..210ceccef 100644 --- a/tests/scripts/run_lxc_network_v07_schema_test.sh +++ b/tests/scripts/run_lxc_network_v07_schema_test.sh @@ -108,27 +108,37 @@ assert_no_new_mxc_chains ip6tables "$CHAINS_BEFORE_V6" echo "PASS: the 0.7 chain kept its DNS exemption." # --------------------------------------------------------------------------- -# Case 2: capabilities mode installs nothing +# Case 2: a stated posture the default mode cannot deliver is refused # --------------------------------------------------------------------------- echo "--- capabilities case: 0.7 defaultPolicy block, enforcementMode absent ---" -CAP_OUTPUT=$("$LXC_EXEC" --debug "$CAPABILITIES_CONFIG" 2>&1 || true) +CAP_OUTPUT="" +CAP_STATUS=0 +CAP_OUTPUT=$("$LXC_EXEC" --debug "$CAPABILITIES_CONFIG" 2>&1) || CAP_STATUS=$? echo "$CAP_OUTPUT" -# `capabilities` is the 0.7 default, so this is what every config that never -# wrote `enforcementMode` gets. Installing a chain here would put a firewall on -# configs that predate the field. -if ! echo "$CAP_OUTPUT" | grep -Fq "requests no firewall; skipping iptables"; then - fail "the default enforcement mode did not skip the firewall. A 0.7 config that never asked for one is being given a chain." -fi +# This fixture asks for `defaultPolicy: block` and names a blocked host, and +# `capabilities` is the 0.7 default it therefore lands in. LXC filters with +# iptables and has nothing else to enforce with, so that combination installs no +# rule in either direction and the container gets the host's open network. The +# refusal is what tells the caller their block list is not in effect. +# +# The control for this -- a 0.7 config that names no network posture at all, +# which still runs -- is in run_lxc_config_refusal_test.sh. It is what keeps +# this refusal from swallowing every config written before the field existed. +[ "$CAP_STATUS" -ne 0 ] \ + || fail "a config asking for a block policy under the default enforcement mode exited 0; its block list is not being enforced and nothing said so." + +echo "$CAP_OUTPUT" | grep -Fq "names a posture LXC cannot enforce" \ + || fail "refused, but the message never said the enforcement mode was the problem." -if ! echo "$CAP_OUTPUT" | grep -Fq "MXC_WORKLOAD_RAN"; then - fail "the workload did not run; skipping the firewall must remain a successful no-op." +if echo "$CAP_OUTPUT" | grep -Fq "MXC_WORKLOAD_RAN"; then + fail "the workload ran with an unenforced network policy." fi assert_no_new_mxc_chains iptables "$CHAINS_BEFORE_V4" assert_no_new_mxc_chains ip6tables "$CHAINS_BEFORE_V6" -echo "PASS: the default 0.7 enforcement mode installed nothing." +echo "PASS: a posture the default 0.7 enforcement mode cannot deliver was refused." echo "LXC schema 0.7 enforcement test complete." diff --git a/tests/scripts/run_lxc_state_aware_adopt_test.sh b/tests/scripts/run_lxc_state_aware_adopt_test.sh new file mode 100644 index 000000000..52b18528b --- /dev/null +++ b/tests/scripts/run_lxc_state_aware_adopt_test.sh @@ -0,0 +1,224 @@ +#!/bin/bash +# LXC state-aware identifier and adopt-or-create test. +# +# Covers the LXC exception in section 5 of +# docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md, which is the one +# place a backend is allowed to accept containerId on a state-aware provision +# and the only backend whose sandboxId is not opaque. Four documented +# consequences had no end-to-end coverage: the returned id is lxc:, +# provision adopts an existing container instead of creating one, deprovision +# destroys an adopted container just as readily as a created one, and omitting +# containerId mints a name that adopts nothing. +# +# The adoption path is the one that matters to a caller, because a caller who +# passes the name of a container they already own hands MXC the right to +# destroy it. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" + +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +DISTRIBUTION="alpine" +RELEASE="3.23" + +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +[ "$(id -u)" -eq 0 ] || skip "requires root for LXC." +command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed." +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." + +WORK_DIR="$(mktemp -d)" +PASSED=0 +FAILED=0 +CLEANED_UP=0 + +# Names this test may have brought into existence, by any route: minted by the +# backend, supplied to it, or created here with lxc-create to give provision +# something to adopt. Cleanup destroys the lot directly rather than through +# deprovision, because several assertions below depend on deprovision having +# already run and a second one would report a failure the run has passed. +TRACKED_NAMES="" +track() { + TRACKED_NAMES="$TRACKED_NAMES $1" +} + +cleanup() { + if [ "$CLEANED_UP" -ne 0 ]; then + return + fi + CLEANED_UP=1 + for name in $TRACKED_NAMES; do + if lxc-info -n "$name" >/dev/null 2>&1; then + echo "--- cleanup: destroying $name ---" + lxc-stop -k -n "$name" >/dev/null 2>&1 || true + lxc-destroy -f -n "$name" >/dev/null 2>&1 || true + fi + done + rm -rf "$WORK_DIR" +} +trap cleanup EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM + +# Emit a provision request carrying $1 as containerId, or no containerId at all +# when $1 is empty, and run it. +provision() { + local container_id="$1" + local req="$WORK_DIR/provision.json" + + { + printf '{\n "version": "0.8.0-alpha",\n "phase": "provision",\n "containment": "lxc"' + if [ -n "$container_id" ]; then + printf ',\n "containerId": "%s"' "$container_id" + fi + printf ',\n "experimental": { "lxc": { "provision": { "distribution": "%s", "release": "%s" } } }' \ + "$DISTRIBUTION" "$RELEASE" + printf '\n}\n' + } > "$req" + + "$LXC_EXEC" --experimental "$req" +} + +deprovision() { + local sandbox_id="$1" + local req="$WORK_DIR/deprovision.json" + + printf '{\n "version": "0.8.0-alpha",\n "phase": "deprovision",\n "sandboxId": "%s"\n}\n' \ + "$sandbox_id" > "$req" + + "$LXC_EXEC" --experimental "$req" +} + +# Read one scalar out of a result envelope without jq or python, neither of +# which is guaranteed on an LXC test host. +field() { + sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1 +} + +flag() { + sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\(true\|false\).*/\1/p" | head -n 1 +} + +check() { + local name="$1" + local ok="$2" + if [ "$ok" = "0" ]; then + echo "PASS: $name" + PASSED=$((PASSED + 1)) + else + echo "FAIL: $name" + FAILED=$((FAILED + 1)) + fi +} + +check_eq() { + local name="$1" + local want="$2" + local got="$3" + if [ "$want" = "$got" ]; then + check "$name" 0 + else + check "$name (want '$want', got '$got')" 1 + fi +} + +echo "Running LXC state-aware adopt-or-create test..." + +SUPPLIED="mxc-adopt-$$" +track "$SUPPLIED" + +# --- create: a supplied name nothing is using ------------------------------ +echo "=== provision with a supplied containerId (creates) ===" +OUT="$(provision "$SUPPLIED")" +check "provision with a supplied containerId exits 0" $? +echo "$OUT" + +check_eq "sandboxId is lxc:" "lxc:$SUPPLIED" "$(printf '%s' "$OUT" | field sandboxId)" +check_eq "metadata reports the container name" "$SUPPLIED" "$(printf '%s' "$OUT" | field containerName)" +check_eq "a name nothing was using reports created" "true" "$(printf '%s' "$OUT" | flag created)" + +# --- adopt: the same name, now taken --------------------------------------- +# The container from the previous phase is still there, so this is the adoption +# the caller asked for rather than a second create. +echo "=== provision again with the same containerId (adopts) ===" +OUT="$(provision "$SUPPLIED")" +check "re-provisioning an existing name exits 0" $? +echo "$OUT" + +check_eq "adopting returns the same sandboxId" "lxc:$SUPPLIED" "$(printf '%s' "$OUT" | field sandboxId)" +check_eq "an adopted container reports created false" "false" "$(printf '%s' "$OUT" | flag created)" +check_eq "adoption leaves one container, not two" "1" "$(lxc-ls -1 | grep -c "^$SUPPLIED\$")" + +echo "=== deprovision ===" +deprovision "lxc:$SUPPLIED" >/dev/null +check "deprovision exits 0" $? +if lxc-info -n "$SUPPLIED" >/dev/null 2>&1; then + check "deprovision destroys the container" 1 +else + check "deprovision destroys the container" 0 +fi + +# --- adopt a container MXC never made -------------------------------------- +# Section 5 spells out the consequence a caller has to understand: a container +# handed to provision by name is destroyed by deprovision even though MXC did +# not create it, because MXC keeps no state between phases that could tell the +# two apart. +FOREIGN="mxc-foreign-$$" +track "$FOREIGN" +echo "=== provision adopts a container created outside MXC ===" +if ! lxc-create -n "$FOREIGN" -t download -- \ + -d "$DISTRIBUTION" -r "$RELEASE" -a amd64 >/dev/null 2>&1; then + skip "could not create a container to adopt; no image cache and no network?" +fi + +OUT="$(provision "$FOREIGN")" +check "provisioning onto a foreign container exits 0" $? +check_eq "a container MXC did not create reports created false" "false" "$(printf '%s' "$OUT" | flag created)" + +deprovision "lxc:$FOREIGN" >/dev/null +check "deprovision of an adopted container exits 0" $? +if lxc-info -n "$FOREIGN" >/dev/null 2>&1; then + check "deprovision destroys an adopted container it never created" 1 +else + check "deprovision destroys an adopted container it never created" 0 +fi + +# --- omitting containerId adopts nothing ----------------------------------- +echo "=== provision without a containerId (mints) ===" +OUT="$(provision "")" +check "provision without a containerId exits 0" $? +MINTED="$(printf '%s' "$OUT" | field sandboxId)" +track "${MINTED#lxc:}" + +case "$MINTED" in + lxc:mxc-*) check "a minted sandboxId carries the lxc:mxc- form ($MINTED)" 0 ;; + *) check "a minted sandboxId carries the lxc:mxc- form ($MINTED)" 1 ;; +esac +check_eq "a minted name is always created, never adopted" "true" "$(printf '%s' "$OUT" | flag created)" + +deprovision "$MINTED" >/dev/null +check "deprovision of a minted container exits 0" $? + +# --- a containerId LXC cannot name ----------------------------------------- +# Rejected before anything is created, so this costs no container. +echo "=== provision with an unusable containerId ===" +OUT="$(provision "not a valid name!" 2>&1)" +case "$OUT" in + *malformed_request*) check "an unusable containerId is malformed_request" 0 ;; + *) check "an unusable containerId is malformed_request (got: $OUT)" 1 ;; +esac + +echo "================================" +echo "Results: $PASSED passed, $FAILED failed" +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi diff --git a/tests/scripts/run_lxc_state_aware_network_test.sh b/tests/scripts/run_lxc_state_aware_network_test.sh new file mode 100644 index 000000000..c292f9f66 --- /dev/null +++ b/tests/scripts/run_lxc_state_aware_network_test.sh @@ -0,0 +1,978 @@ +#!/bin/bash +# LXC state-aware network policy matrix test. +# +# Proves that LXC start requests enforce policy-driven network rules, including +# the inherited default-deny policy when the request omits a network block. +# +# Case 3 proves the default-deny hook reaches a stock provisioned container, +# and reads the host's own iptables state to show the hook was applied rather +# than merely reported. +# +# The start fixtures keep a __SANDBOX_ID__ placeholder because the live LXC +# sandboxId is the container name returned by this test's own provision call. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +CONFIG_DIR="$REPO_DIR/tests/configs" + +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +WORK_DIR="$REPO_DIR/tests/.lxc_state_aware_network_test.$$" +SANDBOX_ID="" +SANDBOX_STARTED=0 +PASSED=0 +FAILED=0 +QUARANTINED=0 +QUARANTINE_ACTIVE="" +QUARANTINE_NOTES="" +CLEANED_UP=0 +FS_FIXTURES_CREATED=0 + +cleanup() { + if [ "$CLEANED_UP" -ne 0 ]; then + return + fi + CLEANED_UP=1 + if [ -n "$SANDBOX_ID" ]; then + if [ "$SANDBOX_STARTED" -ne 0 ]; then + echo "--- cleanup: stop $SANDBOX_ID ---" + run_phase stop "$SANDBOX_ID" >/dev/null 2>&1 || true + SANDBOX_STARTED=0 + fi + echo "--- cleanup: deprovision $SANDBOX_ID ---" + run_phase deprovision "$SANDBOX_ID" >/dev/null 2>&1 || true + fi + if [ "$FS_FIXTURES_CREATED" -ne 0 ]; then + rm -rf "$FS_RO_DIR" "$FS_DENIED_DIR" + FS_FIXTURES_CREATED=0 + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM + +run_phase() { + local phase="$1" + local sandbox_id="${2:-}" + local extra="${3:-}" + local req="$WORK_DIR/$phase.json" + + { + printf '{\n "version": "0.8.0-alpha",\n "phase": "%s"' "$phase" + if [ "$phase" = "provision" ]; then + printf ',\n "containment": "lxc"' + fi + if [ -n "$sandbox_id" ]; then + printf ',\n "sandboxId": "%s"' "$sandbox_id" + fi + if [ -n "$extra" ]; then + printf ',\n %s' "$extra" + fi + printf '\n}\n' + } > "$req" + + "$LXC_EXEC" --experimental "$req" +} + +extract_sandbox_id() { + sed -n 's/.*"sandboxId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 +} + +check() { + local name="$1" + local ok="$2" + if [ "$ok" = "0" ]; then + echo "PASS: $name" + PASSED=$((PASSED + 1)) + elif [ -n "$QUARANTINE_ACTIVE" ]; then + # The assertion above is preserved byte for byte and still runs; only + # its tally changes, because it exposes a product gap that predates this + # change and is out of its scope. This must never read as a pass. + echo "QUARANTINED (BEHAVIOR NOT VERIFIED): $name" + echo " reason: $QUARANTINE_ACTIVE" + QUARANTINED=$((QUARANTINED + 1)) + QUARANTINE_NOTES="${QUARANTINE_NOTES} - ${name} + ${QUARANTINE_ACTIVE} +" + else + echo "FAIL: $name" + FAILED=$((FAILED + 1)) + fi +} + +fail_now() { + echo "FAIL: $1" + exit 1 +} + +record_result() { + local case_no="$1" + local config="$2" + local cause="$3" + local expected="$4" + local actual="$5" + local status="$6" + RESULTS="${RESULTS}${case_no}|${config}|${cause}|${expected}|${actual}|${status} +" +} + +CONFIG_NO_NETWORK="$CONFIG_DIR/lxc_state_aware_start_no_network.json" +CONFIG_BLOCK_CAPS="$CONFIG_DIR/lxc_state_aware_start_default_block_capabilities.json" +CONFIG_BLOCK_FIREWALL="$CONFIG_DIR/lxc_state_aware_start_default_block_firewall.json" +CONFIG_ALLOW_CAPS="$CONFIG_DIR/lxc_state_aware_start_default_allow_capabilities.json" +CONFIG_EMPTY_ALLOWED="$CONFIG_DIR/lxc_state_aware_start_empty_allowed_hosts.json" +CONFIG_NONEMPTY_ALLOWED="$CONFIG_DIR/lxc_state_aware_start_nonempty_allowed_hosts_capabilities.json" +CONFIG_PROVISION_NETWORK="$CONFIG_DIR/lxc_state_aware_start_network_at_provision_rejected.json" +CONFIG_PROVISION_FILESYSTEM="$CONFIG_DIR/lxc_state_aware_start_filesystem_at_provision_rejected.json" +CONFIG_BLOCK_BOTH="$CONFIG_DIR/lxc_state_aware_start_default_block_both.json" +CONFIG_ALLOWED_FIREWALL="$CONFIG_DIR/lxc_state_aware_start_allowed_hosts_firewall.json" +CONFIG_BLOCKED_CAPS="$CONFIG_DIR/lxc_state_aware_start_blocked_hosts_capabilities.json" +CONFIG_ALLOW_LOCAL="$CONFIG_DIR/lxc_state_aware_start_allow_local_network.json" +CONFIG_PROXY="$CONFIG_DIR/lxc_state_aware_start_proxy.json" +CONFIG_PROXY_EXTERNAL="$CONFIG_DIR/lxc_state_aware_start_proxy_external.json" +CONFIG_FILESYSTEM_PATHS="$CONFIG_DIR/lxc_state_aware_start_filesystem_paths.json" +CONFIG_HOSTLOOPBACK_ALLOW="$CONFIG_DIR/lxc_state_aware_start_hostloopback_allow_rejected.json" +CONFIG_HOSTLOOPBACK_DENY="$CONFIG_DIR/lxc_state_aware_start_hostloopback_deny.json" +RESULTS="" + +verify_fixture_contracts() { + for cfg in "$CONFIG_NO_NETWORK" "$CONFIG_BLOCK_CAPS" "$CONFIG_BLOCK_FIREWALL" \ + "$CONFIG_ALLOW_CAPS" "$CONFIG_EMPTY_ALLOWED" "$CONFIG_NONEMPTY_ALLOWED" \ + "$CONFIG_PROVISION_NETWORK" "$CONFIG_PROVISION_FILESYSTEM" \ + "$CONFIG_BLOCK_BOTH" "$CONFIG_ALLOWED_FIREWALL" "$CONFIG_BLOCKED_CAPS" \ + "$CONFIG_ALLOW_LOCAL" "$CONFIG_PROXY" "$CONFIG_PROXY_EXTERNAL" \ + "$CONFIG_FILESYSTEM_PATHS" "$CONFIG_HOSTLOOPBACK_ALLOW" \ + "$CONFIG_HOSTLOOPBACK_DENY"; do + [ -f "$cfg" ] || fail_now "fixture not found: $cfg" + done + + grep -q '"hostLoopback"[[:space:]]*:[[:space:]]*"allow"' "$CONFIG_HOSTLOOPBACK_ALLOW" \ + || fail_now "fixture drift in $CONFIG_HOSTLOOPBACK_ALLOW: must carry ingress.hostLoopback=allow" + grep -q '"hostLoopback"[[:space:]]*:[[:space:]]*"deny"' "$CONFIG_HOSTLOOPBACK_DENY" \ + || fail_now "fixture drift in $CONFIG_HOSTLOOPBACK_DENY: must carry ingress.hostLoopback=deny" + + # Case 8's fixture is guarded here rather than in the block below so the + # existing seven keep their positional indices. + grep -q '"phase"[[:space:]]*:[[:space:]]*"provision"' "$CONFIG_PROVISION_FILESYSTEM" \ + || fail_now "fixture drift in $CONFIG_PROVISION_FILESYSTEM: phase must be provision" + grep -q '"filesystem"' "$CONFIG_PROVISION_FILESYSTEM" \ + || fail_now "fixture drift in $CONFIG_PROVISION_FILESYSTEM: must carry a filesystem block" + grep -q '"sandboxId"' "$CONFIG_PROVISION_FILESYSTEM" \ + && fail_now "fixture drift in $CONFIG_PROVISION_FILESYSTEM: must not hard-code a sandboxId" + + if command -v python3 >/dev/null 2>&1; then + python3 - "$CONFIG_NO_NETWORK" "$CONFIG_BLOCK_CAPS" "$CONFIG_BLOCK_FIREWALL" \ + "$CONFIG_ALLOW_CAPS" "$CONFIG_EMPTY_ALLOWED" "$CONFIG_NONEMPTY_ALLOWED" \ + "$CONFIG_PROVISION_NETWORK" <<'PY' +import json +import sys + +cases = [json.load(open(path, encoding="utf-8")) for path in sys.argv[1:]] +paths = sys.argv[1:] + +def fail(index, message): + raise SystemExit(f"fixture drift in {paths[index]}: {message}") + +if cases[0].get("phase") != "start" or cases[0].get("sandboxId") != "__SANDBOX_ID__" or "network" in cases[0]: + fail(0, "case 1 must be a start request with no network block") +if cases[1].get("network") != {"defaultPolicy": "block"}: + fail(1, "case 2 must carry only defaultPolicy=block") +if cases[2].get("network") != {"defaultPolicy": "block", "enforcementMode": "firewall"}: + fail(2, "case 3 must carry defaultPolicy=block with enforcementMode=firewall") +if cases[3].get("network") != {"defaultPolicy": "allow"}: + fail(3, "case 4 must carry only defaultPolicy=allow") +if cases[4].get("network") != {"allowedHosts": []}: + fail(4, "case 5 must carry an empty allowedHosts list and no enforcementMode") +if cases[5].get("network") != {"allowedHosts": ["example.com"]}: + fail(5, "case 6 must carry one allowedHosts entry and no enforcementMode") +if cases[6].get("phase") != "provision" or cases[6].get("containment") != "lxc": + fail(6, "case 7 must be an LXC provision request") +if cases[6].get("network") != {"defaultPolicy": "block"}: + fail(6, "case 7 must carry provision-time network.defaultPolicy=block") +if "sandboxId" in cases[6]: + fail(6, "case 7 must not hard-code a sandboxId") +PY + [ $? -eq 0 ] || fail_now "fixture drift in the case 1-7 configs" + else + grep -q '"network"' "$CONFIG_NO_NETWORK" && fail_now "fixture drift in $CONFIG_NO_NETWORK: case 1 must have no network block" + grep -q '"defaultPolicy"[[:space:]]*:[[:space:]]*"block"' "$CONFIG_BLOCK_CAPS" || fail_now "fixture drift in $CONFIG_BLOCK_CAPS: missing defaultPolicy=block" + grep -q '"enforcementMode"' "$CONFIG_BLOCK_CAPS" && fail_now "fixture drift in $CONFIG_BLOCK_CAPS: enforcementMode must be omitted" + grep -q '"defaultPolicy"[[:space:]]*:[[:space:]]*"block"' "$CONFIG_BLOCK_FIREWALL" || fail_now "fixture drift in $CONFIG_BLOCK_FIREWALL: missing defaultPolicy=block" + grep -q '"enforcementMode"[[:space:]]*:[[:space:]]*"firewall"' "$CONFIG_BLOCK_FIREWALL" || fail_now "fixture drift in $CONFIG_BLOCK_FIREWALL: missing enforcementMode=firewall" + grep -q '"defaultPolicy"[[:space:]]*:[[:space:]]*"allow"' "$CONFIG_ALLOW_CAPS" || fail_now "fixture drift in $CONFIG_ALLOW_CAPS: missing defaultPolicy=allow" + grep -q '"enforcementMode"' "$CONFIG_ALLOW_CAPS" && fail_now "fixture drift in $CONFIG_ALLOW_CAPS: enforcementMode must be omitted" + grep -q '"allowedHosts"[[:space:]]*:[[:space:]]*\[\]' "$CONFIG_EMPTY_ALLOWED" || fail_now "fixture drift in $CONFIG_EMPTY_ALLOWED: allowedHosts must be empty" + grep -q '"allowedHosts"[[:space:]]*:[[:space:]]*\["example.com"\]' "$CONFIG_NONEMPTY_ALLOWED" || fail_now "fixture drift in $CONFIG_NONEMPTY_ALLOWED: allowedHosts must contain example.com" + grep -q '"phase"[[:space:]]*:[[:space:]]*"provision"' "$CONFIG_PROVISION_NETWORK" || fail_now "fixture drift in $CONFIG_PROVISION_NETWORK: phase must be provision" + grep -q '"defaultPolicy"[[:space:]]*:[[:space:]]*"block"' "$CONFIG_PROVISION_NETWORK" || fail_now "fixture drift in $CONFIG_PROVISION_NETWORK: missing defaultPolicy=block" + grep -q '"sandboxId"' "$CONFIG_PROVISION_NETWORK" && fail_now "fixture drift in $CONFIG_PROVISION_NETWORK: must not hard-code sandboxId" + fi + + # Guarded in its own block so the seven above keep their positional indices. + # Each of these fixtures isolates one field, and a field that drifts into a + # neighbouring fixture would let a case pass while pinning nothing. + if command -v python3 >/dev/null 2>&1; then + python3 - "$CONFIG_BLOCK_BOTH" "$CONFIG_ALLOWED_FIREWALL" "$CONFIG_BLOCKED_CAPS" \ + "$CONFIG_ALLOW_LOCAL" "$CONFIG_PROXY" "$CONFIG_FILESYSTEM_PATHS" \ + "$CONFIG_PROXY_EXTERNAL" <<'PY' +import json +import sys + +paths = sys.argv[1:] +cases = [json.load(open(path, encoding="utf-8")) for path in paths] + +def fail(index, message): + raise SystemExit(f"fixture drift in {paths[index]}: {message}") + +for i, case in enumerate(cases): + if case.get("phase") != "start" or case.get("sandboxId") != "__SANDBOX_ID__": + fail(i, "must be a start request carrying the sandbox id placeholder") + +if cases[0].get("network") != {"defaultPolicy": "block", "enforcementMode": "both"}: + fail(0, "case 11 must carry defaultPolicy=block with enforcementMode=both") +if cases[1].get("network") != {"allowedHosts": ["example.com"], "enforcementMode": "firewall"}: + fail(1, "case 12 must carry one allowedHosts entry with enforcementMode=firewall") +if cases[2].get("network") != {"blockedHosts": ["evil.example.com"]}: + fail(2, "case 13 must carry one blockedHosts entry and no enforcementMode") +if cases[3].get("network", {}).get("allowLocalNetwork") is not True: + fail(3, "case 14 must request allowLocalNetwork=true") +if cases[4].get("network") != {"proxy": {"builtinTestServer": True}}: + fail(4, "case 15 must carry exactly the builtin-test-server proxy and nothing else: an " + "external proxy url, a host list, or an enforcementMode is refused earlier by a " + "shared parse-time rule, so the case would pass without ever reaching the LXC " + "start verdict it exists to pin") +if cases[5].get("filesystem") != { + "readonlyPaths": ["/mxc-e2e-ro"], + "deniedPaths": ["/mxc-e2e-denied"], +}: + fail(5, "case 16 must carry exactly the readonly and denied paths the case creates on the host") +if "network" in cases[5]: + fail(5, "case 16 must carry no network block so it isolates the filesystem fields") +if not cases[6].get("network", {}).get("proxy", {}).get("url"): + fail(6, "case 15's second half must carry an external proxy url, which is the form a " + "production config uses") +if cases[6].get("network", {}).get("proxy", {}).get("builtinTestServer"): + fail(6, "case 15's second half must not be the builtin form; the two halves exist to cover " + "the two different refusal routes") +PY + [ $? -eq 0 ] || fail_now "fixture drift in the field-isolation configs" + fi + echo "Fixture drift guard passed for all LXC state-aware network configs." +} + +make_request_from_config() { + local config="$1" + local out="$2" + sed "s/__SANDBOX_ID__/$SANDBOX_ID/g" "$config" > "$out" +} + +expect_error_code() { + local output="$1" + local code="$2" + echo "$output" | grep -Eq '"code"[[:space:]]*:[[:space:]]*"'"$code"'"' +} + +start_fresh_sandbox() { + local label="$1" + local out rc + echo "=== provision for $label ===" + out="$($LXC_EXEC --experimental "$CONFIG_DIR/lxc_state_aware_provision.json")" + rc=$? + echo "$out" + if [ "$rc" -ne 0 ]; then + fail_now "$label: provision failed before the network input could be tested (config: $CONFIG_DIR/lxc_state_aware_provision.json, rc=$rc)." + fi + SANDBOX_ID="$(printf '%s' "$out" | extract_sandbox_id)" + if [ -z "$SANDBOX_ID" ]; then + fail_now "$label: provision did not return a sandboxId, so the start input cannot be tested." + fi + case "$SANDBOX_ID" in + lxc:mxc-*) ;; + *) fail_now "$label: provision returned unsafe-looking sandboxId '$SANDBOX_ID'." ;; + esac + SANDBOX_STARTED=0 +} + +finish_current_sandbox() { + if [ -n "$SANDBOX_ID" ]; then + if [ "$SANDBOX_STARTED" -ne 0 ]; then + echo "=== stop $SANDBOX_ID ===" + run_phase stop "$SANDBOX_ID" + check "stop after $1 exits 0 for input $2" $? + SANDBOX_STARTED=0 + fi + echo "=== deprovision $SANDBOX_ID ===" + run_phase deprovision "$SANDBOX_ID" + local deprovision_rc=$? + check "deprovision after $1 exits 0 for input $2" "$deprovision_rc" + if [ "$deprovision_rc" -ne 0 ]; then + # SANDBOX_ID is the EXIT trap's only handle on a container that is + # still up, and the next case's provision overwrites it. Stop here + # so the trap can still reach it, and so its stray veth and + # iptables state never reaches a later case's assertions. + echo "ABORT: deprovision failed for $SANDBOX_ID; refusing to run later cases against a dirty host." + exit 1 + fi + SANDBOX_ID="" + fi +} + +container_host_veth() { + local name="$1" + local peer + + # Ask the container which host ifindex its own link is paired with, then + # resolve that index on the host. Nothing here trusts a name MXC chose, so + # a hook aimed at the wrong interface still fails. + # + # `lxc-info` reports a Link: line instead, but it walks lxc.net.N from 0 and + # stops at the first gap, so it reports nothing at all for a container whose + # interface is numbered above a hole -- which is exactly the topology case 10 + # builds. Reading it from the container's own kernel view works whatever the + # index. + peer="$(lxc-attach -n "$name" -- ip -o link 2>/dev/null \ + | sed -n 's/.*@if\([0-9][0-9]*\):.*/\1/p' | head -1)" + [ -n "$peer" ] || return 0 + ip -o link 2>/dev/null \ + | awk -v want="$peer" '{ idx = $1; sub(/:$/, "", idx); if (idx == want) { n = $2; sub(/[@:].*/, "", n); print n; exit } }' +} + +renumber_sole_interface() { + local index="$1" + local label="$2" + local name="${SANDBOX_ID#lxc:}" + local cfg="${LXC_PATH:-/var/lib/lxc}/$name/config" + + # A provisioned container is handed its interface by an include, always at + # lxc.net.0, so this is the only way to build the one topology that tells + # "MXC found the interface" apart from "MXC assumed index 0". Assigning an + # empty lxc.net clears what the include supplied; the numbered keys then + # declare the same interface somewhere else. + [ -f "$cfg" ] || fail_now "$label: no container config at $cfg to renumber." + { + echo "lxc.net =" + echo "lxc.net.$index.type = veth" + echo "lxc.net.$index.link = lxcbr0" + echo "lxc.net.$index.flags = up" + } >> "$cfg" + + echo "=== $label: renumbered sole interface to lxc.net.$index ===" + echo " lxc.net -> $(lxc-info -n "$name" -c lxc.net 2>&1 | tr '\n' ' ')" + echo " index 0 type -> $(lxc-info -n "$name" -c lxc.net.0.type 2>&1 | tr '\n' ' ')" + echo " index $index type -> $(lxc-info -n "$name" -c "lxc.net.$index.type" 2>&1 | tr '\n' ' ')" + + # Without these the case is vacuous: a renumber that silently failed leaves + # an ordinary index-0 container, every assertion below still passes, and the + # test reports success for a topology it never built. + if lxc-info -n "$name" -c lxc.net.0.type 2>/dev/null | grep -q .; then + fail_now "$label: renumber did not take -- liblxc still reports an interface at lxc.net.0, so this case would not exercise index independence." + fi + if ! lxc-info -n "$name" -c "lxc.net.$index.type" 2>/dev/null | grep -q "veth"; then + fail_now "$label: renumber did not take -- liblxc reports no veth at lxc.net.$index." + fi + if [ "$(lxc-info -n "$name" -c lxc.net 2>/dev/null | grep -c 'veth')" != "1" ]; then + fail_now "$label: renumber left the container with something other than exactly one interface." + fi +} + +run_start_case() { + local case_no="$1" + local config="$2" + local cause="$3" + local expected="$4" + local expect_success="$5" + local must_exec="$6" + local clause="$7" + local assert_default_deny="${8:-}" + local quarantine="${9:-}" + local renumber_to="${10:-}" + local req="$WORK_DIR/case_${case_no}.json" + local out rc actual status sentinel + + start_fresh_sandbox "case $case_no" + if [ -n "$renumber_to" ]; then + renumber_sole_interface "$renumber_to" "case $case_no" + fi + make_request_from_config "$config" "$req" + QUARANTINE_ACTIVE="$quarantine" + + echo "=== case $case_no start: $cause ===" + out="$($LXC_EXEC --experimental "$req" 2>&1)" + rc=$? + echo "$out" + + if [ "$expect_success" = "1" ]; then + if [ "$rc" -eq 0 ]; then + check "case $case_no start succeeds for input $config -- $clause" 0 + SANDBOX_STARTED=1 + actual="start exited 0" + status="PASS" + else + check "case $case_no start succeeds for input $config -- $clause" 1 + actual="start exited $rc: $(echo "$out" | tr '\n' ' ' | sed 's/|/ /g')" + status="FAIL" + fi + + if [ "$must_exec" = "1" ] && [ "$rc" -eq 0 ]; then + sentinel="MXC_STATE_AWARE_NETWORK_CASE_${case_no}_RAN" + echo "=== case $case_no exec: prove container is running ===" + out="$(run_phase exec "$SANDBOX_ID" '"process": { "commandLine": "echo '"$sentinel"'" }' 2>&1)" + rc=$? + echo "$out" + if [ "$rc" -eq 0 ] && echo "$out" | grep -Fq "$sentinel"; then + check "case $case_no exec observes running container for input $config -- $clause" 0 + actual="$actual; exec printed $sentinel" + else + check "case $case_no exec observes running container for input $config -- $clause" 1 + actual="$actual; exec rc=$rc output=$(echo "$out" | tr '\n' ' ' | sed 's/|/ /g')" + status="FAIL" + fi + fi + + if [ -n "$assert_default_deny" ] && [ "$rc" -eq 0 ]; then + local veth chain direct terminal + # The roadmap asks that the hook be applied, which a zero exit does + # not show: a run that skipped enforcement and still reported success + # would look identical here. Read the host instead, and read it + # through the interface the container actually ended up with rather + # than a name this test derived, so a hook aimed at the wrong + # interface cannot pass. + # + # The physdev form is the one that decides the case. lxcbr0 is a + # bridge, and on a bridged veth the plain `-i` rule installs cleanly + # and never matches, so asserting only that form would accept a + # container whose traffic walks past the chain untouched. + veth="$(container_host_veth "${SANDBOX_ID#lxc:}")" + chain="" + direct="" + if [ -n "$veth" ]; then + chain="$(iptables -S FORWARD 2>/dev/null \ + | awk -v v="$veth" 'index($0, "--physdev-in " v " -j ") { for (i = 1; i <= NF; i++) if ($i == "-j") { print $(i + 1); exit } }')" + direct="$(iptables -S FORWARD 2>/dev/null \ + | awk -v v="$veth" 'index($0, "-i " v " -j ") { for (i = 1; i <= NF; i++) if ($i == "-j") { print $(i + 1); exit } }')" + fi + + echo "=== case $case_no default-deny: live veth=${veth:-} physdev->${chain:-} direct->${direct:-} ===" + if [ "$assert_default_deny" = "none" ]; then + # Asserting the absence, rather than dropping the assertion, is + # what keeps this case able to fail: a start that quietly began + # installing chains again would still exit 0 and still run the + # container. + if [ -z "$chain" ] && [ -z "$direct" ]; then + check "case $case_no leaves live veth ${veth:-} out of every FORWARD chain -- $clause" 0 + actual="$actual; FORWARD carries no hook for veth ${veth:-}" + else + check "case $case_no leaves live veth ${veth:-} out of every FORWARD chain -- $clause" 1 + actual="$actual; physdev hook=${chain:-} direct hook=${direct:-} for veth ${veth:-}" + status="FAIL" + fi + elif [ -n "$veth" ] && [ -n "$chain" ] && [ "$direct" = "$chain" ]; then + check "case $case_no hooks live veth $veth into $chain on both the physdev and direct paths -- (N1) default-deny outbound" 0 + actual="$actual; FORWARD hooks $veth to $chain on both paths" + else + check "case $case_no hooks live veth ${veth:-} into a chain on both the physdev and direct paths -- (N1) default-deny outbound" 1 + actual="$actual; physdev hook=${chain:-} direct hook=${direct:-} for veth ${veth:-}" + status="FAIL" + fi + + if [ "$assert_default_deny" != "none" ]; then + terminal="$(iptables -S "$chain" 2>/dev/null | tail -1)" + echo "=== case $case_no terminal rule: ${terminal:-} ===" + if [ -n "$chain" ] && [ "${terminal##* }" = "DROP" ]; then + check "case $case_no hooked chain ends in DROP -- (N1) default-deny outbound" 0 + actual="$actual; chain ends in DROP" + else + check "case $case_no hooked chain ends in DROP -- (N1) default-deny outbound" 1 + actual="$actual; terminal rule was ${terminal:-}" + status="FAIL" + fi + fi + fi + else + if [ "$rc" -ne 0 ] && expect_error_code "$out" "policy_validation"; then + check "case $case_no start rejects input $config with policy_validation -- $clause" 0 + actual="start exited $rc with policy_validation" + status="PASS" + else + check "case $case_no start rejects input $config with policy_validation -- $clause" 1 + actual="start rc=$rc output=$(echo "$out" | tr '\n' ' ' | sed 's/|/ /g')" + status="FAIL" + if [ "$rc" -eq 0 ]; then + SANDBOX_STARTED=1 + fi + fi + fi + + # Clear before teardown so a genuine stop/deprovision failure is still a + # real failure rather than being absorbed by this case's quarantine. + if [ -n "$QUARANTINE_ACTIVE" ] && [ "$status" = "FAIL" ]; then + status="QUARANTINED" + fi + QUARANTINE_ACTIVE="" + + finish_current_sandbox "case $case_no" "$config" + record_result "$case_no" "$config" "$cause" "$expected" "$actual" "$status" +} + +run_hostloopback_block_case() { + local case_no="$1" + local clause="$2" + local cause='ingress.hostLoopback=deny with egress default allow, then reach the host' + local expected='the container cannot reach the host across the bridge' + local out rc actual status probe_cmd extra + + start_fresh_sandbox "case $case_no" + make_request_from_config "$CONFIG_HOSTLOOPBACK_DENY" "$WORK_DIR/case_${case_no}.json" + + echo "=== case $case_no start: $cause ===" + out="$($LXC_EXEC --experimental "$WORK_DIR/case_${case_no}.json" 2>&1)" + rc=$? + echo "$out" + if [ "$rc" -ne 0 ]; then + check "case $case_no start succeeds for input $CONFIG_HOSTLOOPBACK_DENY -- $clause" 1 + actual="start exited $rc: $(echo "$out" | tr '\n' ' ' | sed 's/|/ /g')" + finish_current_sandbox "case $case_no" "$CONFIG_HOSTLOOPBACK_DENY" + record_result "$case_no" "$CONFIG_HOSTLOOPBACK_DENY" "$cause" "$expected" "$actual" "FAIL" + return + fi + check "case $case_no start succeeds for input $CONFIG_HOSTLOOPBACK_DENY -- $clause" 0 + SANDBOX_STARTED=1 + + # The probe waits for its own default route before testing anything: LXC + # reports a container RUNNING before the guest holds an address, and a probe + # that ran first would find the host unreachable for the wrong reason. + # It then pings its own address, which no policy governs, so a container + # that cannot ping at all is reported as inconclusive instead of as blocked. + probe_cmd="sh -c 'i=0; gw=; while [ \$i -lt 30 ]; do set -- \$(ip route 2>/dev/null | grep -m1 ^default); gw=\$3; if [ x\$gw != x ]; then break; fi; i=\$((i+1)); sleep 1; done; if [ x\$gw = x ]; then echo MXC_HOST_NOGW; exit 0; fi; set -- \$(ip -o -4 addr show scope global 2>/dev/null | head -1); self=\$4; self=\${self%%/*}; if ping -c 1 -W 2 \$self >/dev/null 2>&1; then echo MXC_PING_OK; else echo MXC_PING_DEAD; fi; if ping -c 1 -W 2 \$gw >/dev/null 2>&1; then echo MXC_HOST_REACHABLE; else echo MXC_HOST_BLOCKED; fi'" + extra="\"process\": { \"commandLine\": \"$probe_cmd\" }" + + echo "=== case $case_no probe: reach the host from inside the container ===" + out="$(run_phase exec "$SANDBOX_ID" "$extra" 2>&1)" + rc=$? + echo "$out" + + if echo "$out" | grep -Fq "MXC_HOST_NOGW"; then + check "case $case_no probe obtained a default route -- $clause" 1 + actual="the container never obtained a default route, so the host path was never exercised" + status="FAIL" + elif ! echo "$out" | grep -Fq "MXC_PING_OK"; then + check "case $case_no probe can ping at all -- $clause" 1 + actual="the container could not ping its own address, so a blocked verdict would prove nothing" + status="FAIL" + elif echo "$out" | grep -Fq "MXC_HOST_BLOCKED"; then + check "case $case_no blocks container-to-host loopback -- $clause" 0 + actual="the container pinged itself and still could not reach the host" + status="PASS" + elif echo "$out" | grep -Fq "MXC_HOST_REACHABLE"; then + check "case $case_no blocks container-to-host loopback -- $clause" 1 + actual="the container reached the host across the bridge" + status="FAIL" + else + check "case $case_no probe produced a verdict -- $clause" 1 + actual="probe rc=$rc produced no verdict: $(echo "$out" | tr '\n' ' ' | sed 's/|/ /g')" + status="FAIL" + fi + + finish_current_sandbox "case $case_no" "$CONFIG_HOSTLOOPBACK_DENY" + record_result "$case_no" "$CONFIG_HOSTLOOPBACK_DENY" "$cause" "$expected" "$actual" "$status" +} + +run_provision_rejection_case() { + local case_no="$1" + local config="$2" + local cause="$3" + local clause="$4" + local expected='rejected with policy_validation' + local out rc actual status + + echo "=== case $case_no provision rejection: $cause ===" + out="$($LXC_EXEC --experimental "$config" 2>&1)" + rc=$? + echo "$out" + + if [ "$rc" -ne 0 ] && expect_error_code "$out" "policy_validation"; then + check "case $case_no provision rejects input $config with policy_validation -- $clause" 0 + actual="provision exited $rc with policy_validation" + status="PASS" + else + check "case $case_no provision rejects input $config with policy_validation -- $clause" 1 + actual="provision rc=$rc output=$(echo "$out" | tr '\n' ' ' | sed 's/|/ /g')" + status="FAIL" + SANDBOX_ID="$(printf '%s' "$out" | extract_sandbox_id)" + if [ -n "$SANDBOX_ID" ]; then + case "$SANDBOX_ID" in + lxc:mxc-*) ;; + *) fail_now "case $case_no unexpectedly returned unsafe-looking sandboxId '$SANDBOX_ID'; refusing to deprovision it." ;; + esac + fi + fi + finish_current_sandbox "case $case_no" "$config" + record_result "$case_no" "$config" "$cause" "$expected" "$actual" "$status" +} + +FS_RO_DIR="/mxc-e2e-ro" +FS_DENIED_DIR="/mxc-e2e-denied" +FS_SENTINEL="MXC_E2E_FS_SENTINEL" + +# The filesystem lists are the one part of the start policy whose effect is not +# visible in iptables, so this case reads it where it does show: from inside the +# container. Host directories are created first because the parser refuses paths +# that do not exist (roadmap item 8), and each carries a sentinel file so a mount +# that silently did not happen cannot be mistaken for one that did. +run_filesystem_start_case() { + local case_no="$1" + local config="$2" + local cause="$3" + local clause="$4" + local expected='start succeeds, the readonly path is readable but not writable, and the denied path is masked' + local req="$WORK_DIR/case_${case_no}.json" + local out rc actual status probe + + # These are absolute paths at the host root, so anything already there + # belongs to something else and deleting it would destroy data this test + # never created. + for fixture_dir in "$FS_RO_DIR" "$FS_DENIED_DIR"; do + if [ -e "$fixture_dir" ]; then + fail_now "case $case_no found a pre-existing host path '$fixture_dir'; refusing to delete a directory this test did not create. Remove it by hand if an interrupted run left it behind." + fi + done + mkdir -p "$FS_RO_DIR" "$FS_DENIED_DIR" || fail_now "case $case_no could not create its host fixture directories" + FS_FIXTURES_CREATED=1 + echo "$FS_SENTINEL" > "$FS_RO_DIR/sentinel" + echo "$FS_SENTINEL" > "$FS_DENIED_DIR/sentinel" + + start_fresh_sandbox "case $case_no" + make_request_from_config "$config" "$req" + + echo "=== case $case_no start: $cause ===" + out="$($LXC_EXEC --experimental "$req" 2>&1)" + rc=$? + echo "$out" + + if [ "$rc" -eq 0 ]; then + check "case $case_no start succeeds for input $config -- $clause" 0 + SANDBOX_STARTED=1 + actual="start exited 0" + status="PASS" + else + check "case $case_no start succeeds for input $config -- $clause" 1 + actual="start exited $rc: $(echo "$out" | tr '\n' ' ' | sed 's/|/ /g')" + status="FAIL" + fi + + if [ "$rc" -eq 0 ]; then + echo "=== case $case_no probe: read the mounts from inside the container ===" + probe="cat $FS_RO_DIR/sentinel 2>/dev/null; touch $FS_RO_DIR/probe 2>/dev/null && echo RO_WRITABLE || echo RO_READONLY; cat $FS_DENIED_DIR/sentinel 2>/dev/null && echo DENIED_VISIBLE || echo DENIED_HIDDEN" + out="$(run_phase exec "$SANDBOX_ID" '"process": { "commandLine": "'"$probe"'" }' 2>&1)" + rc=$? + echo "$out" + + if [ "$rc" -eq 0 ] && echo "$out" | grep -Fq "$FS_SENTINEL"; then + check "case $case_no readonlyPaths mounts the host directory into the container -- $clause" 0 + actual="$actual; readonly path carries the host sentinel" + else + check "case $case_no readonlyPaths mounts the host directory into the container -- $clause" 1 + actual="$actual; readonly path did not carry the host sentinel (exec rc=$rc)" + status="FAIL" + fi + + if echo "$out" | grep -Fq "RO_READONLY"; then + check "case $case_no readonlyPaths is mounted read-only -- $clause" 0 + actual="$actual; readonly path refused a write" + else + check "case $case_no readonlyPaths is mounted read-only -- $clause" 1 + actual="$actual; readonly path accepted a write" + status="FAIL" + fi + + if echo "$out" | grep -Fq "DENIED_HIDDEN"; then + check "case $case_no deniedPaths masks the host directory -- $clause" 0 + actual="$actual; denied path is masked" + else + check "case $case_no deniedPaths masks the host directory -- $clause" 1 + actual="$actual; denied path still exposed the host sentinel" + status="FAIL" + fi + fi + + finish_current_sandbox "case $case_no" "$config" + rm -rf "$FS_RO_DIR" "$FS_DENIED_DIR" + FS_FIXTURES_CREATED=0 + record_result "$case_no" "$config" "$cause" "$expected" "$actual" "$status" +} + +LXC_PROXY_REFUSAL='LXC state-aware start does not support network.proxy' + +# The proxy field needs its own case because neither wire form reaches the LXC +# verdict by the route the other cases use. +# +# A state-aware start may not carry `containment` -- the backend is fixed at +# provision and later phases route by sandboxId -- so the shared parser applies +# its backend-specific rules under the default backend. An external proxy url is +# refused there, before any LXC code runs. The builtin-test-server form skips +# that rule but is testing-only scaffolding gated centrally for every backend, +# so it needs --allow-testing-features to get past the gate. +# +# Both halves are asserted. The first opens the testing gate and requires LXC's +# own refusal *by its message*, because a case that accepted any non-zero exit +# would pass on the central gate alone and would still pass with LXC's refusal +# deleted. The second sends the production-shaped external form and requires only +# that it is refused -- which layer refuses it is not LXC's contract to state, +# but silently accepting it would leave the container talking past a proxy the +# caller believed was in force. +run_proxy_start_case() { + local case_no="$1" + local cause="$2" + local clause="$3" + local expected='start is refused, and the builtin form is refused by LXC itself' + local req="$WORK_DIR/case_${case_no}.json" + local out rc actual status + + start_fresh_sandbox "case $case_no" + make_request_from_config "$CONFIG_PROXY" "$req" + + echo "=== case $case_no start: $cause ===" + out="$($LXC_EXEC --experimental --allow-testing-features "$req" 2>&1)" + rc=$? + echo "$out" + + if [ "$rc" -ne 0 ] && expect_error_code "$out" "policy_validation" \ + && echo "$out" | grep -Fq "$LXC_PROXY_REFUSAL"; then + check "case $case_no start refuses the builtin proxy with LXC's own verdict -- $clause" 0 + actual="start exited $rc with LXC's policy_validation refusal" + status="PASS" + else + check "case $case_no start refuses the builtin proxy with LXC's own verdict -- $clause" 1 + actual="start rc=$rc output=$(echo "$out" | tr '\n' ' ' | sed 's/|/ /g')" + status="FAIL" + if [ "$rc" -eq 0 ]; then + SANDBOX_STARTED=1 + fi + fi + + if [ "$rc" -ne 0 ]; then + make_request_from_config "$CONFIG_PROXY_EXTERNAL" "$req" + echo "=== case $case_no start: external proxy url ===" + out="$($LXC_EXEC --experimental "$req" 2>&1)" + rc=$? + echo "$out" + if [ "$rc" -ne 0 ]; then + check "case $case_no start refuses an external proxy url rather than accepting it -- $clause" 0 + actual="$actual; external url refused" + else + check "case $case_no start refuses an external proxy url rather than accepting it -- $clause" 1 + actual="$actual; external url started the container" + status="FAIL" + SANDBOX_STARTED=1 + fi + fi + + finish_current_sandbox "case $case_no" "$CONFIG_PROXY" + record_result "$case_no" "$CONFIG_PROXY" "$cause" "$expected" "$actual" "$status" +} + +print_case_table() { + echo "| Case | Config file | Cause | Expected effect | Actual result | Status |" + echo "|---|---|---|---|---|---|" + printf '%s' "$RESULTS" | while IFS='|' read -r case_no config cause expected actual status; do + [ -n "$case_no" ] || continue + echo "| $case_no | $config | $cause | $expected | $actual | $status |" + done +} + +verify_fixture_contracts +mkdir -p "$WORK_DIR" || fail_now "could not create work directory $WORK_DIR" + +[ "$(id -u)" -eq 0 ] || skip "LXC state-aware network matrix UNVERIFIED — requires root for LXC." +command -v iptables >/dev/null 2>&1 || skip "LXC state-aware network matrix UNVERIFIED — iptables is not installed." +command -v ip6tables >/dev/null 2>&1 || skip "LXC state-aware network matrix UNVERIFIED — ip6tables is not installed." +command -v lxc-create >/dev/null 2>&1 || skip "LXC state-aware network matrix UNVERIFIED — LXC (lxc-create) is not installed." +[ -f "$LXC_EXEC" ] || skip "LXC state-aware network matrix UNVERIFIED — lxc-exec binary not built; run build.sh first." + +echo "Running LXC state-aware network policy matrix test..." + +# Cases 1, 2, 5, 6, and 13 omit `enforcementMode`. Under 0.8 the directional +# field set enforces on its own, and each of these installs a chain with no +# explicit mode asked for. Case 3 states that clause; these five pin it. +run_start_case "1" "$CONFIG_NO_NETWORK" \ + 'no network block at start' \ + 'start succeeds, exec proves the container runs, and FORWARD drops by default' \ + "1" "1" \ + 'an inherited default-deny policy installs a firewall with no enforcementMode asked for' \ + "1" + +run_start_case "2" "$CONFIG_BLOCK_CAPS" \ + 'defaultPolicy=block with enforcementMode omitted at start' \ + 'start succeeds, exec proves the container runs, and FORWARD drops by default' \ + "1" "1" \ + 'defaultPolicy=block alone installs a firewall' \ + "1" + +# +# This is the direct observable proof of roadmap item 13's "ensure hook is +# always applied": a stock provisioned container carries +# `lxc.include = /usr/share/lxc/config/common.conf`, and the default-deny hook +# has to reach it anyway. The assertion was previously quarantined because +# enumeration read the container's own config file, where an include hides +# whatever it pulls in; it is live now that enumeration asks liblxc, which has +# already resolved the include. +run_start_case "3" "$CONFIG_BLOCK_FIREWALL" \ + 'defaultPolicy=block with enforcementMode=firewall at start' \ + 'start succeeds, exec proves the container runs, and FORWARD drops by default' \ + "1" "1" \ + 'an explicit firewall mode remains accepted, but is not required for enforcement' \ + "1" + +run_start_case "4" "$CONFIG_ALLOW_CAPS" \ + 'defaultPolicy=allow with enforcementMode omitted at start' \ + 'start succeeds' \ + "1" "0" \ + 'a permissive default needs no iptables rule and does not require firewall enforcement' + +run_start_case "5" "$CONFIG_EMPTY_ALLOWED" \ + 'allowedHosts empty list with enforcementMode omitted at start' \ + 'start succeeds, exec proves the container runs, and FORWARD drops by default' \ + "1" "1" \ + 'an empty allowedHosts list installs a firewall' \ + "1" + +run_start_case "6" "$CONFIG_NONEMPTY_ALLOWED" \ + 'allowedHosts non-empty with enforcementMode omitted at start' \ + 'start succeeds, exec proves the container runs, and FORWARD drops by default' \ + "1" "1" \ + 'a non-empty allowedHosts list installs a firewall' \ + "1" + +# Clause: roadmap item 13 (N1) asks that default-deny outbound be enforced. It +# says nothing about how the container numbers its interfaces, and an interface +# at lxc.net.3 is exactly as filterable as one at lxc.net.0 -- enforcement must +# not read the index at all. This is the only case that separates "MXC enforced +# whichever interface the container has" from "MXC assumed index 0", so it is +# the one that fails if any index assumption is ever reintroduced. +run_start_case "10" "$CONFIG_BLOCK_FIREWALL" \ + 'defaultPolicy=block with enforcementMode=firewall on a container whose only interface is at lxc.net.3' \ + 'start succeeds, exec proves the container runs, and FORWARD drops by default' \ + "1" "1" \ + '(N1) default-deny outbound does not depend on the interface index' \ + "1" "" "3" + +# Clause: the LXC matrix marks network as rejected at provision. +run_provision_rejection_case "7" "$CONFIG_PROVISION_NETWORK" \ + 'network.defaultPolicy=block sent at provision' \ + 'matrix marks network as rejected at provision' + +# Clause: the LXC matrix marks a non-empty filesystem path list as rejected at +# provision. +run_provision_rejection_case "8" "$CONFIG_PROVISION_FILESYSTEM" \ + 'filesystem block with a populated path list sent at provision' \ + 'matrix marks a non-empty filesystem path list as rejected at provision' + +# Clause: roadmap item 13 (N1) names firewall enforcement, and the schema offers +# `both` alongside `firewall`. Case 3 pins `firewall`; nothing pinned `both`, so +# a mode that parsed but enforced nothing would have gone unnoticed. This asserts +# the same host-visible default-deny for it. +run_start_case "11" "$CONFIG_BLOCK_BOTH" \ + 'defaultPolicy=block with enforcementMode=both at start' \ + 'start succeeds, exec proves the container runs, and FORWARD drops by default' \ + "1" "1" \ + '(N1) default-deny outbound is enforced under enforcementMode=both' \ + "1" + +# Case 12 preserves compatibility for callers that explicitly request firewall enforcement. +run_start_case "12" "$CONFIG_ALLOWED_FIREWALL" \ + 'allowedHosts non-empty with enforcementMode=firewall at start' \ + 'start succeeds, exec proves the container runs, and FORWARD drops by default' \ + "1" "1" \ + '(N3) a non-empty allowedHosts policy remains enforceable with an explicit firewall mode' \ + "1" + +# The last of the enforcementMode-omitted group; see case 1. +run_start_case "13" "$CONFIG_BLOCKED_CAPS" \ + 'blockedHosts non-empty with enforcementMode omitted at start' \ + 'start succeeds, exec proves the container runs, and FORWARD drops by default' \ + "1" "1" \ + 'a config that never wrote enforcementMode still gets a firewall' \ + "1" + +# Clause: roadmap item 14 (N2) is explicit -- "reject `hostLoopback: "allow"` +# rather than guessing ports or exposing the container IP." The roadmap records +# `allowLocalNetwork` as "parsed but silently ignored", which is the behavior the +# item asks to replace. A silent ignore and a refusal both exit non-zero on +# nothing, so only asserting the error code separates them. +run_start_case "14" "$CONFIG_ALLOW_LOCAL" \ + 'allowLocalNetwork=true at start' \ + 'start fails with policy_validation' \ + "0" "0" \ + '(N2) allowLocalNetwork is rejected rather than silently ignored' + +# Clause: roadmap item 17 (N5) records the proxy field as one the backend +# ignores, and asks for env-var injection plus egress restriction to the proxy +# port. That work is not in this PR. What is in scope is item 13 (N1)'s posture: +# fail fast rather than silently skip. A start that accepted a proxy it cannot +# enforce would report success while the container talked past it, so it is +# refused until the enforcement item 17 describes exists. See +# run_proxy_start_case for why this case needs both wire forms. +run_proxy_start_case "15" \ + 'network.proxy set at start' \ + '(N5) an unenforceable proxy is refused rather than silently ignored' + +# Clause: the start phase accepts the filesystem lists it rejects at provision +# (case 8), and D1/D4 ask that readonly be readable-not-writable and that +# denied be masked. Case 8 only pins the provision-time refusal; without this, +# no case shows the lists ever take effect anywhere. +run_filesystem_start_case "16" "$CONFIG_FILESYSTEM_PATHS" \ + 'readonlyPaths and deniedPaths sent at start' \ + 'the start phase applies the filesystem lists it refuses at provision' + +# Clause: networking.md:112-113 -- "A backend that cannot enforce both +# directions must reject `hostLoopback: "allow"` rather than accept it with +# partial enforcement." Case 14 pins the 0.7 spelling of permissive inbound; +# this pins the 0.8 spelling, which reaches the same refusal through a +# different field. +run_start_case "17" "$CONFIG_HOSTLOOPBACK_ALLOW" \ + 'ingress.hostLoopback=allow at start' \ + 'start fails with policy_validation' \ + "0" "0" \ + 'hostLoopback allow is refused rather than accepted with partial enforcement' + +# Clause: networking.md:109 -- the specific `hostLoopback` value overrides +# `default` for the host-loopback path. Case 17 pins the value LXC refuses; +# without this, no state-aware case shows the field being accepted at all. +run_start_case "18" "$CONFIG_HOSTLOOPBACK_DENY" \ + 'ingress.hostLoopback=deny at start' \ + 'start succeeds and exec proves the container runs' \ + "1" "1" \ + 'the enforceable hostLoopback value is accepted' + +# Clause: networking.md:274 -- ingress.hostLoopback deny blocks both +# host-loopback directions -- read with networking.md:462, which names what +# this backend owes: "routing and output policy enforce its container-to-host +# half." Case 18 pins only that the value is accepted. The fixture pairs the +# deny with egress default allow, the one combination where the egress chain +# cannot block the host path incidentally, so without this case nothing shows +# the container-to-host half enforced anywhere. +run_hostloopback_block_case "19" \ + 'the container-to-host half of hostLoopback deny is enforced' + +echo "================================" +echo "Results: $PASSED passed, $FAILED failed, $QUARANTINED quarantined" +print_case_table +if [ "$QUARANTINED" -gt 0 ]; then + echo "" + echo "!!! $QUARANTINED assertion(s) QUARANTINED -- these behaviors are NOT verified !!!" + printf '%s' "$QUARANTINE_NOTES" +fi +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi diff --git a/tests/scripts/run_lxc_state_aware_phase_ordering_test.sh b/tests/scripts/run_lxc_state_aware_phase_ordering_test.sh new file mode 100644 index 000000000..768b320d1 --- /dev/null +++ b/tests/scripts/run_lxc_state_aware_phase_ordering_test.sh @@ -0,0 +1,205 @@ +#!/bin/bash +# LXC state-aware phase-ordering test. +# +# The lifecycle contract names twelve error codes and closes the set +# (docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md:1041-1070). Only +# malformed_request and policy_validation are exercised anywhere in +# tests/scripts. This covers the four that describe calling a phase against a +# sandbox in the wrong state, plus the one that describes calling any phase +# against a sandbox that is gone. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +CONFIG_DIR="$REPO_DIR/tests/configs" + +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +# Each string is the contract this test holds the backend to. A code that gets +# renamed breaks these rather than silently matching nothing and passing. +NOT_STARTED='not_started' +ALREADY_STARTED='already_started' +ALREADY_STOPPED='already_stopped' +STALE_ID='stale_id' + +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +[ "$(id -u)" -eq 0 ] || skip "requires root for LXC." +command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed." +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." +[ -f "$CONFIG_DIR/lxc_state_aware_provision.json" ] || skip "provision config is missing." + +WORK_DIR="$(mktemp -d)" +SANDBOX_ID="" +PASSED=0 +FAILED=0 +CLEANED_UP=0 + +cleanup() { + if [ "$CLEANED_UP" -ne 0 ]; then + return + fi + CLEANED_UP=1 + if [ -n "$SANDBOX_ID" ]; then + run_phase deprovision "$SANDBOX_ID" >/dev/null 2>&1 || true + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM + +run_phase() { + local phase="$1" + local sandbox_id="${2:-}" + local extra="${3:-}" + local req="$WORK_DIR/$phase.json" + + { + printf '{\n "version": "0.8.0-alpha",\n "phase": "%s"' "$phase" + if [ "$phase" = "provision" ]; then + printf ',\n "containment": "lxc"' + fi + if [ -n "$sandbox_id" ]; then + printf ',\n "sandboxId": "%s"' "$sandbox_id" + fi + if [ -n "$extra" ]; then + printf ',\n %s' "$extra" + fi + printf '\n}\n' + } > "$req" + + "$LXC_EXEC" --experimental "$req" +} + +extract_sandbox_id() { + sed -n 's/.*"sandboxId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 +} + +check() { + local name="$1" + local ok="$2" + if [ "$ok" = "0" ]; then + echo "PASS: $name" + PASSED=$((PASSED + 1)) + else + echo "FAIL: $name" + FAILED=$((FAILED + 1)) + fi +} + +# A refusal is two observations, and asserting only the exit code would pass on +# any failure at all, including a crash before the phase was ever dispatched. +expect_error_code() { + local name="$1" + local expected="$2" + local phase="$3" + local sandbox_id="$4" + local extra="${5:-}" + local out rc + + out="$(run_phase "$phase" "$sandbox_id" "$extra" 2>/dev/null)" + rc=$? + + if [ "$rc" -eq 0 ]; then + check "$name (exited 0; expected a refusal naming $expected)" 1 + return + fi + if printf '%s' "$out" | grep -Eq "\"code\"[[:space:]]*:[[:space:]]*\"$expected\""; then + check "$name" 0 + else + check "$name (envelope did not name $expected)" 1 + echo " got: $out" + fi +} + +echo "Running LXC state-aware phase-ordering test..." + +echo "=== provision ===" +PROVISION_OUT="$("$LXC_EXEC" --experimental "$CONFIG_DIR/lxc_state_aware_provision.json")" +if [ $? -ne 0 ]; then + echo "Cannot continue: provision failed." + echo "$PROVISION_OUT" + exit 1 +fi +SANDBOX_ID="$(printf '%s' "$PROVISION_OUT" | extract_sandbox_id)" +if [ -z "$SANDBOX_ID" ]; then + echo "Cannot continue: provision returned no sandboxId." + exit 1 +fi +echo "sandboxId: $SANDBOX_ID" + +# --- provisioned, not yet started ------------------------------------------ +echo "=== exec before start ===" +expect_error_code "exec before start is refused as $NOT_STARTED" \ + "$NOT_STARTED" exec "$SANDBOX_ID" '"process": { "commandLine": "echo unreachable" }' + +# LXC reads "never started" and "started and since stopped" from one probe, so +# stop answers for the state it can see rather than the history it cannot. +echo "=== stop before start ===" +expect_error_code "stop before start is refused as $ALREADY_STOPPED" \ + "$ALREADY_STOPPED" stop "$SANDBOX_ID" + +# --- started --------------------------------------------------------------- +echo "=== start ===" +run_phase start "$SANDBOX_ID" >/dev/null +check "start exits 0" $? + +echo "=== start again ===" +expect_error_code "a second start is refused as $ALREADY_STARTED" \ + "$ALREADY_STARTED" start "$SANDBOX_ID" + +# --- stopped --------------------------------------------------------------- +echo "=== stop ===" +run_phase stop "$SANDBOX_ID" >/dev/null +check "stop exits 0" $? + +echo "=== stop again ===" +expect_error_code "a second stop is refused as $ALREADY_STOPPED" \ + "$ALREADY_STOPPED" stop "$SANDBOX_ID" + +# --- deprovisioned --------------------------------------------------------- +echo "=== deprovision ===" +run_phase deprovision "$SANDBOX_ID" >/dev/null +DEPROVISION_RC=$? +check "deprovision exits 0" "$DEPROVISION_RC" + +# Deprovision also runs the authoritative network teardown, so a caller that +# retries after a failure mid-teardown has to be able to reach it again. +echo "=== deprovision again ===" +run_phase deprovision "$SANDBOX_ID" >/dev/null +check "a second deprovision exits 0" $? + +# The id still parses once the container is destroyed, which is what separates +# this refusal from malformed_id. +echo "=== start after deprovision ===" +expect_error_code "start against a destroyed sandbox is refused as $STALE_ID" \ + "$STALE_ID" start "$SANDBOX_ID" + +echo "=== exec after deprovision ===" +expect_error_code "exec against a destroyed sandbox is refused as $STALE_ID" \ + "$STALE_ID" exec "$SANDBOX_ID" '"process": { "commandLine": "echo unreachable" }' + +# Stop answered already_stopped while the container existed but had never run. +# Once it is destroyed the same call answers stale_id, because the probe that +# told those two apart no longer has a container to read. +echo "=== stop after deprovision ===" +expect_error_code "stop against a destroyed sandbox is refused as $STALE_ID" \ + "$STALE_ID" stop "$SANDBOX_ID" + +if [ "$DEPROVISION_RC" -eq 0 ]; then + SANDBOX_ID="" +fi + +echo "================================" +echo "Results: $PASSED passed, $FAILED failed" +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi diff --git a/tests/scripts/run_lxc_state_aware_routing_test.sh b/tests/scripts/run_lxc_state_aware_routing_test.sh new file mode 100644 index 000000000..77c71d772 --- /dev/null +++ b/tests/scripts/run_lxc_state_aware_routing_test.sh @@ -0,0 +1,217 @@ +#!/bin/bash +# LXC state-aware routing test. +# +# Every refusal here is decided before any backend runs, from the sandbox id +# alone. The contract states what a caller gets for an id that does not parse, +# an id naming a backend this build does not carry, and an id naming a backend +# that has no state-aware lifecycle +# (docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md:1044-1047). +# +# Nothing here creates a container, so this suite needs neither root nor a +# working LXC installation. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" + +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +# Each string is the contract this test holds the binary to. A code that gets +# renamed breaks these rather than silently matching nothing and passing. +MALFORMED_ID='malformed_id' +MALFORMED_REQUEST='malformed_request' +UNSUPPORTED_CONTAINMENT='unsupported_containment' +UNSUPPORTED_PHASE='unsupported_phase' +BACKEND_UNAVAILABLE='backend_unavailable' + +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." + +WORK_DIR="$(mktemp -d)" +PASSED=0 +FAILED=0 + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM + +check() { + local name="$1" + local ok="$2" + if [ "$ok" = "0" ]; then + echo "PASS: $name" + PASSED=$((PASSED + 1)) + else + echo "FAIL: $name" + FAILED=$((FAILED + 1)) + fi +} + +# Writes a start request carrying $1 as the sandbox id, or carrying no +# sandboxId at all when $1 is empty. +write_request() { + local sandbox_id="$1" + { + printf '{\n "version": "0.8.0-alpha",\n "phase": "start"' + if [ -n "$sandbox_id" ]; then + printf ',\n "sandboxId": "%s"' "$sandbox_id" + fi + printf '\n}\n' + } > "$WORK_DIR/request.json" +} + +# A refusal is two observations, and asserting only the exit code would pass on +# any failure at all, including a crash before the id was ever examined. +expect_error_code() { + local name="$1" + local expected="$2" + local sandbox_id="$3" + shift 3 + local out rc + + write_request "$sandbox_id" + out="$("$LXC_EXEC" "$@" "$WORK_DIR/request.json" 2>/dev/null)" + rc=$? + + if [ "$rc" -eq 0 ]; then + check "$name (exited 0; expected a refusal naming $expected)" 1 + return + fi + if printf '%s' "$out" | grep -Eq "\"code\"[[:space:]]*:[[:space:]]*\"$expected\""; then + check "$name" 0 + else + check "$name (envelope did not name $expected)" 1 + echo " got: $out" + fi +} + +echo "Running LXC state-aware routing test..." + +echo "=== ids that do not parse ===" +expect_error_code "an id with no prefix separator is refused as $MALFORMED_ID" \ + "$MALFORMED_ID" 'no-colon' +expect_error_code "an id with an empty prefix is refused as $MALFORMED_ID" \ + "$MALFORMED_ID" ':orphan' +expect_error_code "an id with an empty container name is refused as $MALFORMED_ID" \ + "$MALFORMED_ID" 'lxc:' --experimental +expect_error_code "an id with a space in the container name is refused as $MALFORMED_ID" \ + "$MALFORMED_ID" 'lxc:bad name' --experimental +expect_error_code "an id with a punctuation character is refused as $MALFORMED_ID" \ + "$MALFORMED_ID" 'lxc:bad!name' --experimental + +# The contract caps the container name at 20 characters. Asserting only the +# rejection would also pass if every name were rejected, which would make the +# five cases above meaningless. +echo "=== container-name length boundary ===" +expect_error_code "a 21-character container name is refused as $MALFORMED_ID" \ + "$MALFORMED_ID" 'lxc:aaaaaaaaaaaaaaaaaaaaa' --experimental + +write_request 'lxc:aaaaaaaaaaaaaaaaaaaa' +LEN20_OUT="$("$LXC_EXEC" --experimental "$WORK_DIR/request.json" 2>/dev/null)" +if printf '%s' "$LEN20_OUT" | grep -Eq "\"code\"[[:space:]]*:[[:space:]]*\"$MALFORMED_ID\""; then + check "a 20-character container name is not refused as $MALFORMED_ID" 1 + echo " got: $LEN20_OUT" +else + check "a 20-character container name is not refused as $MALFORMED_ID" 0 +fi + +echo "=== a missing sandboxId is a malformed request, not a malformed id ===" +expect_error_code "a start request with no sandboxId is refused as $MALFORMED_REQUEST" \ + "$MALFORMED_REQUEST" '' + +echo "=== prefixes this build does not serve ===" +expect_error_code "an unregistered prefix is refused as $UNSUPPORTED_CONTAINMENT" \ + "$UNSUPPORTED_CONTAINMENT" 'foo:bar' + +# The experimental gate is reached before the backend is asked whether it has a +# state-aware lifecycle, and the two refusals name different codes. +expect_error_code "an experimental backend without the opt-in is refused as $BACKEND_UNAVAILABLE" \ + "$BACKEND_UNAVAILABLE" 'wsb:0a1b2c3d' +expect_error_code "an ephemeral-only backend is refused as $UNSUPPORTED_PHASE" \ + "$UNSUPPORTED_PHASE" 'wsb:0a1b2c3d' --experimental +expect_error_code "a backend absent from this build is refused as $BACKEND_UNAVAILABLE" \ + "$BACKEND_UNAVAILABLE" 'wslc:0a1b2c3d' --experimental +expect_error_code "an lxc lifecycle call without the opt-in is refused as $BACKEND_UNAVAILABLE" \ + "$BACKEND_UNAVAILABLE" 'lxc:mxc-routingtest' + +echo "=== the runtime dependency is missing ===" +# §11.7 requires a feature-unavailable test: on a host without the LXC runtime, +# a phase reports backend_unavailable rather than panicking, hanging, or +# blaming the container. A PATH holding coreutils and no lxc-* tools is that +# host. +STRIPPED_BIN="$WORK_DIR/nolxc" +mkdir -p "$STRIPPED_BIN" +for tool in sh bash env cat sed grep printf; do + resolved="$(command -v "$tool" 2>/dev/null)" && ln -sf "$resolved" "$STRIPPED_BIN/$tool" +done + +{ + printf '{\n "version": "0.8.0-alpha",\n "phase": "provision",\n' + printf ' "containment": "lxc",\n "experimental": {\n "lxc": {\n' + printf ' "provision": { "distribution": "alpine", "release": "3.23" }\n' + printf ' }\n }\n}\n' +} > "$WORK_DIR/provision.json" + +# Without this the case passes whenever the strip failed and LXC was reachable +# all along, which is the one condition it exists to rule out. +if PATH="$STRIPPED_BIN" command -v lxc-info >/dev/null 2>&1; then + check "the stripped PATH hides the LXC tools" 1 +else + check "the stripped PATH hides the LXC tools" 0 + + UNAVAIL_OUT="$(PATH="$STRIPPED_BIN" "$LXC_EXEC" --experimental "$WORK_DIR/provision.json" 2>/dev/null)" + UNAVAIL_RC=$? + if [ "$UNAVAIL_RC" -eq 0 ]; then + check "provision without the LXC runtime is refused as $BACKEND_UNAVAILABLE (exited 0)" 1 + elif printf '%s' "$UNAVAIL_OUT" | grep -Eq "\"code\"[[:space:]]*:[[:space:]]*\"$BACKEND_UNAVAILABLE\""; then + check "provision without the LXC runtime is refused as $BACKEND_UNAVAILABLE" 0 + else + check "provision without the LXC runtime is refused as $BACKEND_UNAVAILABLE" 1 + echo " got: $UNAVAIL_OUT" + fi +fi + +echo "=== stdout carries the envelope and nothing else ===" +# The SDK tells a dispatch failure from a script that exited non-zero by +# parsing stdout whole (§7.3). That rule holds only while MXC keeps its own +# diagnostics on stderr, so anything printed alongside the envelope -- a +# warning, a progress line -- breaks a consumer rather than this test. +write_request 'lxc:bad name' +ENVELOPE_OUT="$("$LXC_EXEC" --experimental "$WORK_DIR/request.json" 2>/dev/null)" +if [ "$(printf '%s' "$ENVELOPE_OUT" | wc -l)" -eq 0 ] && + printf '%s' "$ENVELOPE_OUT" | grep -Eq '^\{.*"error".*\}$'; then + check "a refusal writes one envelope and nothing else to stdout" 0 +else + check "a refusal writes one envelope and nothing else to stdout" 1 + echo " got: $ENVELOPE_OUT" +fi + +# §7.3 reserves operation, nativeCode, and remediation for a failure that +# happened inside a platform API call. An id rejected by the parser never +# reached one, and a consumer reading those fields would be told an API failed +# when none ran. +for diagnostic_field in operation nativeCode remediation; do + if printf '%s' "$ENVELOPE_OUT" | grep -q "\"$diagnostic_field\""; then + check "a refusal raised outside any API call omits $diagnostic_field" 1 + echo " got: $ENVELOPE_OUT" + else + check "a refusal raised outside any API call omits $diagnostic_field" 0 + fi +done + +echo "================================" +echo "Results: $PASSED passed, $FAILED failed" +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi diff --git a/tests/scripts/run_lxc_state_aware_test.sh b/tests/scripts/run_lxc_state_aware_test.sh new file mode 100755 index 000000000..470d962af --- /dev/null +++ b/tests/scripts/run_lxc_state_aware_test.sh @@ -0,0 +1,195 @@ +#!/bin/bash +# LXC state-aware lifecycle test. +# +# Drives the full provision -> start -> exec -> stop -> deprovision sequence +# against lxc-exec, relaying the sandboxId the way a real client does. This is +# the Linux counterpart to run_isolation_session_state_aware_tests.ps1 and +# run_windows_sandbox_state_aware_tests.ps1. +# +# Until this existed, tests/configs/lxc_state_aware_provision.json was checked +# in but never executed by anything, so no test covered the LXC lifecycle end to +# end: the unit tests stub the container, and run_lxc_all_tests.sh only exercised +# the one-shot path. A phase that failed on a real host would ship green. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +CONFIG_DIR="$REPO_DIR/tests/configs" + +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +# An honest skip for a missing prerequisite: exit 77 so run_lxc_all_tests.sh +# records SKIPPED rather than FAILED. Without this the lifecycle runs anyway and +# dies at provision on any host without LXC, which reads as a broken test rather +# than an unmet prerequisite. The provision config asks for no network, so this +# probes only root, the LXC runtime, and the binary. +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +[ "$(id -u)" -eq 0 ] || skip "requires root for LXC." +command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed." +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." + +WORK_DIR="$(mktemp -d)" +SANDBOX_ID="" +PASSED=0 +FAILED=0 +CLEANED_UP=0 + +# Always attempt to tear the container down, including on an early failure or a +# signal. A leaked container outlives the test run and breaks the next one, so +# this is best-effort and deliberately ignores its own exit status. +# +# Bash resumes the script after an INT or TERM handler returns, so those two +# need handlers that clean up and then exit rather than letting the run carry +# on with the work directory already gone. Each exit fires the EXIT trap as +# well, so the guard keeps the deprovision to one run rather than two. +cleanup() { + if [ "$CLEANED_UP" -ne 0 ]; then + return + fi + CLEANED_UP=1 + if [ -n "$SANDBOX_ID" ]; then + echo "--- cleanup: deprovision $SANDBOX_ID ---" + run_phase deprovision "$SANDBOX_ID" >/dev/null 2>&1 || true + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM + +# Emit a state-aware request for $1 (phase) with sandboxId $2, plus any extra +# JSON members in $3, then run it. Envelope goes to stdout, diagnostics to +# stderr, so the caller can parse stdout directly. +run_phase() { + local phase="$1" + local sandbox_id="${2:-}" + local extra="${3:-}" + local req="$WORK_DIR/$phase.json" + + { + printf '{\n "version": "0.8.0-alpha",\n "phase": "%s"' "$phase" + # The parser rejects a non-provision envelope that carries containment; + # the backend is fixed at provision and later phases route by sandboxId. + if [ "$phase" = "provision" ]; then + printf ',\n "containment": "lxc"' + fi + if [ -n "$sandbox_id" ]; then + printf ',\n "sandboxId": "%s"' "$sandbox_id" + fi + if [ -n "$extra" ]; then + printf ',\n %s' "$extra" + fi + printf '\n}\n' + } > "$req" + + "$LXC_EXEC" --experimental "$req" +} + +# Pull sandboxId out of a result envelope without depending on jq or python, +# neither of which is guaranteed on an LXC test host. +extract_sandbox_id() { + sed -n 's/.*"sandboxId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 +} + +check() { + local name="$1" + local ok="$2" + if [ "$ok" = "0" ]; then + echo "PASS: $name" + PASSED=$((PASSED + 1)) + else + echo "FAIL: $name" + FAILED=$((FAILED + 1)) + fi +} + +echo "Running LXC state-aware lifecycle test..." + +# --- provision ------------------------------------------------------------- +# Uses the checked-in config so the distribution/release stay in one place. +echo "=== provision ===" +PROVISION_OUT="$("$LXC_EXEC" --experimental "$CONFIG_DIR/lxc_state_aware_provision.json")" +PROVISION_RC=$? +check "provision exits 0" "$PROVISION_RC" +echo "$PROVISION_OUT" + +SANDBOX_ID="$(printf '%s' "$PROVISION_OUT" | extract_sandbox_id)" +if [ -n "$SANDBOX_ID" ]; then + check "provision returns a sandboxId" 0 +else + check "provision returns a sandboxId" 1 + echo "Cannot continue without a sandboxId." + echo "Results: $PASSED passed, $FAILED failed" + exit 1 +fi + +case "$SANDBOX_ID" in + lxc:mxc-*) check "sandboxId has the lxc:mxc- prefix ($SANDBOX_ID)" 0 ;; + *) check "sandboxId has the lxc:mxc- prefix ($SANDBOX_ID)" 1 ;; +esac + +# --- start ----------------------------------------------------------------- +echo "=== start ===" +run_phase start "$SANDBOX_ID" +check "start exits 0" $? + +# --- exec ------------------------------------------------------------------ +# Exec relays the script's own exit code rather than an envelope, so these two +# assert the code directly: a successful command and a deliberate failure. +echo "=== exec (success) ===" +run_phase exec "$SANDBOX_ID" '"process": { "commandLine": "echo hello-from-lxc" }' +check "exec relays exit code 0" $? + +echo "=== exec (nonzero) ===" +run_phase exec "$SANDBOX_ID" '"process": { "commandLine": "exit 7" }' +EXEC_RC=$? +if [ "$EXEC_RC" = "7" ]; then + check "exec relays a nonzero exit code (got 7)" 0 +else + check "exec relays a nonzero exit code (got $EXEC_RC, want 7)" 1 +fi + +echo "=== exec (timeout) ===" +# A script that outruns process.timeout surfaces as a sentinel exit code +# rather than an error envelope. Returning well before the sleep could have +# finished is what separates the kill from any other nonzero exit. +TIMEOUT_STARTED=$(date +%s) +run_phase exec "$SANDBOX_ID" '"process": { "commandLine": "sleep 30", "timeout": 1000 }' >/dev/null 2>&1 +TIMEOUT_RC=$? +TIMEOUT_ELAPSED=$(( $(date +%s) - TIMEOUT_STARTED )) +if [ "$TIMEOUT_RC" -ne 0 ] && [ "$TIMEOUT_ELAPSED" -lt 25 ]; then + check "exec kills a script that outruns process.timeout (rc=$TIMEOUT_RC, ${TIMEOUT_ELAPSED}s)" 0 +else + check "exec kills a script that outruns process.timeout (rc=$TIMEOUT_RC, ${TIMEOUT_ELAPSED}s)" 1 +fi + +# --- stop ------------------------------------------------------------------ +echo "=== stop ===" +run_phase stop "$SANDBOX_ID" +check "stop exits 0" $? + +# --- deprovision ----------------------------------------------------------- +echo "=== deprovision ===" +run_phase deprovision "$SANDBOX_ID" +DEPROVISION_RC=$? +check "deprovision exits 0" "$DEPROVISION_RC" +# Release the ID only once the container is actually gone. Clearing it after a +# failed deprovision disarms the trap's retry, so the container the test could +# not remove leaks into the next run. +if [ "$DEPROVISION_RC" -eq 0 ]; then + SANDBOX_ID="" +fi + +echo "================================" +echo "Results: $PASSED passed, $FAILED failed" +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi diff --git a/tests/scripts/run_lxc_state_aware_version_floor_test.sh b/tests/scripts/run_lxc_state_aware_version_floor_test.sh new file mode 100644 index 000000000..359e187bd --- /dev/null +++ b/tests/scripts/run_lxc_state_aware_version_floor_test.sh @@ -0,0 +1,244 @@ +#!/bin/bash +# LXC state-aware schema version floor test. +# +# The state-aware lifecycle shipped after the 0.7 schema was cut. LXC accepts a +# lifecycle request only from a config declaring 0.8.0 or later. This pins that +# boundary from the outside: a config goes in, an exit status and a response +# envelope come out. +# +# cause effect +# ---------------------------------------- ------------------------------- +# lifecycle request declaring 0.7 refused, naming the floor +# lifecycle request declaring 0.6 refused, naming the floor +# lifecycle request declaring no version refused, naming the floor +# lifecycle request on a later phase, 0.7 refused, naming the floor +# lifecycle request with a nonsense version refused +# lifecycle request declaring 0.8 admitted +# one-shot request declaring 0.7 admitted +# +# The last row is the control that matters most. Committed one-shot fixtures +# declare 0.7, and the floor must leave every one of them alone. +# +# The fourth row is the second control: the floor guards the lifecycle entry +# point, not the provision phase, and a later phase must be refused the same +# way. +# +# Below-floor requests are written to temp files rather than committed under +# tests/configs. A committed config that fails dev-schema validation has to be +# listed in scripts/versioning/config-validation-exemptions.json, and that list +# is deliberately empty. +# +# Every assertion lands during config parsing or at the lifecycle entry point. +# The script needs no root, no LXC runtime and no iptables. --dry-run keeps it +# from creating anything, and an admitted request is allowed to come back +# reporting the runtime is missing. run_lxc_all_tests.sh still +# requires root before it dispatches anything, which makes that property matter +# only when this script is invoked directly. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +CONFIG_DIR="$REPO_DIR/tests/configs" + +# Quoted exactly as the binary emits it, which makes it part of the observable +# contract. Moving the floor in the code without moving it here fails the run +# rather than passing against a stale expectation. +EXPECTED_FLOOR='0.8.0' +REFUSAL_CODE='"code":"malformed_request"' +# What a host with no LXC tooling answers once a request has cleared the floor. +# Quoted as the binary emits it, like the refusal code above. +RUNTIME_ABSENT_CODE='"code":"backend_unavailable"' + +# An admitted lifecycle request under --dry-run, and a fixture that already +# declares the floor version. +ADMITTED_CONFIG="$CONFIG_DIR/lxc_state_aware_provision.json" +# A committed one-shot fixture that declares 0.7, which the floor must ignore. +ONE_SHOT_CONFIG="$CONFIG_DIR/lxc_network_enforcement_allow.json" + +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +# An honest skip for a missing prerequisite: exit 77 so run_lxc_all_tests.sh +# records SKIPPED rather than PASS. +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +PASSED=0 +FAILED=0 + +fail() { + echo "FAIL: $1" + FAILED=$((FAILED + 1)) +} + +pass() { + echo "PASS: $1" + PASSED=$((PASSED + 1)) +} + +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." +for c in "$ADMITTED_CONFIG" "$ONE_SHOT_CONFIG"; do + [ -f "$c" ] || { echo "FAIL: fixture is missing: $c"; exit 1; } +done + +WORK_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$WORK_DIR" + return 0 +} +trap cleanup EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM + +# The admitted fixture is the drift guard for the floor itself. If someone +# raises the floor in the code and leaves the fixtures behind, the whole +# state-aware suite goes red; catching it here names the reason. +if ! grep -q "\"version\"[[:space:]]*:[[:space:]]*\"$EXPECTED_FLOOR" "$ADMITTED_CONFIG"; then + fail "the admitted fixture no longer declares $EXPECTED_FLOOR: $ADMITTED_CONFIG" +fi + +# Write a provision request declaring $1 as its version, or no version at all +# when $1 is empty. Echoes the path it wrote. +write_provision_request() { + local version="$1" + local path="$WORK_DIR/provision-${version:-none}.json" + { + printf '{\n' + if [ -n "$version" ]; then + printf ' "version": "%s",\n' "$version" + fi + printf ' "phase": "provision",\n' + printf ' "containment": "lxc",\n' + printf ' "experimental": {\n' + printf ' "lxc": {\n' + printf ' "provision": { "distribution": "alpine", "release": "3.23" }\n' + printf ' }\n' + printf ' }\n' + printf '}\n' + } > "$path" + echo "$path" +} + +# Run a config and leave its exit status in LAST_STATUS and its envelope in +# LAST_STDOUT. Diagnostics go to stderr, which is not part of the contract +# under test. +LAST_STATUS=0 +LAST_STDOUT='' +run_request() { + set +e + LAST_STDOUT="$("$LXC_EXEC" --dry-run "$1" 2>/dev/null)" + LAST_STATUS=$? + set -e +} + +run_lifecycle_request() { + set +e + LAST_STDOUT="$("$LXC_EXEC" --dry-run --experimental "$1" 2>/dev/null)" + LAST_STATUS=$? + set -e +} + +assert_refused_naming_floor() { + local label="$1" + if [ "$LAST_STATUS" -eq 0 ]; then + fail "$label: expected a refusal, got exit 0 and: $LAST_STDOUT" + return + fi + case "$LAST_STDOUT" in + *"$REFUSAL_CODE"*) ;; + *) fail "$label: expected $REFUSAL_CODE, got: $LAST_STDOUT"; return ;; + esac + case "$LAST_STDOUT" in + *"$EXPECTED_FLOOR"*) ;; + *) fail "$label: refusal does not name the $EXPECTED_FLOOR floor: $LAST_STDOUT"; return ;; + esac + pass "$label" +} + +assert_refused() { + local label="$1" + if [ "$LAST_STATUS" -eq 0 ]; then + fail "$label: expected a refusal, got exit 0 and: $LAST_STDOUT" + return + fi + case "$LAST_STDOUT" in + *"$REFUSAL_CODE"*) pass "$label" ;; + *) fail "$label: expected $REFUSAL_CODE, got: $LAST_STDOUT" ;; + esac +} + +# Admission means the request cleared the version floor. On a host with no LXC +# tooling a cleared request comes back backend_unavailable rather than a result, +# and reaching the backend at all is proof the floor let it through. +assert_admitted() { + local label="$1" + case "$LAST_STDOUT" in + *"$EXPECTED_FLOOR or later"*) + fail "$label: refused over its schema version: $LAST_STDOUT" + return + ;; + esac + if [ "$LAST_STATUS" -eq 0 ]; then + pass "$label" + return + fi + case "$LAST_STDOUT" in + *"$RUNTIME_ABSENT_CODE"*) pass "$label" ;; + *) fail "$label: expected admission, got exit $LAST_STATUS and: $LAST_STDOUT" ;; + esac +} + +run_lifecycle_request "$(write_provision_request '0.7.0-alpha')" +assert_refused_naming_floor "a 0.7 lifecycle request is refused" + +run_lifecycle_request "$(write_provision_request '0.6.0-alpha')" +assert_refused_naming_floor "a 0.6 lifecycle request is refused" + +run_lifecycle_request "$(write_provision_request '')" +assert_refused_naming_floor "a lifecycle request with no declared version is refused" + +# A later phase carries no provision fields, and the floor must still refuse it. +LATER_PHASE="$WORK_DIR/start-0.7.json" +cat > "$LATER_PHASE" <<'JSON' +{ + "version": "0.7.0-alpha", + "phase": "start", + "sandboxId": "lxc:mxc-version-floor-probe" +} +JSON +run_lifecycle_request "$LATER_PHASE" +assert_refused_naming_floor "a 0.7 lifecycle request on a later phase is refused" + +# A version that is not semver is refused before the floor is ever compared. +# The caller sees the same code either way, which is what this pins. +NONSENSE="$WORK_DIR/provision-nonsense.json" +cat > "$NONSENSE" <<'JSON' +{ + "version": "banana", + "phase": "provision", + "containment": "lxc", + "experimental": { + "lxc": { + "provision": { "distribution": "alpine", "release": "3.23" } + } + } +} +JSON +run_lifecycle_request "$NONSENSE" +assert_refused "a lifecycle request with a nonsense version is refused" + +run_lifecycle_request "$ADMITTED_CONFIG" +assert_admitted "a lifecycle request declaring the floor version is admitted" + +run_request "$ONE_SHOT_CONFIG" +assert_admitted "a 0.7 one-shot request is untouched by the lifecycle floor" + +echo +echo "Passed: $PASSED" +echo "Failed: $FAILED" +[ "$FAILED" -eq 0 ]