Files
OpenShell/crates/openshell-server
Jesse Jaggars 2c31ac7e60 feat(network): support additional destination CAs (#3292)
* feat(network): support additional destination CAs

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(network): harden additional CA propagation

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(network): narrow additional CA review scope

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(network): harden additional CA rotation

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* feat(network): complete additional CA delivery

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(docker): remove stale runtime fixture field

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(network): adapt additional CA series to upstream main

Resolve rebase fallout against current upstream main after the series was
rebased 214 commits forward:

- follow upstream #3110: supervisor→gateway TLS is CA-only, so
  configure_gateway_tls and its isolation tests drop the client identity
- follow upstream tls_secret_names reduction to [String; 1] in the Podman
  driver while keeping the network trust bundle delivery
- port [openshell.supervisor.network] rendering into the
  openshell.gatewayConfigToml serializer in _toml.tpl
- adapt tests to upstream struct/function changes (DriverStartupContext,
  SandboxRunConfig, IsolationSpecInput, supervisor_pod, VM bootstrap image)
- drop conformance-artifact-prefix plumbing removed upstream

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* refactor(network): adopt upstream additional CA bundle surface

Unify the additional destination CA series on the mechanism merged to
main in #3996: one operator pre-assembled PEM bundle, the
--additional-ca-bundle supervisor flag, and the
/etc/openshell/tls/additional/ca-bundle.pem staging path.

- Replace the gateway-level additional_ca_cert_paths list with a single
  additional_ca_bundle file validated and staged at gateway startup.
- Restore the upstream supervisor argument path and fold-into-roots
  behavior; drop the --network-additional-ca-bundle and
  --network-additional-ca-digest contracts.
- Keep gateway normalization bounds, content-addressed staging,
  digest-based driver reconciliation, and stop/start rotation; the
  freshness check now runs at the gateway/driver boundary instead of
  the supervisor boundary.
- Reject additional_ca_bundle in operator driver JSON config; standalone
  drivers self-source the bundle from --sandbox-additional-ca-bundle
  with the same validation.
- Extend the Kubernetes ConfigMap name digest segment to keep the full
  63-byte budget after the prefix rename.
- Box the persisted-sandbox reconciliation futures so callers stay
  under the clippy::large_futures threshold.

Updates Docker, Podman, Kubernetes, and VM delivery, Helm chart and
tests, e2e wiring, gateway configuration docs, and the cluster
debugging skill to the single mechanism.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(ci): make gateway startup tests wrap-tolerant and refresh example lockfile

The network trust startup tests asserted on raw substrings of gateway
diagnostics, but the CLI renderer wraps output at a fixed width with
continuation markers and hard-breaks long paths. Compare assertions on
whitespace- and continuation-squashed text so they hold regardless of
wrap position.

Format the e2e workspace (separate cargo workspace, not covered by the
root cargo fmt run) and refresh examples/isolation-backend-v8/Cargo.lock
for the libc dependency added to the network trust crate.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(podman): reuse TLS CA secret during stopped-pair trust replacement

The additional CA e2e never ran in CI before the first maintainer test
dispatch, and its local container inspection was broken in two ways: it
located the supervisor container through e2e network membership (the
supervisor is host-networked or runtime-bridged, so the filter matched
the CI job container instead), and it required the gateway mTLS CA as a
bind mount (Podman delivers it as a container secret when user
namespaces remap UIDs). Select the supervisor by managed labels and
accept the secret delivery form.

The lifecycle leg then exposed a driver bug: stopped-pair trust
replacement re-created the per-sandbox TLS CA secret, colliding with
the secret still owned by the renamed stopped pair. Mirror the token
and resolver secret handling: require and reuse the existing secret
during replacement instead of creating it.

Validated end-to-end with the Podman additional CA e2e lane.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(server): restore upstream recovery semantics for start-request failures

Recovery no longer marks sandboxes Error/InvalidPersistedSandbox when
driver_start_request fails during the startup or lifecycle sweeps.
Gateway configuration problems (for example a missing launch signer)
must not permanently fail unrelated sandbox records; the sweep logs a
warning and skips them, matching the behavior before the additional CA
work. Sandbox-record failures surface through the normal start path.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* refactor(podman): refresh stale supervisors without replacing workloads

Replace the stopped-pair trust reconciliation with supervisor-only
recreation. The destination-trust generation label moves from the
workload to the supervisor container, and start_sandbox recreates only
the supervisor when its generation is missing or stale. The workload
container, its volumes, its secrets, and its workspace data are never
replaced: the additional CA bundle is mounted only into the supervisor.

Running sandboxes are never gated on trust labels, and recovery without
fresh launch credentials keeps the existing supervisor. The intentional
-removal fence machinery in the watcher existed only for workload
replacement and is removed with it.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* refactor(docker): drop workload replacement for destination-trust changes

Delete the stopped-workload trust reconciliation path and all of its
machinery: the trust-generation label on workload containers, the
generation match check in the start fence, the stopped-sandbox
replacement, the bounded workspace archive copy with its 512 MiB cap
and regular-file-only validation, and the boundary-state rollback
helpers.

No replacement machinery is needed: the trusted supervisor container
is already re-created from the gateway's current configuration on
every start via ensure_control_process_for_container, so stop/start
picks up the current additional CA bundle while the workload
container, its volumes, and its workspace data are never replaced.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* docs(configuration): drop reconciliation wording from additional CA lifecycle

The stop/start lifecycle description already matches the
supervisor-only refresh model; remove the two remaining references to
restart reconciliation of workload containers.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(network): port additional CA test fixtures to the crypto interface

Route the gateway TLS isolation test PKI, the destination CA source
fixtures, and the gateway startup trust tests through
openshell_crypto::pki now that rcgen access is centralized there:
generate_keypair/self_signed/signed_by replace the direct rcgen calls
and CertifiedKey field access follows the wrapper's shape.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(e2e): repair additional CA lane for Docker and non-container builds

The first maintainer test dispatch revealed two latent issues in the
additional CA e2e that no local run had exercised: the Podman gateway
CA secret helper referenced ContainerEngine without the container
feature gate, breaking Kubernetes and VM builds of the test, and the
Docker branch asserted a client ca.crt bind mount that the Docker
driver never creates. Gate the helper like its callers, and assert
Docker's actual delivery path instead: the gateway mTLS CA staged as
tls/ca.pem inside the read-only supervisor state volume, selected
through the OPENSHELL_TLS_CA environment pointer.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(helm): restore alphabetical README ordering for supervisor keys

The helm:docs check regenerates the chart README with helm-docs, which
orders rows alphabetically; a conflict resolution had placed
supervisor.network.additionalCaConfigMapName after the
supervisor.podSecurityContext rows. Regenerate with mise run
helm:docs.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* chore: refresh testkit lockfile for openshell-core libc dependency

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(driver-podman): stage operator CA bundle as driver-owned artifact

Standalone Podman drivers accepting --sandbox-additional-ca-bundle
previously kept the operator's source path as the trust artifact path.
The immutable-artifact verification performed on every create/start
requires exactly mode 0444, which an operator-owned source path cannot
guarantee, so a readable-but-writable source failed reconciliation.

Read the operator bundle with the same bounded, no-symlink validation,
then stage the normalized material into a driver-owned artifact inside a
private tempdir held for the driver process lifetime. The artifact is
created 0600, synced, and only then restricted to 0444 so no observer
ever sees a writable artifact. The operator source and its permissions
are never referenced after startup.

Addresses GATOR-291f43b2-02.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(driver-podman): pin retained workload image during supervisor refresh

Supervisor-only trust reconciliation re-entered
create_sandbox_with_existing_token, which re-pulled and re-resolved the
workload image tag even though the workload container is retained. If
the tag moved to a different image between stop and start, identity
resolution and child-environment construction would reflect the new
image while the retained workload still runs the original one, leaving
mode-0600 bootstrap files the retained user cannot read.

During a refresh with a retained workload, inspect the stopped container
and pin to the immutable image ID it was created from (Config.Image);
never pull or resolve the request tag. The requested reference is still
used for child metadata so the retained workload's environment is
byte-identical to its original launch.

Addresses GATOR-291f43b2-01.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* test(e2e): verify host-only VM trust delivery and drop curl dependency

The additional-CA lane's VM assertions read the destination CA out of the
guest overlay and a guest-side supervisor-argv marker — artifacts from
the guest-supervisor design that no longer exist now that the VM driver
stages the CA host-only and runs the supervisor on the host. Inspect the
driver-staged host artifact (digest-compared against the source), assert
the guest overlay contains no CA material at the old mount paths, and
keep the serial-console no-disclosure check.

The workload validation scripts also ran curl, which exists only in the
container-engine fixture image; the Kubernetes and VM lanes use the
uv-based workload image and died instantly with "canonical main process
already finished". Rewrite the main-process, exec, and lifecycle
validation scripts in python3 (present in every lane's workload image).
The network-policy binary identity is the resolved /proc/<pid>/exe
target, so the versioned python3.12 binaries are allowlisted rather
than the unversioned symlinks. The hostname mismatch check keeps its
semantics: same reachable fixture, mismatched TLS SNI, and the failure
must specifically be hostname verification.

Addresses GATOR-291f43b2-04.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(kubernetes): allow admission-added labels on destination-trust ConfigMaps

The destination-trust ConfigMap validation compared the whole label map
for equality. A mutating admission webhook that attaches an
organizational label to the created ConfigMap made the create response
fail validation, and the retained ConfigMap then failed every subsequent
create/start through the existing-object path, blocking sandbox launches
despite the ownership labels, immutable flag, digest, and certificate
bytes remaining correct.

Validate the gateway-owned label pairs individually and allow unrelated
labels; the exact trust-material checks are unchanged. Covered by a
regression test with an admission-added label and a missing
gateway-owned label.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(podman): close supervisor-refresh rollback gaps

The supervisor-only trust refresh renamed the previous supervisor to a
random rollback name and then performed the token-secret lookup. A
transient lookup failure returned with the supervisor still renamed,
where a normal delete could never find it, and the rollback rename and
final rollback removal ignored their own failures, leaving orphans with
attached control and channel resources.

Complete every fallible preparation before the rename. On a failed
replacement, restore the canonical name; if the restore itself fails,
return an error naming the rollback container so the orphan stays
identifiable — a later retry still succeeds because the canonical name
is absent and the replacement path recreates a fresh supervisor. Treat
not-found during the final rollback removal as success and name the
orphan in both the log and the error on other removal failures; a retry
observes the fresh canonical supervisor and resumes the normal start
path.

Covered by failure-injection tests for each transaction boundary:
preparation failure before any rename, replacement failure restoring
the canonical name, and restore failure naming the orphaned rollback
container.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* docs: address destination-trust review corrections

The gateway-configuration RFC documented an additional_ca_cert_paths
list that the strict configuration parser never accepted; document the
additional_ca_bundle single-path setting the parser actually enforces.

The debug-openshell-cluster skill named the wrong generation annotation
(openshell.ai/network-supervisor-trust-generation) and described digest
argv and network-init canonicalization checks the implementation does
not perform. Correct it to openshell.ai/additional-ca-generation and
describe the actual delivery: the supervisor Pod mounts the immutable
ConfigMap read-only and receives --additional-ca-bundle for the mount
path, with the driver validating the digest annotation and exact bytes
per creation.

Classify the destination-trust generation hashes as deterministic
identity derivation rather than authenticity proofs, following the
openshell-crypto exclusions table: trust decisions come from the
gateway's normalization, immutable staging, exact-byte validation, and
TLS verification. Record the classification in the crypto README and at
each hashing site.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(network): reconcile additional CA tests with suspend/resume and auth changes

Upstream's suspend/resume lifecycle took StartSandboxRequest fields 7
and 8, so the durable-snapshot field moves from 7 to 9; update the
wire-format test to assert the field-9 key (0x4a) and initialize the
new resume controls. Repair the cli test module after the auth opt-in
test merged alongside the environment-guard helpers, and initialize the
gateway driver-startup context's network-trust fields in the shared
supervision test.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(server): box start-recovery futures for clippy large-futures

The durable-snapshot field grows StartSandboxRequest enough that the
start-recovery match and the recovery-expiry test exceed the
large-futures limit under -D warnings. Box::pin both futures, matching
the pattern the recovery path used before the suspend/resume rework.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

---------

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>
2026-10-27 18:31:11 +00:00
..

OpenShell gateway server

The server owns control-plane RPCs, authentication, provider credential delivery, and sandbox lifecycle orchestration. The openshell-gateway crate composes the server with selected compute drivers.

Sandbox launch identity

A new start, including an automatic restart, commits a fresh runtime generation and an incremented authorization epoch before invoking the compute driver. The launch session ID is derived from the durable sandbox ID and generation. Retries reuse that generation and session, including after gateway restart. Credential renewal retains both; credentials for an older launch cannot authorize the new one.

Shared supervisor pool

The SharedSupervision the gateway hands a compute driver that asks for it always coordinates a pool of shared supervisor hosts, at any member count; the driver itself is unchanged. [openshell.gateway.supervisor_pool] only tunes it (recovery_timeout_secs), and the gateway refuses the table when no in-process driver uses the shared supervisor. supervisor_pool keeps the pool's durable state and reconciles it every two seconds on every gateway replica, independent of which replica runs the compute driver's watch:

  • Membership. The replica serving a member's host stream refreshes its supervisor_pool_member record from a task of its own, every two seconds and at once when a member attaches, leaves, or detaches, each write bounded, so a slow reconciliation pass never lets a served member expire. Only the member's newest stream (by accept time) writes the record, so a replica still holding a stale stream of the same process cannot overwrite it. A record older than 15 seconds is dead; a member that announces it is leaving drops out at once; a member whose process changed lost every instance its previous process owned. The timings members and replicas share, and the reconnect budget that keeps a member reconnecting past an unresponsive replica well inside the TTL, are in openshell_supervisor_control::pool_timing.

  • Listings. A pass reads each record type page by page after an immutable key (list_by_type_after), so a row removed meanwhile shifts nothing out of the listing. A listing is still not a snapshot, so before a pass drops an owner its previous view had, or an instance that view or an owner names, it reads the record by key; a failed read fails the pass, which then publishes nothing.

  • Desired set. A launch records the instance (supervisor_instance_assignment) before anything else. The record holds only the handle of the launch material, which the configured credential store keeps, as it keeps sandbox SSH host keys. Every replica derives the desired set from these records and loads the material of instances owned by members it serves. Recording takes single atomic store writes and holds no lock: it reserves the route with a row named by the route and identified by a fresh writer token, stores the material, has a supervisor_instance_material_candidate row own the handle, and publishes by advancing exactly its reservation's version. A launch publishes its record as Admitting, which is not desired (nothing places, charges, or grants it) and keeps its candidate; a driver recovery's backfill publishes directly. Collection deletes a candidate's material only once its recording no longer holds its reserved or admitting row, after which that recording can never publish or be admitted; a reservation or admitting record older than two minutes (a stalled, failed, or crashed launch) is fenced by removing it at its version, and its material is then collected. A withdrawal removes the material and then the record, or fences a reserved or admitting row; an ended generation (sandbox gone, replaced, stopped, or deleting) is withdrawn by the sweep; and the compute driver's recovery backfills instances it runs without a record and withdraws records published before its listing that it no longer lists.

  • Placement and ownership. A launch admits its instance on the live member the consistent-hash ring prefers (gateway_ring): one transaction creates the owner record, charges the member's capacity, and publishes the Admitting record, conditioned on that record's version, so an owner always has its published instance and a removed record never gains one. A member at its 256-instance capacity is skipped. With no live member with room the launch is refused as a capacity refusal: it removes the admitting record it created, only at the version it created (a concurrent launch of the same generation that admitted it first wins), and then that record's candidate and material; a removal that fails is left to fencing. One supervisor_instance_owner record per instance names the member, its process, and the ownership term (the sandbox's credential epoch). Records change only by compare-and-swap, and a live owner keeps its instances.

  • Capacity. One supervisor_pool_capacity record per member lists the routes whose owner record, in any phase, names the member. Every owner create, move, or removal is one Store::write_atomically transaction with the capacity records it changes, conditioned on all of their versions, and a full member is refused before anything is written. Two replicas admitting the last slot of a member, by launch or takeover, therefore admit one, and a stalled admission whose slot was taken meanwhile fails. A takeover prefers the route's previous member, whose record already lists it, so a member whose process restarted re-adopts its instances even when every member is full.

  • Takeover. Only an instance whose owner failed or left moves. A replica records the intent (new owner, next term, the process the runtime will pin, a new token lineage), advances the sandbox's persisted epoch with advance_identity_for_takeover, which revokes the previous owner's credentials on every replica, and then grants it. Any replica completes an interrupted intent; a stopping, stopped, or deleted sandbox, a replaced generation, or a term another lineage holds cancels it. A replica that just started waits 30 seconds before taking over, so members reconnect after a gateway restart first. The takeover log names its cause: expired, leaving, or restarted.

  • Credentials. Each replica mints a takeover term's grant and session credentials only for members it serves, from the granted record and the persisted identity, and mints them again after half the grant's lifetime. Other replicas send the term without credentials; a host that already runs it keeps it.

  • Observations. The replica serving an owner records what the owner reports of its instances (supervisor_instance_report; no error text), one record per instance and ownership term, and every replica judges an instance whose owner streams elsewhere from the record of the current owner term, so launch readiness and bootstrap completion do not depend on which replica a member reached. A report counts for a term only once the member's stream acknowledged the snapshot that installed the term, and only if the pass's owner record shows that term, so a status from an earlier term is never recorded as a later one. A record is rewritten whenever it differs from the report, so one another writer replaced is repaired. Reports are listed before owners, and only records of terms older than the listed owner, or of instances without one, are removed.

Owner records of withdrawn instances are removed once their generation ended, or two minutes after the withdrawal, after their owner was told to stop them. Pool members still authenticate with the one shared supervisor token, so a member identity is asserted by its hello rather than bound to its Pod.

Operator credential export

GetProviderCredentials exports selected profile-declared runtime environment keys from one provider in an explicitly named workspace. It requires the distinct Principal::Operator, established only from a verified TLS leaf certificate with OU=operator when mtls_auth.operator_enabled is enabled. The TLS listener supplies its certificate fingerprint separately from user identity; the ordinary identity-only serving path cannot establish an operator.

Operators explicitly inherit user/platform-admin permissions, but not sandbox or peer identities. Descriptor authorization and a handler guard both protect the export RPC. Operator mutation admission uses a separate caller namespace from ordinary mTLS users with the same certificate subject. Credential responses never enter durable mutation replay or generic interceptor routing.

The export resolver validates the entire selection before minting, resolves only selected credential handles, and fences provider revisions, refresh epochs, and profile declarations before delivery. Driver expiration and provider expiration combine using the earlier timestamp. OAuth minting inputs, broker-only keys, and supervisor-bound dynamic grants are not exportable. A failed response can still have committed refresh effects. Missing refresh-owned stored outputs trigger refresh-on-read; missing static outputs and other credential-backend errors fail closed without falling back to inline values.

Refresh coordination

Explicit rotation, background refresh, and operator refresh-on-read share the same refresh coordination:

  • A process-local weak keyed mutex coalesces waiters without retaining deleted providers indefinitely.
  • A bounded admission queue and executor pool limit detached refresh work.
  • PostgreSQL deployments also take a dedicated per-provider/per-credential advisory lock; SQLite uses the local mutex. Worker secret cleanup takes the same locks and reloads state before changing its resource version.
  • A durable refresh_in_progress marker precedes upstream minting. A refresh_committing marker protects the interval between grant persistence and provider-handle persistence. Only a completed provider commit publishes refreshed state, with a unique completed-mint identity in metadata annotations. Waiters coalesce against that identity, not bookkeeping changes to the resource version.
  • Automatic callers recheck live recovery and retry deadlines under both locks. A failed mint blocks queued automatic exchanges without disabling scheduled worker retries or a separately requested manual rotation.
  • Detached refresh tasks complete despite request cancellation, preserving replacement refresh tokens returned by the issuer.
  • Existing generation/CAS fencing remains active for deletion and reconfiguration. An ambiguous rotating-token exchange is parked for recovery rather than automatically consuming the grant again.

A crash or lost database-lock session can leave a parked marker. Reconfigure or reauthorize the refresh grant after investigating its issuer state. Changing a marker directly is not a supported recovery workflow.

Focused verification

cargo test -p openshell-server --features test-support,prebuilt-z3
cargo clippy -p openshell-server --all-targets --features test-support,prebuilt-z3 -- -D warnings

The ignored postgres_refresh_locks_coordinate_independent_replica_sessions test runs under mise run test:rust:postgres (see TESTING.md) and still accepts OPENSHELL_REFRESH_TEST_DATABASE_URL pointing to a disposable PostgreSQL database. It exercises independent sessions without the local mutex. Do not point this fixture at an existing deployment database.