feat(rpm): add RPM packaging with Packit/COPR and GHA release publishing (#1126)

This commit is contained in:
Adam Miller
2026-05-05 15:42:00 -07:00
committed by GitHub
parent 5b29189b1e
commit d8b84773ca
37 changed files with 2858 additions and 123 deletions
+26 -5
View File
@@ -635,12 +635,20 @@ jobs:
checkout-ref: ${{ github.sha }}
secrets: inherit
build-rpm:
name: Build RPM Packages
needs: [compute-versions]
uses: ./.github/workflows/rpm-package.yml
with:
checkout-ref: ${{ github.sha }}
secrets: inherit
# ---------------------------------------------------------------------------
# Create / update the dev GitHub Release with CLI binaries and wheels
# ---------------------------------------------------------------------------
release-dev:
name: Release Dev
needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheels-linux, build-python-wheel-macos, build-deb]
needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheels-linux, build-python-wheel-macos, build-deb, build-rpm]
runs-on: linux-amd64-cpu8
timeout-minutes: 10
outputs:
@@ -683,6 +691,13 @@ jobs:
path: release/
merge-multiple: true
- name: Download RPM package artifacts
uses: actions/download-artifact@v4
with:
pattern: rpm-linux-*
path: release/
merge-multiple: true
- name: Capture wheel filenames
id: wheel_filenames
run: |
@@ -700,6 +715,7 @@ jobs:
openshell-aarch64-unknown-linux-musl.tar.gz \
openshell-aarch64-apple-darwin.tar.gz \
openshell_*.deb \
openshell-*.rpm \
*.whl > openshell-checksums-sha256.txt
cat openshell-checksums-sha256.txt
sha256sum \
@@ -712,7 +728,7 @@ jobs:
openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz > openshell-sandbox-checksums-sha256.txt
cat openshell-sandbox-checksums-sha256.txt
- name: Prune stale wheel and deb assets from dev release
- name: Prune stale wheel, deb, and rpm assets from dev release
uses: actions/github-script@v7
env:
WHEEL_VERSION: ${{ needs.compute-versions.outputs.python_version }}
@@ -744,13 +760,17 @@ jobs:
core.info(` ${String(a.id).padStart(12)} ${a.name}`);
}
// Delete stale wheels
let kept = 0, deleted = 0, debDeleted = 0;
// Delete stale wheels, debs, and rpms
let kept = 0, deleted = 0, debDeleted = 0, rpmDeleted = 0;
for (const asset of assets) {
if (asset.name.endsWith('.deb')) {
core.info(`Deleting stale deb package: ${asset.name} (id=${asset.id})`);
await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id });
debDeleted++;
} else if (asset.name.endsWith('.rpm')) {
core.info(`Deleting stale rpm package: ${asset.name} (id=${asset.id})`);
await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id });
rpmDeleted++;
} else if (asset.name.endsWith('.whl') && asset.name.startsWith(currentPrefix)) {
core.info(`Keeping current wheel: ${asset.name}`);
kept++;
@@ -760,7 +780,7 @@ jobs:
deleted++;
}
}
core.info(`Summary: kept_wheels=${kept}, deleted_wheels=${deleted}, deleted_debs=${debDeleted}`);
core.info(`Summary: kept_wheels=${kept}, deleted_wheels=${deleted}, deleted_debs=${debDeleted}, deleted_rpms=${rpmDeleted}`);
- name: Move dev tag
run: |
@@ -792,6 +812,7 @@ jobs:
release/openshell-aarch64-unknown-linux-musl.tar.gz
release/openshell-aarch64-apple-darwin.tar.gz
release/openshell_*.deb
release/openshell-*.rpm
release/openshell-gateway-x86_64-unknown-linux-gnu.tar.gz
release/openshell-gateway-aarch64-unknown-linux-gnu.tar.gz
release/openshell-gateway-aarch64-apple-darwin.tar.gz
+18 -1
View File
@@ -662,12 +662,20 @@ jobs:
checkout-ref: ${{ inputs.tag || github.ref }}
secrets: inherit
build-rpm:
name: Build RPM Packages
needs: [compute-versions]
uses: ./.github/workflows/rpm-package.yml
with:
checkout-ref: ${{ inputs.tag || github.ref }}
secrets: inherit
# ---------------------------------------------------------------------------
# Create a tagged GitHub Release with CLI binaries and wheels
# ---------------------------------------------------------------------------
release:
name: Release
needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheels-linux, build-python-wheel-macos, tag-ghcr-release, build-deb]
needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheels-linux, build-python-wheel-macos, tag-ghcr-release, build-deb, build-rpm]
runs-on: linux-amd64-cpu8
timeout-minutes: 10
outputs:
@@ -712,6 +720,13 @@ jobs:
path: release/
merge-multiple: true
- name: Download RPM package artifacts
uses: actions/download-artifact@v4
with:
pattern: rpm-linux-*
path: release/
merge-multiple: true
- name: Capture wheel filenames
id: wheel_filenames
run: |
@@ -729,6 +744,7 @@ jobs:
openshell-aarch64-unknown-linux-musl.tar.gz \
openshell-aarch64-apple-darwin.tar.gz \
openshell_*.deb \
openshell-*.rpm \
*.whl > openshell-checksums-sha256.txt
cat openshell-checksums-sha256.txt
sha256sum \
@@ -762,6 +778,7 @@ jobs:
release/openshell-aarch64-unknown-linux-musl.tar.gz
release/openshell-aarch64-apple-darwin.tar.gz
release/openshell_*.deb
release/openshell-*.rpm
release/openshell-gateway-x86_64-unknown-linux-gnu.tar.gz
release/openshell-gateway-aarch64-unknown-linux-gnu.tar.gz
release/openshell-gateway-aarch64-apple-darwin.tar.gz
+71
View File
@@ -0,0 +1,71 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
name: RPM Package
on:
workflow_call:
inputs:
checkout-ref:
required: true
type: string
permissions:
contents: read
defaults:
run:
shell: bash
jobs:
build-rpm-linux:
name: Build RPM Package (Linux ${{ matrix.arch }})
strategy:
matrix:
include:
- arch: x86_64
runner: build-amd64
- arch: aarch64
runner: build-arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
container:
image: fedora:latest
steps:
- name: Install build dependencies
run: |
dnf install -y \
packit rpm-build \
rust cargo gcc gcc-c++ make cmake pkg-config \
clang-devel z3-devel systemd-rpm-macros \
pandoc python3-devel git-core \
cargo-rpm-macros
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
fetch-depth: 0
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Fetch tags
run: git fetch --tags --force
- name: Build RPMs via Packit
run: packit build locally
- name: Collect RPM artifacts
run: |
set -euo pipefail
mkdir -p artifacts
find ~/rpmbuild/RPMS/ -name '*.rpm' -exec cp {} artifacts/ \;
echo "=== Built RPMs ==="
ls -lah artifacts/
- name: Upload RPM artifacts
uses: actions/upload-artifact@v4
with:
name: rpm-linux-${{ matrix.arch }}
path: artifacts/*.rpm
retention-days: 5
+6
View File
@@ -206,5 +206,11 @@ rfc.md
.worktrees
.z3-trace
# RPM build artifacts
*.src.rpm
*.tar.gz
*.tar.xz
*.tar.bz2
# Markdown/mermaid lint tooling deps
scripts/lint-mermaid/node_modules/
+5 -1
View File
@@ -10,7 +10,11 @@
".opencode/**",
".github/**",
"THIRD-PARTY-NOTICES/**",
"CLAUDE.md"
"CLAUDE.md",
// Man page sources use pandoc markdown with multiple H1 sections
// (NAME, SYNOPSIS, DESCRIPTION, etc.) which is standard for man
// pages but violates MD025.
"deploy/man/**"
],
"config": {
"default": true,
+77
View File
@@ -0,0 +1,77 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Packit configuration for OpenShell RPM builds via Fedora COPR.
# See https://packit.dev/docs/configuration for full reference.
upstream_tag_template: "v{version}"
upstream_package_name: openshell
downstream_package_name: openshell
specfile_path: openshell.spec
# Packages needed in the SRPM build environment to create vendor tarball
srpm_build_deps:
- rust
- cargo
- git-core
actions:
get-current-version:
# Derive version from the latest upstream tag on the current branch.
- 'bash -c "git describe --tags --match ''v*'' --abbrev=0 HEAD | sed ''s/^v//''"'
create-archive:
# Step 1: Create source tarball from git working tree.
# Uses git ls-files + tar instead of git archive so the tarball
# reflects any patching that Packit may have done (e.g. version bumps).
- 'bash -c "VERSION=${PACKIT_PROJECT_VERSION} && TMPDIR=$(mktemp -d) && DIR=openshell-${VERSION} && mkdir -p ${TMPDIR}/${DIR} && git ls-files -z | xargs -0 tar cf - | tar xf - -C ${TMPDIR}/${DIR}/ && tar -czf openshell-${VERSION}.tar.gz -C ${TMPDIR} ${DIR} && rm -rf ${TMPDIR}"'
# Step 2: Create vendored Cargo dependencies tarball for offline RPM build.
- 'bash -c "VERSION=${PACKIT_PROJECT_VERSION} && cargo vendor --quiet && tar -cJf openshell-${VERSION}-vendor.tar.xz vendor/ && rm -rf vendor/"'
# Step 3: Return BOTH archive names. Packit maps each line to Source0, Source1, etc.
- 'bash -c "echo openshell-${PACKIT_PROJECT_VERSION}.tar.gz && echo openshell-${PACKIT_PROJECT_VERSION}-vendor.tar.xz"'
fix-spec-file:
# Update Source0 to the generated tarball name
- 'bash -c "sed -i \"s|^Source0:.*|Source0: openshell-${PACKIT_PROJECT_VERSION}.tar.gz|\" openshell.spec"'
# Update Source1 to the generated vendor tarball name
- 'bash -c "sed -i \"s|^Source1:.*|Source1: openshell-${PACKIT_PROJECT_VERSION}-vendor.tar.xz|\" openshell.spec"'
# Update Version
- 'bash -c "sed -i -r \"s/^Version:(\\s*)\\S+/Version:\\1${PACKIT_RPMSPEC_VERSION}/\" openshell.spec"'
# Update Release
- 'bash -c "sed -i -r \"s/^Release:(\\s*)\\S+/Release:\\1${PACKIT_RPMSPEC_RELEASE}%{?dist}/\" openshell.spec"'
jobs:
# Build on every pull request targeting main for CI validation
- job: copr_build
trigger: pull_request
branch: main
identifier: main-pr
targets:
- fedora-all
- epel-10
# Build into maxamillion/openshell on every commit to main
# for continuous development and testing builds.
- job: copr_build
trigger: commit
branch: main
owner: "maxamillion"
project: "openshell"
identifier: main-commit
targets:
- fedora-all
- epel-10
preserve_project: true
list_on_homepage: true
# Build on GitHub releases for publishable RPMs.
# See: https://packit.dev/docs/configuration/upstream/copr_build#using-a-custom-copr-project
- job: copr_build
trigger: release
owner: "maxamillion"
project: "openshell"
targets:
- fedora-all
- epel-10
preserve_project: true
list_on_homepage: true
+14 -2
View File
@@ -100,7 +100,19 @@ sequenceDiagram
C->>C: entrypoint: /opt/openshell/bin/openshell-sandbox
```
The supervisor image is a `FROM scratch` image containing only the prebuilt `openshell-sandbox` binary. It is built by the `supervisor-output` target in `deploy/docker/Dockerfile.images`. The `image_volumes` field in the container spec mounts this image's filesystem at `/opt/openshell/bin` with `rw: false`, making it a read-only overlay that the sandbox cannot tamper with.
The supervisor image is a `FROM scratch` image containing only the prebuilt `openshell-sandbox` binary. It is built by the `supervisor` target in `deploy/docker/Dockerfile.images`. The `image_volumes` field in the container spec mounts this image's filesystem at `/opt/openshell/bin` with `rw: false`, making it a read-only overlay that the sandbox cannot tamper with.
## TLS
When the Podman driver's TLS configuration is set (`tls_ca`, `tls_cert`, `tls_key` in `PodmanComputeConfig`), the driver:
1. Switches the auto-detected endpoint scheme from `http://` to `https://`
2. Bind-mounts the client cert files (read-only) into the container at `/etc/openshell/tls/client/`
3. Sets `OPENSHELL_TLS_CA`, `OPENSHELL_TLS_CERT`, `OPENSHELL_TLS_KEY` env vars pointing to the container-side paths
The supervisor reads these env vars and uses them to establish an mTLS connection back to the gateway.
The RPM packaging auto-generates a self-signed PKI on first start via `init-pki.sh`. Client certs are placed in the CLI auto-discovery directory (`~/.config/openshell/gateways/openshell/mtls/`) so the CLI connects with mTLS without manual configuration. See `deploy/rpm/CONFIGURATION.md` for the full RPM configuration reference and `deploy/rpm/QUICKSTART.md` for the quick start guide.
## Network Model
@@ -158,7 +170,7 @@ The SSH handshake secret is injected via Podman's `secret_env` API rather than a
| gRPC endpoint (`OPENSHELL_ENDPOINT`) | Plaintext env var, override-protected | Yes | Yes |
| Supervisor relay socket path (`OPENSHELL_SSH_SOCKET_PATH`) | Plaintext env var, override-protected (same value as `PodmanComputeConfig::sandbox_ssh_socket_path`) | Yes | Yes |
The `build_env()` function in `container.rs` inserts user-supplied variables first, then unconditionally overwrites all security-critical variables to prevent spoofing via sandbox templates: `OPENSHELL_SANDBOX`, `OPENSHELL_SANDBOX_ID`, `OPENSHELL_ENDPOINT`, `OPENSHELL_SSH_SOCKET_PATH`, `OPENSHELL_SSH_LISTEN_ADDR`, `OPENSHELL_SSH_HANDSHAKE_SKEW_SECS`, `OPENSHELL_CONTAINER_IMAGE`, `OPENSHELL_SANDBOX_COMMAND`.
The `build_env()` function in `container.rs` inserts user-supplied variables first, then unconditionally overwrites all security-critical variables to prevent spoofing via sandbox templates: `OPENSHELL_SANDBOX`, `OPENSHELL_SANDBOX_ID`, `OPENSHELL_ENDPOINT`, `OPENSHELL_SSH_SOCKET_PATH`, `OPENSHELL_SSH_HANDSHAKE_SKEW_SECS`, `OPENSHELL_CONTAINER_IMAGE`, `OPENSHELL_SANDBOX_COMMAND`.
The `PodmanComputeConfig::Debug` impl redacts the handshake secret as `[REDACTED]`.
+26 -11
View File
@@ -306,37 +306,52 @@ Supervisor proxy (10.200.0.1:3128 in container netns)
### Supervisor gRPC Callback to Gateway
The Podman driver auto-detects the callback endpoint scheme based on
whether TLS client certificates are configured. When the RPM's
auto-generated PKI is in place, the endpoint is
`https://host.containers.internal:8080` and the supervisor connects
with mTLS. Without TLS configuration, it falls back to
`http://host.containers.internal:8080`.
```text
Supervisor (container netns, 10.89.x.2)
|
1. gRPC connect to http://host.containers.internal:8080
1. mTLS connect to https://host.containers.internal:8080
(resolves to 169.254.1.2:8080 via /etc/hosts)
Client cert bind-mounted from host at /etc/openshell/tls/client/
|
2. Routed through container default gateway (bridge)
|
3. Pasta translates: L2 frame -> host L4 socket
(pasta host-gateway mapping: 169.254.1.2 -> 127.0.0.1)
|
4. Host TCP socket connects to 127.0.0.1:8080
4. Host TCP socket connects to gateway (0.0.0.0:8080)
|
Gateway (host, port 8080)
Gateway (host, 0.0.0.0:8080, mTLS enabled)
|
5. ConnectSupervisor bidirectional stream established
6. Heartbeats every N seconds (gateway sends interval in SessionAccepted, default 15s)
7. Reconnects with exponential backoff (1s initial, 30s max) on failure
8. Same gRPC channel reused for RelayStream calls (no new TLS handshake)
5. TLS handshake: server presents server cert, client presents client cert
6. ConnectSupervisor bidirectional stream established
7. Heartbeats every N seconds (gateway sends interval in SessionAccepted, default 15s)
8. Reconnects with exponential backoff (1s initial, 30s max) on failure
9. Same gRPC channel reused for RelayStream calls (no new TLS handshake)
```
The gateway binds to `0.0.0.0` by default in the RPM packaging. mTLS
prevents unauthenticated access even though the gateway is reachable
from the network. Client certificates are auto-generated by
`init-pki.sh` on first start and bind-mounted into sandbox containers
by the Podman driver. See `deploy/rpm/CONFIGURATION.md` for the full
configuration reference.
## Differences from the Kubernetes Driver
| Aspect | Kubernetes | Podman (rootless pasta) |
|--------|-----------|----------------------|
| Container/Pod IP | Routable cluster-wide | Non-routable (10.89.x.x inside user namespace) |
| Network reachability | Pod IPs reachable from gateway | Bridge not routable from host; requires pasta port forwarding or `host.containers.internal` |
| Sandbox -> Gateway | Direct TCP to K8s service IP | `host.containers.internal` (169.254.1.2 via pasta) |
| Network reachability | Pod IPs reachable from gateway | Bridge not routable from host; requires `host.containers.internal` |
| Sandbox -> Gateway | Direct TCP to K8s service IP | `host.containers.internal` via bridge + pasta |
| SSH transport | Reverse gRPC relay (`ConnectSupervisor` + `RelayStream`) -- same mechanism as Podman | Reverse gRPC relay (`ConnectSupervisor` + `RelayStream`) |
| Port publishing | Not needed (routable IPs) | Ephemeral host port via pasta port forwarding |
| TLS | mTLS via K8s secrets | Disabled by default (loopback-only, `--disable-tls`) |
| TLS | mTLS via K8s secrets | mTLS via auto-generated PKI (RPM default) or `--disable-tls` |
| DNS | Kubernetes CoreDNS | Podman bridge DNS (aardvark-dns, `dns_enabled: true`) |
| Network policy | K8s NetworkPolicy (ingress restricted to gateway) | iptables inside inner sandbox netns |
| Supervisor delivery | Kubernetes driver managed pod image/template | OCI image volume mount (FROM scratch image) |
@@ -69,6 +69,18 @@ pub struct GatewayMetadata {
/// Local VM driver state directory for standalone VM gateways.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vm_driver_state_dir: Option<PathBuf>,
/// Whether the CLI manages this gateway's full lifecycle (deploy,
/// stop, destroy).
///
/// - `Some(true)` — deployed via `gateway start`; destroy/stop operate on
/// the underlying container or VM.
/// - `Some(false)` — registered via `gateway add`; destroy/stop only remove
/// the local registration metadata.
/// - `None` — legacy metadata written before this field existed; the CLI
/// falls back to the previous heuristic (`is_remote`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_lifecycle_managed: Option<bool>,
}
impl GatewayMetadata {
@@ -157,6 +169,7 @@ pub fn create_gateway_metadata_with_host(
remote_host,
resolved_host,
auth_mode: disable_tls.then(|| "plaintext".to_string()),
client_lifecycle_managed: Some(true),
..Default::default()
}
}
@@ -506,6 +519,53 @@ mod tests {
assert!(parsed.resolved_host.is_none());
}
#[test]
fn metadata_deserialize_without_client_lifecycle_managed_field() {
// Legacy metadata files won't have the client_lifecycle_managed field.
// Ensure backwards compatibility: defaults to None.
let json = r#"{
"name": "test",
"gateway_endpoint": "https://127.0.0.1:8080",
"is_remote": false,
"gateway_port": 8080
}"#;
let parsed: GatewayMetadata = serde_json::from_str(json).unwrap();
assert_eq!(parsed.client_lifecycle_managed, None);
}
#[test]
fn metadata_roundtrip_with_client_lifecycle_managed_field() {
let meta = GatewayMetadata {
name: "test".to_string(),
gateway_endpoint: "https://127.0.0.1:8080".to_string(),
gateway_port: 8080,
client_lifecycle_managed: Some(false),
..Default::default()
};
let json = serde_json::to_string(&meta).unwrap();
assert!(json.contains(r#""client_lifecycle_managed":false"#));
let parsed: GatewayMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.client_lifecycle_managed, Some(false));
}
#[test]
fn metadata_omits_client_lifecycle_managed_when_none() {
let meta = GatewayMetadata {
name: "test".to_string(),
gateway_endpoint: "https://127.0.0.1:8080".to_string(),
gateway_port: 8080,
..Default::default()
};
let json = serde_json::to_string(&meta).unwrap();
assert!(!json.contains("client_lifecycle_managed"));
}
#[test]
fn create_gateway_metadata_sets_client_lifecycle_managed_true() {
let meta = create_gateway_metadata("test", None, 8080);
assert_eq!(meta.client_lifecycle_managed, Some(true));
}
#[test]
fn local_gateway_metadata_with_gateway_host_override() {
let meta = create_gateway_metadata_with_host(
+1
View File
@@ -178,6 +178,7 @@ mod tests {
gateway_endpoint: "https://alpha.example.com".to_string(),
is_remote: true,
auth_mode: Some("cloudflare_jwt".to_string()),
client_lifecycle_managed: Some(false),
..Default::default()
},
)
+1
View File
@@ -2992,6 +2992,7 @@ mod tests {
gateway_endpoint: endpoint.to_string(),
is_remote: true,
auth_mode: Some("cloudflare_jwt".to_string()),
client_lifecycle_managed: Some(false),
..Default::default()
}
}
+163 -8
View File
@@ -867,6 +867,30 @@ fn is_loopback_gateway_endpoint(endpoint: &str) -> bool {
}
}
/// Check whether mTLS client certs exist on disk for the gateway that
/// would serve this endpoint.
///
/// Loopback endpoints (`localhost`, `127.0.0.1`, `::1`) resolve to the
/// `"openshell"` gateway name, matching the convention used by
/// `init-pki.sh` and the TLS cert resolver in `tls.rs`.
fn mtls_certs_exist_for_endpoint(name: &str, endpoint: &str) -> bool {
let cert_name = if is_loopback_gateway_endpoint(endpoint) {
"openshell"
} else {
name
};
openshell_core::paths::xdg_config_dir().is_ok_and(|d| {
let mtls = d
.join("openshell")
.join("gateways")
.join(cert_name)
.join("mtls");
mtls.join("ca.crt").is_file()
&& mtls.join("tls.crt").is_file()
&& mtls.join("tls.key").is_file()
})
}
fn plaintext_gateway_is_remote(endpoint: &str, remote: Option<&str>, local: bool) -> bool {
if local {
return false;
@@ -897,6 +921,7 @@ fn plaintext_gateway_metadata(
remote_host,
resolved_host,
auth_mode: Some("plaintext".to_string()),
client_lifecycle_managed: Some(false),
..Default::default()
}
}
@@ -1026,9 +1051,14 @@ pub async fn gateway_add(
}
// Derive a gateway name from the hostname when none is provided.
// Loopback endpoints use the canonical "openshell" name, matching the
// convention in init-pki.sh, default_tls_dir, and bootstrap.
let derived_name;
let name = if let Some(n) = name {
n
} else if is_loopback_gateway_endpoint(&endpoint) {
derived_name = "openshell".to_string();
&derived_name
} else {
// Parse out just the host portion of the URL.
derived_name = url::Url::parse(&endpoint)
@@ -1136,6 +1166,24 @@ pub async fn gateway_add(
}
if endpoint.starts_with("http://") {
// Warn if mTLS certs exist for this gateway — the user likely
// meant to use https:// instead of http://.
let has_mtls_certs = mtls_certs_exist_for_endpoint(name, &endpoint);
if has_mtls_certs {
let https_endpoint = endpoint.replacen("http://", "https://", 1);
let suggestion = if is_loopback_gateway_endpoint(&endpoint) {
format!("openshell gateway add --local {https_endpoint}")
} else {
format!("openshell gateway add {https_endpoint}")
};
eprintln!(
"{} mTLS certificates found for gateway '{name}'. Did you mean to use https?",
"⚠".yellow().bold(),
);
eprintln!(" Try: {suggestion}");
}
let metadata = plaintext_gateway_metadata(name, &endpoint, remote, local);
let gateway_type = gateway_type_label(&metadata);
let gateway_auth = gateway_auth_label(&metadata);
@@ -1143,6 +1191,21 @@ pub async fn gateway_add(
store_gateway_metadata(name, &metadata)?;
save_active_gateway(name)?;
// Verify the gateway is reachable.
let tls = TlsOptions::default();
match http_health_check(&endpoint, &tls).await {
Ok(Some(status)) if status.is_success() => {}
_ => {
eprintln!(
"{} Gateway is not reachable at {endpoint}",
"⚠".yellow().bold(),
);
if !has_mtls_certs {
eprintln!(" Verify the gateway is running and the endpoint is correct.");
}
}
}
eprintln!(
"{} Gateway '{}' added and set as active",
"✓".green().bold(),
@@ -1169,10 +1232,20 @@ pub async fn gateway_add(
// is not registered. Pass the endpoint port so the container can be
// identified by its host port binding when multiple gateways run on
// the same Docker host.
let endpoint_port = url::Url::parse(&endpoint).ok().and_then(|u| u.port());
eprintln!("• Extracting TLS certificates from gateway container...");
openshell_bootstrap::extract_and_store_pki(name, remote_opts.as_ref(), endpoint_port)
.await?;
//
// Skip extraction when client certs are already on disk (e.g.,
// RPM/systemd deployments where init-pki.sh pre-provisions them
// before the gateway starts).
let certs_on_disk = mtls_certs_exist_for_endpoint(name, &endpoint);
if certs_on_disk {
eprintln!("• TLS certificates already present, skipping extraction");
} else {
let endpoint_port = url::Url::parse(&endpoint).ok().and_then(|u| u.port());
eprintln!("• Extracting TLS certificates from gateway container...");
openshell_bootstrap::extract_and_store_pki(name, remote_opts.as_ref(), endpoint_port)
.await?;
}
let (remote_host, resolved_host) = remote.map_or((None, None), |dest| {
let ssh_host = extract_host_from_ssh_destination(dest);
@@ -1188,12 +1261,25 @@ pub async fn gateway_add(
remote_host,
resolved_host,
auth_mode: Some("mtls".to_string()),
client_lifecycle_managed: Some(false),
..Default::default()
};
store_gateway_metadata(name, &metadata)?;
save_active_gateway(name)?;
// Verify the gateway is reachable over mTLS.
let tls = TlsOptions::default().with_gateway_name(name);
match http_health_check(&endpoint, &tls).await {
Ok(Some(status)) if status.is_success() => {}
_ => {
eprintln!(
"{} Gateway is not reachable at {endpoint}. Verify the gateway is running.",
"⚠".yellow().bold(),
);
}
}
eprintln!(
"{} Gateway '{}' added and set as active",
"✓".green().bold(),
@@ -1205,7 +1291,15 @@ pub async fn gateway_add(
"Type:".dimmed(),
if local { "local" } else { "remote" },
);
eprintln!("{} TLS certificates extracted", "✓".green().bold());
eprintln!(
"{} TLS certificates {}",
"✓".green().bold(),
if certs_on_disk {
"already present"
} else {
"extracted"
}
);
} else {
// Cloud (edge-authenticated) gateway.
let metadata = GatewayMetadata {
@@ -1213,6 +1307,7 @@ pub async fn gateway_add(
gateway_endpoint: endpoint.clone(),
is_remote: true,
auth_mode: Some("cloudflare_jwt".to_string()),
client_lifecycle_managed: Some(false),
..Default::default()
};
@@ -1738,10 +1833,20 @@ fn resolve_gateway_control_target_from(
}
match metadata {
Some(metadata) if metadata.is_remote => metadata.remote_host.map_or(
// Not client-managed (`gateway add`) — the gateway lifecycle is
// managed externally (e.g. systemd, Podman, bare-metal); only
// remove the local registration metadata on destroy/stop.
Some(ref m) if m.client_lifecycle_managed == Some(false) => {
GatewayControlTarget::ExternalRegistration
}
// Remote gateway with SSH destination — managed remote container.
Some(ref m) if m.is_remote => m.remote_host.clone().map_or(
GatewayControlTarget::ExternalRegistration,
GatewayControlTarget::Remote,
),
// Client-managed (`gateway start`) or legacy metadata (no
// `client_lifecycle_managed` field) — treat as a
// locally-managed container.
_ => GatewayControlTarget::Local,
}
}
@@ -5935,6 +6040,7 @@ mod tests {
gateway_endpoint: endpoint.to_string(),
is_remote: true,
auth_mode: Some("cloudflare_jwt".to_string()),
client_lifecycle_managed: Some(false),
..Default::default()
}
}
@@ -6335,6 +6441,49 @@ mod tests {
}
}
#[test]
fn resolve_gateway_control_target_non_managed_loopback_is_external() {
// A gateway registered via `gateway add http://localhost:8080` should
// be classified as an external registration, not a local container.
let metadata = GatewayMetadata {
name: "localhost".to_string(),
gateway_endpoint: "http://localhost:8080".to_string(),
auth_mode: Some("plaintext".to_string()),
client_lifecycle_managed: Some(false),
..Default::default()
};
let target = resolve_gateway_control_target_from(Some(metadata), None);
assert!(matches!(target, GatewayControlTarget::ExternalRegistration));
}
#[test]
fn resolve_gateway_control_target_managed_gateway_is_local() {
// A gateway deployed via `gateway start` should be classified as local.
let metadata = GatewayMetadata {
name: "openshell".to_string(),
gateway_endpoint: "https://127.0.0.1:8080".to_string(),
gateway_port: 8080,
client_lifecycle_managed: Some(true),
..Default::default()
};
let target = resolve_gateway_control_target_from(Some(metadata), None);
assert!(matches!(target, GatewayControlTarget::Local));
}
#[test]
fn resolve_gateway_control_target_legacy_metadata_defaults_to_local() {
// Legacy metadata without the `client_lifecycle_managed` field
// should preserve the existing behavior: non-remote → Local.
let metadata = GatewayMetadata {
name: "openshell".to_string(),
gateway_endpoint: "https://127.0.0.1:8080".to_string(),
gateway_port: 8080,
..Default::default()
};
let target = resolve_gateway_control_target_from(Some(metadata), None);
assert!(matches!(target, GatewayControlTarget::Local));
}
#[test]
fn gateway_select_uses_explicit_name_without_prompting() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
@@ -6486,6 +6635,7 @@ mod tests {
#[test]
fn gateway_add_registers_plaintext_loopback_gateway_without_local_flag() {
let _ = rustls::crypto::ring::default_provider().install_default();
let tmpdir = tempfile::tempdir().expect("create tmpdir");
with_tmp_xdg(tmpdir.path(), || {
let runtime = tokio::runtime::Runtime::new().expect("create runtime");
@@ -6505,16 +6655,20 @@ mod tests {
.expect("register plaintext gateway");
});
let metadata = load_gateway_metadata("127.0.0.1").expect("load stored gateway");
// Loopback endpoints derive the canonical "openshell" gateway
// name, matching init-pki.sh and default_tls_dir conventions.
let metadata = load_gateway_metadata("openshell").expect("load stored gateway");
assert_eq!(metadata.auth_mode.as_deref(), Some("plaintext"));
assert!(!metadata.is_remote);
assert_eq!(metadata.client_lifecycle_managed, Some(false));
assert_eq!(metadata.gateway_endpoint, "http://127.0.0.1:8080");
assert_eq!(load_active_gateway().as_deref(), Some("127.0.0.1"));
assert_eq!(load_active_gateway().as_deref(), Some("openshell"));
});
}
#[test]
fn gateway_add_respects_local_flag_for_plaintext_registrations() {
let _ = rustls::crypto::ring::default_provider().install_default();
let tmpdir = tempfile::tempdir().expect("create tmpdir");
with_tmp_xdg(tmpdir.path(), || {
let runtime = tokio::runtime::Runtime::new().expect("create runtime");
@@ -6537,6 +6691,7 @@ mod tests {
let metadata = load_gateway_metadata("dev-http").expect("load stored gateway");
assert_eq!(metadata.auth_mode.as_deref(), Some("plaintext"));
assert!(!metadata.is_remote);
assert_eq!(metadata.client_lifecycle_managed, Some(false));
assert_eq!(metadata.gateway_endpoint, "http://gateway.example.com:8080");
assert_eq!(load_active_gateway().as_deref(), Some("dev-http"));
});
+6 -5
View File
@@ -69,8 +69,9 @@ const DOCKER_NETWORK_DRIVER: &str = "bridge";
/// explicit `--docker-supervisor-bin` override or local build is available.
const DEFAULT_DOCKER_SUPERVISOR_IMAGE_REPO: &str = "ghcr.io/nvidia/openshell/supervisor";
/// Path to the supervisor binary inside the `openshell/supervisor` image.
const SUPERVISOR_IMAGE_BINARY_PATH: &str = "/usr/local/bin/openshell-sandbox";
/// Path to the supervisor binary inside the `openshell/supervisor` image
/// (a `FROM scratch` image containing only the binary).
const SUPERVISOR_IMAGE_BINARY_PATH: &str = "/openshell-sandbox";
/// Return the default `ghcr.io/nvidia/openshell/supervisor:<tag>` reference
/// used when no supervisor binary override is provided.
@@ -1661,8 +1662,8 @@ fn linux_supervisor_candidates(daemon_arch: &str) -> Vec<PathBuf> {
}
/// Pull the supervisor image (if not already local), extract
/// `/usr/local/bin/openshell-sandbox` to a host cache keyed by the image's
/// content digest, and return the cache path.
/// `/openshell-sandbox` to a host cache keyed by the image's content
/// digest, and return the cache path.
///
/// The extraction is atomic: the binary is written to a sibling temp file
/// inside the digest-keyed directory and renamed into place, so concurrent
@@ -1758,7 +1759,7 @@ async fn extract_supervisor_binary_bytes(docker: &Docker, image: &str) -> CoreRe
),
ContainerCreateBody {
image: Some(image.to_string()),
entrypoint: Some(vec!["/bin/true".to_string()]),
entrypoint: Some(vec!["/openshell-sandbox".to_string()]),
cmd: Some(Vec::new()),
..Default::default()
},
@@ -234,6 +234,18 @@ pub struct HostInfo {
pub cgroup_version: String,
#[serde(default)]
pub network_backend: String,
#[serde(default)]
pub security: SecurityInfo,
}
/// Security-related fields from the Podman system info response.
///
/// Podman returns `host.security.rootless: true` when the daemon is
/// running without root privileges (rootless mode).
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct SecurityInfo {
#[serde(default)]
pub rootless: bool,
}
// ── Client ───────────────────────────────────────────────────────────────
+169 -4
View File
@@ -88,8 +88,6 @@ pub struct PodmanComputeConfig {
/// Name of the Podman bridge network.
/// Created automatically if it does not exist.
pub network_name: String,
/// SSH listen address passed to the sandbox binary.
pub ssh_listen_addr: String,
/// SSH port inside the container.
pub ssh_port: u16,
/// Shared secret for the NSSH1 SSH handshake.
@@ -102,9 +100,60 @@ pub struct PodmanComputeConfig {
/// Mounted read-only into sandbox containers at /opt/openshell/bin
/// using Podman's `type=image` mount.
pub supervisor_image: String,
/// Host path to the CA certificate for sandbox mTLS.
///
/// When all three TLS paths (`guest_tls_ca`, `guest_tls_cert`,
/// `guest_tls_key`) are set, the driver bind-mounts them into sandbox
/// containers and switches the auto-detected endpoint from `http://`
/// to `https://`.
pub guest_tls_ca: Option<PathBuf>,
/// Host path to the client certificate for sandbox mTLS.
pub guest_tls_cert: Option<PathBuf>,
/// Host path to the client private key for sandbox mTLS.
pub guest_tls_key: Option<PathBuf>,
}
impl PodmanComputeConfig {
/// Returns `true` when all three TLS paths are configured.
#[must_use]
pub fn tls_enabled(&self) -> bool {
self.guest_tls_ca.is_some() && self.guest_tls_cert.is_some() && self.guest_tls_key.is_some()
}
/// Validate TLS configuration consistency.
///
/// Returns `Ok(())` when either all three TLS paths are set (full mTLS)
/// or none are set (plaintext). Returns an error naming the missing
/// fields when only a subset is provided — this prevents silent
/// fallback to plaintext when an operator partially configures mTLS.
pub fn validate_tls_config(&self) -> Result<(), crate::client::PodmanApiError> {
let has_ca = self.guest_tls_ca.is_some();
let has_cert = self.guest_tls_cert.is_some();
let has_key = self.guest_tls_key.is_some();
// All set or none set — both are valid.
if (has_ca && has_cert && has_key) || (!has_ca && !has_cert && !has_key) {
return Ok(());
}
let mut missing = Vec::new();
if !has_ca {
missing.push("--podman-tls-ca / OPENSHELL_PODMAN_TLS_CA");
}
if !has_cert {
missing.push("--podman-tls-cert / OPENSHELL_PODMAN_TLS_CERT");
}
if !has_key {
missing.push("--podman-tls-key / OPENSHELL_PODMAN_TLS_KEY");
}
Err(crate::client::PodmanApiError::InvalidInput(format!(
"Partial TLS configuration: all three TLS paths must be provided together. \
Missing: {}",
missing.join(", ")
)))
}
/// Resolve the default socket path from the environment.
///
/// - **macOS**: `$HOME/.local/share/containers/podman/machine/podman.sock`
@@ -142,12 +191,14 @@ impl Default for PodmanComputeConfig {
gateway_port: openshell_core::config::DEFAULT_SERVER_PORT,
sandbox_ssh_socket_path: "/run/openshell/ssh.sock".to_string(),
network_name: DEFAULT_NETWORK_NAME.to_string(),
ssh_listen_addr: String::new(),
ssh_port: DEFAULT_SSH_PORT,
ssh_handshake_secret: String::new(),
ssh_handshake_skew_secs: DEFAULT_SSH_HANDSHAKE_SKEW_SECS,
stop_timeout_secs: DEFAULT_STOP_TIMEOUT_SECS,
supervisor_image: DEFAULT_SUPERVISOR_IMAGE.to_string(),
guest_tls_ca: None,
guest_tls_cert: None,
guest_tls_key: None,
}
}
}
@@ -162,12 +213,14 @@ impl std::fmt::Debug for PodmanComputeConfig {
.field("gateway_port", &self.gateway_port)
.field("sandbox_ssh_socket_path", &self.sandbox_ssh_socket_path)
.field("network_name", &self.network_name)
.field("ssh_listen_addr", &self.ssh_listen_addr)
.field("ssh_port", &self.ssh_port)
.field("ssh_handshake_secret", &"[REDACTED]")
.field("ssh_handshake_skew_secs", &self.ssh_handshake_skew_secs)
.field("stop_timeout_secs", &self.stop_timeout_secs)
.field("supervisor_image", &self.supervisor_image)
.field("guest_tls_ca", &self.guest_tls_ca)
.field("guest_tls_cert", &self.guest_tls_cert)
.field("guest_tls_key", &self.guest_tls_key)
.finish()
}
}
@@ -223,4 +276,116 @@ mod tests {
);
});
}
// ── TLS config validation ─────────────────────────────────────────
#[test]
fn validate_tls_config_all_none_is_ok() {
let cfg = PodmanComputeConfig::default();
assert!(cfg.validate_tls_config().is_ok());
}
#[test]
fn validate_tls_config_all_set_is_ok() {
let cfg = PodmanComputeConfig {
guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")),
guest_tls_cert: Some(PathBuf::from("/tls/tls.crt")),
guest_tls_key: Some(PathBuf::from("/tls/tls.key")),
..PodmanComputeConfig::default()
};
assert!(cfg.validate_tls_config().is_ok());
}
#[test]
fn validate_tls_config_only_ca_is_error() {
let cfg = PodmanComputeConfig {
guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")),
..PodmanComputeConfig::default()
};
let err = cfg
.validate_tls_config()
.expect_err("only CA should be rejected");
let msg = err.to_string();
assert!(msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}");
assert!(msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}");
assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CA"), "{msg}");
}
#[test]
fn validate_tls_config_only_cert_is_error() {
let cfg = PodmanComputeConfig {
guest_tls_cert: Some(PathBuf::from("/tls/tls.crt")),
..PodmanComputeConfig::default()
};
let err = cfg
.validate_tls_config()
.expect_err("only cert should be rejected");
let msg = err.to_string();
assert!(msg.contains("OPENSHELL_PODMAN_TLS_CA"), "{msg}");
assert!(msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}");
assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}");
}
#[test]
fn validate_tls_config_only_key_is_error() {
let cfg = PodmanComputeConfig {
guest_tls_key: Some(PathBuf::from("/tls/tls.key")),
..PodmanComputeConfig::default()
};
let err = cfg
.validate_tls_config()
.expect_err("only key should be rejected");
let msg = err.to_string();
assert!(msg.contains("OPENSHELL_PODMAN_TLS_CA"), "{msg}");
assert!(msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}");
assert!(!msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}");
}
#[test]
fn validate_tls_config_ca_and_cert_missing_key_is_error() {
let cfg = PodmanComputeConfig {
guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")),
guest_tls_cert: Some(PathBuf::from("/tls/tls.crt")),
..PodmanComputeConfig::default()
};
let err = cfg
.validate_tls_config()
.expect_err("missing key should be rejected");
let msg = err.to_string();
assert!(msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}");
assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CA"), "{msg}");
assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}");
}
#[test]
fn validate_tls_config_ca_and_key_missing_cert_is_error() {
let cfg = PodmanComputeConfig {
guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")),
guest_tls_key: Some(PathBuf::from("/tls/tls.key")),
..PodmanComputeConfig::default()
};
let err = cfg
.validate_tls_config()
.expect_err("missing cert should be rejected");
let msg = err.to_string();
assert!(msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}");
assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CA"), "{msg}");
assert!(!msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}");
}
#[test]
fn validate_tls_config_cert_and_key_missing_ca_is_error() {
let cfg = PodmanComputeConfig {
guest_tls_cert: Some(PathBuf::from("/tls/tls.crt")),
guest_tls_key: Some(PathBuf::from("/tls/tls.key")),
..PodmanComputeConfig::default()
};
let err = cfg
.validate_tls_config()
.expect_err("missing CA should be rejected");
let msg = err.to_string();
assert!(msg.contains("OPENSHELL_PODMAN_TLS_CA"), "{msg}");
assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}");
assert!(!msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}");
}
}
+165 -11
View File
@@ -10,6 +10,25 @@ use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
/// Returns `true` when `SELinux` is enabled (enforcing or permissive).
///
/// Checks whether selinuxfs is mounted, matching Podman's own detection
/// logic. Bind-mount relabeling (the `z` mount option) is needed in both
/// enforcing and permissive modes: enforcing blocks access outright, while
/// permissive floods the audit log with AVC denials that mask real issues.
///
/// On non-`SELinux` systems (Ubuntu, macOS, Alpine) the directory does not
/// exist and this returns `false`, leaving mount options unchanged.
#[cfg(target_os = "linux")]
fn is_selinux_enabled() -> bool {
std::path::Path::new("/sys/fs/selinux").is_dir()
}
#[cfg(not(target_os = "linux"))]
fn is_selinux_enabled() -> bool {
false
}
/// Label key for the sandbox ID.
pub const LABEL_SANDBOX_ID: &str = "openshell.sandbox-id";
/// Label key for the sandbox name.
@@ -25,6 +44,11 @@ const CONTAINER_PREFIX: &str = "openshell-sandbox-";
/// Volume name prefix.
const VOLUME_PREFIX: &str = "openshell-sandbox-";
/// Container-side mount paths for client TLS materials.
const TLS_CA_MOUNT_PATH: &str = "/etc/openshell/tls/client/ca.crt";
const TLS_CERT_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.crt";
const TLS_KEY_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.key";
/// Build a Podman container name from the sandbox name.
#[must_use]
pub fn container_name(sandbox_name: &str) -> String {
@@ -246,10 +270,6 @@ fn build_env(
"OPENSHELL_SSH_SOCKET_PATH".into(),
config.sandbox_ssh_socket_path.clone(),
);
env.insert(
"OPENSHELL_SSH_LISTEN_ADDR".into(),
config.ssh_listen_addr.clone(),
);
// NOTE: The SSH handshake secret is injected via a Podman secret
// (see the "secrets" field below) rather than a plaintext env var.
// This prevents exposure through `podman inspect`.
@@ -260,6 +280,15 @@ fn build_env(
env.insert("OPENSHELL_CONTAINER_IMAGE".into(), image.to_string());
env.insert("OPENSHELL_SANDBOX_COMMAND".into(), "sleep infinity".into());
// 3. TLS client cert paths (when mTLS is enabled). These point to
// the container-side mount paths where the cert files are
// bind-mounted from the host.
if config.tls_enabled() {
env.insert("OPENSHELL_TLS_CA".into(), TLS_CA_MOUNT_PATH.into());
env.insert("OPENSHELL_TLS_CERT".into(), TLS_CERT_MOUNT_PATH.into());
env.insert("OPENSHELL_TLS_KEY".into(), TLS_KEY_MOUNT_PATH.into());
}
env
}
@@ -487,12 +516,51 @@ pub fn build_container_spec(sandbox: &DriverSandbox, config: &PodmanComputeConfi
// directory does not exist on the host, so the mkdir inside the container
// fails with EPERM. A private tmpfs gives the supervisor its own writable
// /run/netns without needing host filesystem access.
mounts: vec![Mount {
kind: "tmpfs".into(),
source: "tmpfs".into(),
destination: "/run/netns".into(),
options: vec!["rw".into(), "nosuid".into(), "nodev".into()],
}],
mounts: {
let mut m = vec![Mount {
kind: "tmpfs".into(),
source: "tmpfs".into(),
destination: "/run/netns".into(),
options: vec!["rw".into(), "nosuid".into(), "nodev".into()],
}];
// Bind-mount client TLS materials into the container when mTLS
// is enabled. The supervisor reads these via OPENSHELL_TLS_CA,
// OPENSHELL_TLS_CERT, and OPENSHELL_TLS_KEY env vars (set in
// build_env above) to establish an mTLS connection back to the
// gateway.
if let (Some(ca), Some(cert), Some(key)) = (
&config.guest_tls_ca,
&config.guest_tls_cert,
&config.guest_tls_key,
) {
let mut ro = vec!["ro".into(), "rbind".into()];
// On SELinux-enabled systems (Fedora, RHEL), bind-mounted
// files need the shared relabel option so the container
// process can read them through the SELinux MAC policy.
if is_selinux_enabled() {
ro.push("z".into());
}
m.push(Mount {
kind: "bind".into(),
source: ca.display().to_string(),
destination: TLS_CA_MOUNT_PATH.into(),
options: ro.clone(),
});
m.push(Mount {
kind: "bind".into(),
source: cert.display().to_string(),
destination: TLS_CERT_MOUNT_PATH.into(),
options: ro.clone(),
});
m.push(Mount {
kind: "bind".into(),
source: key.display().to_string(),
destination: TLS_KEY_MOUNT_PATH.into(),
options: ro,
});
}
m
},
// Publish the SSH port with host_port=0 to get an ephemeral host port.
// In rootless Podman the bridge network (10.89.x.x) is not routable from
// the host, so we must use the published host port on 127.0.0.1 instead.
@@ -850,7 +918,6 @@ mod tests {
default_image: "test-image:latest".to_string(),
grpc_endpoint: "http://localhost:50051".to_string(),
sandbox_ssh_socket_path: "/run/openshell/test-ssh.sock".to_string(),
ssh_listen_addr: "0.0.0.0:2222".to_string(),
ssh_handshake_secret: "test-secret-value".to_string(),
..PodmanComputeConfig::default()
}
@@ -888,4 +955,91 @@ mod tests {
"image volume should be read-only"
);
}
#[test]
fn container_spec_includes_tls_mounts_when_configured() {
let sandbox = test_sandbox("tls-id", "tls-name");
let mut config = test_config();
config.guest_tls_ca = Some(std::path::PathBuf::from("/host/ca.crt"));
config.guest_tls_cert = Some(std::path::PathBuf::from("/host/tls.crt"));
config.guest_tls_key = Some(std::path::PathBuf::from("/host/tls.key"));
let spec = build_container_spec(&sandbox, &config);
// Verify TLS env vars are set.
let env_map = spec["env"].as_object().expect("env should be an object");
assert_eq!(
env_map.get("OPENSHELL_TLS_CA").and_then(|v| v.as_str()),
Some("/etc/openshell/tls/client/ca.crt"),
);
assert_eq!(
env_map.get("OPENSHELL_TLS_CERT").and_then(|v| v.as_str()),
Some("/etc/openshell/tls/client/tls.crt"),
);
assert_eq!(
env_map.get("OPENSHELL_TLS_KEY").and_then(|v| v.as_str()),
Some("/etc/openshell/tls/client/tls.key"),
);
// Verify bind mounts exist for all three cert files.
let mounts = spec["mounts"]
.as_array()
.expect("mounts should be an array");
let bind_dests: Vec<&str> = mounts
.iter()
.filter(|m| m["type"].as_str() == Some("bind"))
.filter_map(|m| m["destination"].as_str())
.collect();
assert!(
bind_dests.contains(&"/etc/openshell/tls/client/ca.crt"),
"should bind-mount CA cert"
);
assert!(
bind_dests.contains(&"/etc/openshell/tls/client/tls.crt"),
"should bind-mount client cert"
);
assert!(
bind_dests.contains(&"/etc/openshell/tls/client/tls.key"),
"should bind-mount client key"
);
// Verify SELinux relabel option is present iff SELinux is enabled.
let tls_binds: Vec<&Value> = mounts
.iter()
.filter(|m| m["type"].as_str() == Some("bind"))
.collect();
let has_z = tls_binds.iter().all(|m| {
m["options"]
.as_array()
.is_some_and(|opts| opts.iter().any(|o| o.as_str() == Some("z")))
});
assert_eq!(
has_z,
is_selinux_enabled(),
"TLS bind mounts should include 'z' option iff SELinux is enabled"
);
}
#[test]
fn container_spec_omits_tls_without_config() {
let sandbox = test_sandbox("notls-id", "notls-name");
let config = test_config();
let spec = build_container_spec(&sandbox, &config);
let env_map = spec["env"].as_object().expect("env should be an object");
assert!(
env_map.get("OPENSHELL_TLS_CA").is_none(),
"TLS env vars should not be set without TLS config"
);
let mounts = spec["mounts"]
.as_array()
.expect("mounts should be an array");
let bind_count = mounts
.iter()
.filter(|m| m["type"].as_str() == Some("bind"))
.count();
assert_eq!(bind_count, 0, "no bind mounts without TLS config");
}
}
+67 -30
View File
@@ -73,12 +73,17 @@ impl PodmanComputeDriver {
}
}
// Validate TLS configuration before connecting. Partial configs
// (e.g. CA set but cert/key missing) are rejected early so operators
// get a clear error instead of a silent fallback to plaintext HTTP.
config.validate_tls_config()?;
let client = PodmanClient::new(config.socket_path.clone());
// Verify connectivity.
client.ping().await?;
// Verify cgroups v2 and log system info.
// Verify cgroups v2, detect rootless mode, and log system info.
match client.system_info().await {
Ok(info) => {
if info.host.cgroup_version != "v2" {
@@ -92,6 +97,7 @@ impl PodmanComputeDriver {
info!(
cgroup_version = %info.host.cgroup_version,
network_backend = %info.host.network_backend,
rootless = info.host.security.rootless,
"Connected to Podman"
);
}
@@ -124,16 +130,24 @@ impl PodmanComputeDriver {
// Auto-detect the gRPC callback endpoint when not explicitly
// configured. Sandbox containers use host.containers.internal
// (injected via hostadd with host-gateway in the container spec)
// to reach the gateway server on the host. This works in both
// rootful and rootless Podman — the bridge gateway IP does NOT
// work in rootless mode because it lives inside the user
// namespace, not on the host.
// to reach the gateway server on the host. The scheme is
// determined by whether TLS client certs are configured: when
// all three TLS paths are set, the endpoint uses https so the
// supervisor connects with mTLS.
if config.grpc_endpoint.is_empty() {
config.grpc_endpoint =
format!("http://host.containers.internal:{}", config.gateway_port);
let scheme = if config.tls_enabled() {
"https"
} else {
"http"
};
config.grpc_endpoint = format!(
"{scheme}://host.containers.internal:{}",
config.gateway_port
);
info!(
grpc_endpoint = %config.grpc_endpoint,
"Auto-detected gRPC endpoint via host.containers.internal"
tls = config.tls_enabled(),
"Auto-detected gRPC endpoint"
);
}
@@ -557,51 +571,74 @@ mod tests {
assert!(matches!(err, ComputeDriverError::Message(_)));
}
// ── gateway_port / grpc_endpoint auto-detection ───────────────────────
// ── grpc_endpoint auto-detection ───────────────────────────────────
//
// PodmanComputeDriver::new() fills grpc_endpoint when it is empty.
// These tests use for_tests() (which skips the Podman socket handshake)
// to verify the endpoint that ends up in the config — and therefore in
// OPENSHELL_ENDPOINT inside every sandbox container.
// The scheme (http vs https) depends on whether TLS client certs are
// configured. These tests simulate the auto-detection logic.
#[test]
fn grpc_endpoint_auto_detected_from_gateway_port() {
let config = PodmanComputeConfig {
fn grpc_endpoint_http_without_tls() {
let mut cfg = PodmanComputeConfig {
gateway_port: 8081,
..PodmanComputeConfig::default()
};
// Simulate what new() does once the socket/network checks pass.
let mut cfg = config;
if cfg.grpc_endpoint.is_empty() {
cfg.grpc_endpoint = format!("http://host.containers.internal:{}", cfg.gateway_port);
let scheme = if cfg.tls_enabled() { "https" } else { "http" };
cfg.grpc_endpoint = format!("{scheme}://host.containers.internal:{}", cfg.gateway_port);
}
assert_eq!(cfg.grpc_endpoint, "http://host.containers.internal:8081");
}
#[test]
fn grpc_endpoint_auto_detected_uses_default_port_when_gateway_port_is_default() {
let config = PodmanComputeConfig::default();
assert_eq!(
config.gateway_port,
openshell_core::config::DEFAULT_SERVER_PORT
);
let mut cfg = config;
fn grpc_endpoint_https_with_tls() {
let mut cfg = PodmanComputeConfig {
gateway_port: 8080,
guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")),
guest_tls_cert: Some(PathBuf::from("/tls/tls.crt")),
guest_tls_key: Some(PathBuf::from("/tls/tls.key")),
..PodmanComputeConfig::default()
};
if cfg.grpc_endpoint.is_empty() {
cfg.grpc_endpoint = format!("http://host.containers.internal:{}", cfg.gateway_port);
let scheme = if cfg.tls_enabled() { "https" } else { "http" };
cfg.grpc_endpoint = format!("{scheme}://host.containers.internal:{}", cfg.gateway_port);
}
assert_eq!(cfg.grpc_endpoint, "http://host.containers.internal:8080");
assert_eq!(cfg.grpc_endpoint, "https://host.containers.internal:8080");
}
#[test]
fn explicit_grpc_endpoint_takes_precedence_over_gateway_port() {
let config = PodmanComputeConfig {
fn partial_tls_config_returns_error() {
let cfg = PodmanComputeConfig {
gateway_port: 8080,
guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")),
// guest_tls_cert and guest_tls_key not set — incomplete TLS config.
..PodmanComputeConfig::default()
};
assert!(!cfg.tls_enabled());
let err = cfg
.validate_tls_config()
.expect_err("partial TLS config should be rejected");
let msg = err.to_string();
assert!(
msg.contains("OPENSHELL_PODMAN_TLS_CERT"),
"error should name the missing cert: {msg}"
);
assert!(
msg.contains("OPENSHELL_PODMAN_TLS_KEY"),
"error should name the missing key: {msg}"
);
}
#[test]
fn explicit_grpc_endpoint_takes_precedence() {
let mut cfg = PodmanComputeConfig {
grpc_endpoint: "https://gateway.internal:9000".to_string(),
gateway_port: 8081,
..PodmanComputeConfig::default()
};
let mut cfg = config;
if cfg.grpc_endpoint.is_empty() {
cfg.grpc_endpoint = format!("http://host.containers.internal:{}", cfg.gateway_port);
let scheme = if cfg.tls_enabled() { "https" } else { "http" };
cfg.grpc_endpoint = format!("{scheme}://host.containers.internal:{}", cfg.gateway_port);
}
assert_eq!(cfg.grpc_endpoint, "https://gateway.internal:9000");
}
+15 -1
View File
@@ -86,6 +86,18 @@ struct Args {
/// OCI image containing the openshell-sandbox supervisor binary.
#[arg(long, env = "OPENSHELL_SUPERVISOR_IMAGE")]
supervisor_image: String,
/// Host path to the CA certificate for sandbox mTLS.
#[arg(long, env = "OPENSHELL_PODMAN_TLS_CA")]
podman_tls_ca: Option<PathBuf>,
/// Host path to the client certificate for sandbox mTLS.
#[arg(long, env = "OPENSHELL_PODMAN_TLS_CERT")]
podman_tls_cert: Option<PathBuf>,
/// Host path to the client private key for sandbox mTLS.
#[arg(long, env = "OPENSHELL_PODMAN_TLS_KEY")]
podman_tls_key: Option<PathBuf>,
}
#[tokio::main]
@@ -109,12 +121,14 @@ async fn main() -> Result<()> {
gateway_port: args.gateway_port,
sandbox_ssh_socket_path: args.sandbox_ssh_socket_path,
network_name: args.network_name,
ssh_listen_addr: format!("0.0.0.0:{}", args.sandbox_ssh_port),
ssh_port: args.sandbox_ssh_port,
ssh_handshake_secret: args.ssh_handshake_secret,
ssh_handshake_skew_secs: args.ssh_handshake_skew_secs,
stop_timeout_secs: args.stop_timeout,
supervisor_image: args.supervisor_image,
guest_tls_ca: args.podman_tls_ca,
guest_tls_cert: args.podman_tls_cert,
guest_tls_key: args.podman_tls_key,
})
.await
.into_diagnostic()?;
+59
View File
@@ -447,6 +447,44 @@ mod tests {
use super::{Args, command};
use clap::Parser;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::{LazyLock, Mutex};
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
struct EnvVarGuard {
key: &'static str,
original: Option<String>,
}
impl EnvVarGuard {
#[allow(unsafe_code)]
fn set(key: &'static str, value: &str) -> Self {
let original = std::env::var(key).ok();
// SAFETY: tests serialize environment mutation with ENV_LOCK.
unsafe { std::env::set_var(key, value) };
Self { key, original }
}
#[allow(unsafe_code)]
fn remove(key: &'static str) -> Self {
let original = std::env::var(key).ok();
// SAFETY: tests serialize environment mutation with ENV_LOCK.
unsafe { std::env::remove_var(key) };
Self { key, original }
}
}
impl Drop for EnvVarGuard {
#[allow(unsafe_code)]
fn drop(&mut self) {
match self.original.as_deref() {
// SAFETY: tests serialize environment mutation with ENV_LOCK.
Some(value) => unsafe { std::env::set_var(self.key, value) },
// SAFETY: tests serialize environment mutation with ENV_LOCK.
None => unsafe { std::env::remove_var(self.key) },
}
}
}
#[test]
fn command_uses_gateway_binary_name() {
@@ -465,6 +503,10 @@ mod tests {
#[test]
fn command_defaults_bind_address_to_loopback() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _guard = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS");
let args =
Args::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap();
assert_eq!(args.bind_address, IpAddr::V4(Ipv4Addr::LOCALHOST));
@@ -472,6 +514,10 @@ mod tests {
#[test]
fn command_parses_bind_address() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _guard = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS");
let args = Args::try_parse_from([
"openshell-gateway",
"--db-url",
@@ -482,4 +528,17 @@ mod tests {
.unwrap();
assert_eq!(args.bind_address, IpAddr::V4(Ipv4Addr::LOCALHOST));
}
#[test]
fn command_reads_bind_address_from_env() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _guard = EnvVarGuard::set("OPENSHELL_BIND_ADDRESS", "0.0.0.0");
let args = Args::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"])
.expect("env should provide bind address");
assert_eq!(args.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED));
}
}
+19 -1
View File
@@ -557,6 +557,22 @@ async fn build_compute_runtime(
.filter(|s| !s.is_empty())
.unwrap_or_else(|| openshell_core::config::DEFAULT_SUPERVISOR_IMAGE.to_string());
// TLS client cert paths for sandbox mTLS. When all three are
// set, the Podman driver bind-mounts them into sandbox
// containers and switches the endpoint to https://.
let podman_tls_ca = std::env::var("OPENSHELL_PODMAN_TLS_CA")
.ok()
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from);
let podman_tls_cert = std::env::var("OPENSHELL_PODMAN_TLS_CERT")
.ok()
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from);
let podman_tls_key = std::env::var("OPENSHELL_PODMAN_TLS_KEY")
.ok()
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from);
ComputeRuntime::new_podman(
openshell_driver_podman::PodmanComputeConfig {
socket_path,
@@ -566,12 +582,14 @@ async fn build_compute_runtime(
gateway_port: config.bind_address.port(),
sandbox_ssh_socket_path: config.sandbox_ssh_socket_path.clone(),
network_name,
ssh_listen_addr: format!("0.0.0.0:{}", config.sandbox_ssh_port),
ssh_port: config.sandbox_ssh_port,
ssh_handshake_secret: config.ssh_handshake_secret.clone(),
ssh_handshake_skew_secs: config.ssh_handshake_skew_secs,
stop_timeout_secs,
supervisor_image,
guest_tls_ca: podman_tls_ca,
guest_tls_cert: podman_tls_cert,
guest_tls_key: podman_tls_key,
},
store,
sandbox_index,
+1
View File
@@ -1727,6 +1727,7 @@ fn bootstrap_gateway(rootfs: &Path, gateway_name: &str, gateway_port: u16) -> Re
name: gateway_name.to_string(),
gateway_endpoint: format!("https://127.0.0.1:{gateway_port}"),
gateway_port,
client_lifecycle_managed: Some(true),
..Default::default()
};
+6 -23
View File
@@ -7,9 +7,8 @@
#
# Targets:
# gateway Final gateway image
# supervisor Final supervisor image
# supervisor Final supervisor image (FROM scratch, binary only)
# cluster Final cluster image
# supervisor-output Minimal stage exporting only the supervisor binary
#
# Rust binaries are built natively before the image build and staged at:
# deploy/docker/.build/prebuilt-binaries/<arch>/openshell-{gateway,sandbox}
@@ -87,11 +86,6 @@ FROM rust-builder AS supervisor-binary-1
FROM supervisor-binary-${BUILD_FROM_SOURCE} AS supervisor-binary
# Minimal extraction stage for fast-deploy: exports only the supervisor
# binary (~20-40 MB) instead of the entire build environment (~968 MB).
FROM scratch AS supervisor-output
COPY --from=supervisor-binary /build/out/openshell-sandbox /openshell-sandbox
# ---------------------------------------------------------------------------
# Final gateway image
# ---------------------------------------------------------------------------
@@ -120,22 +114,11 @@ CMD ["--bind-address", "0.0.0.0", "--port", "8080"]
# ---------------------------------------------------------------------------
# Final supervisor image
# ---------------------------------------------------------------------------
FROM nvcr.io/nvidia/base/ubuntu:noble-20251013 AS supervisor
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates && \
apt-get install -y --only-upgrade gpgv && \
rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --user-group openshell
WORKDIR /app
COPY --from=supervisor-binary /build/out/openshell-sandbox /usr/local/bin/
USER openshell
ENTRYPOINT ["openshell-sandbox"]
# Minimal FROM scratch image containing only the supervisor binary.
# Used by both the Docker driver (binary extraction) and the Podman driver
# (OCI image volume mount at /opt/openshell/bin).
FROM scratch AS supervisor
COPY --from=supervisor-binary /build/out/openshell-sandbox /openshell-sandbox
# ---------------------------------------------------------------------------
# Cluster asset stages
+210
View File
@@ -0,0 +1,210 @@
---
title: OPENSHELL-GATEWAY
section: 8
header: OpenShell Manual
footer: openshell-gateway
date: 2025
---
# NAME
openshell-gateway - OpenShell gateway server daemon
# SYNOPSIS
**openshell-gateway** \[*OPTIONS*\]
# DESCRIPTION
**openshell-gateway** is the control-plane server for OpenShell. It
manages sandbox lifecycle, stores provider credentials, delivers
network and filesystem policies to sandboxes, routes inference
requests, and provides the SSH tunnel endpoint for CLI-to-sandbox
connections.
When installed via RPM, the gateway runs as a systemd user service
with the Podman compute driver. Sandboxes are rootless Podman
containers on the host.
The gateway exposes a single port (default 8080) with multiplexed
gRPC and HTTP, secured by mutual TLS (mTLS) by default.
# OPTIONS
**--bind-address** *IP*
: IP address to bind all listeners to. Default: **127.0.0.1**.
Environment: **OPENSHELL_BIND_ADDRESS**.
**--port** *PORT*
: Port for the gRPC/HTTP API. Default: **8080**.
Environment: **OPENSHELL_SERVER_PORT**.
**--health-port** *PORT*
: Port for unauthenticated health endpoints (/healthz, /readyz).
Set to 0 to disable. Default: **0**.
Environment: **OPENSHELL_HEALTH_PORT**.
**--metrics-port** *PORT*
: Port for Prometheus metrics (/metrics). Set to 0 to disable.
Default: **0**. Environment: **OPENSHELL_METRICS_PORT**.
**--log-level** *LEVEL*
: Log level: trace, debug, info, warn, error. Default: **info**.
Environment: **OPENSHELL_LOG_LEVEL**.
**--db-url** *URL*
: SQLite database URL for state persistence. Required.
Environment: **OPENSHELL_DB_URL**.
**--drivers** *DRIVER*\[,*DRIVER*\]
: Compute driver. Accepts a comma-delimited list. The gateway
currently requires exactly one driver. Options: **podman**,
**docker**, **kubernetes**. Default: **kubernetes**.
Environment: **OPENSHELL_DRIVERS**.
**--tls-cert** *PATH*
: Path to server TLS certificate file. Required unless
**--disable-tls** is set. Environment: **OPENSHELL_TLS_CERT**.
**--tls-key** *PATH*
: Path to server TLS private key file. Required unless
**--disable-tls** is set. Environment: **OPENSHELL_TLS_KEY**.
**--tls-client-ca** *PATH*
: Path to CA certificate for client certificate verification (mTLS).
Required unless **--disable-tls** is set.
Environment: **OPENSHELL_TLS_CLIENT_CA**.
**--disable-tls**
: Disable TLS entirely and listen on plaintext HTTP. When the bind
address is **0.0.0.0** (the RPM default), disabling TLS exposes the
API to the entire network without authentication. Only use when the
gateway sits behind a TLS-terminating reverse proxy, or restrict
**--bind-address** to **127.0.0.1**.
Environment: **OPENSHELL_DISABLE_TLS**.
**--disable-gateway-auth**
: Disable mTLS client certificate requirement. The TLS handshake
accepts connections without a client certificate. Ignored when
**--disable-tls** is set.
Environment: **OPENSHELL_DISABLE_GATEWAY_AUTH**.
**--sandbox-image** *IMAGE*
: Default container image for sandboxes.
Environment: **OPENSHELL_SANDBOX_IMAGE**.
**--sandbox-image-pull-policy** *POLICY*
: Image pull policy: Always, IfNotPresent, Never.
Environment: **OPENSHELL_SANDBOX_IMAGE_PULL_POLICY**.
**--ssh-handshake-secret** *SECRET*
: Shared secret for gateway-to-sandbox SSH handshake.
Environment: **OPENSHELL_SSH_HANDSHAKE_SECRET**.
**--ssh-handshake-skew-secs** *SECONDS*
: Allowed clock skew in seconds for SSH handshake. Default: **30**.
Environment: **OPENSHELL_SSH_HANDSHAKE_SKEW_SECS**.
**--ssh-gateway-host** *HOST*
: Public host for the SSH gateway endpoint. Default: **127.0.0.1**.
Environment: **OPENSHELL_SSH_GATEWAY_HOST**.
**--ssh-gateway-port** *PORT*
: Public port for the SSH gateway endpoint. Default: **8080**.
Environment: **OPENSHELL_SSH_GATEWAY_PORT**.
**--grpc-endpoint** *URL*
: gRPC endpoint for sandbox callbacks. Should be reachable from
within sandbox containers.
Environment: **OPENSHELL_GRPC_ENDPOINT**.
# SYSTEMD INTEGRATION
The RPM installs a systemd user unit at
*/usr/lib/systemd/user/openshell-gateway.service*. Manage the gateway
with standard systemd commands:
systemctl --user enable --now openshell-gateway
systemctl --user status openshell-gateway
systemctl --user restart openshell-gateway
systemctl --user stop openshell-gateway
View logs:
journalctl --user -u openshell-gateway
journalctl --user -u openshell-gateway -f
The unit runs two **ExecStartPre** scripts on first start:
1. **init-pki.sh** generates a self-signed PKI bundle for mTLS.
2. **init-gateway-env.sh** generates the environment configuration
file with an auto-generated SSH handshake secret.
Both scripts are idempotent and skip generation if their output files
already exist.
To persist the service across logouts:
sudo loginctl enable-linger $USER
# CONFIGURATION
The systemd user unit reads configuration from
*~/.config/openshell/gateway.env*. See **openshell-gateway.env**(5)
for the full variable reference.
To override individual settings without modifying gateway.env:
systemctl --user edit openshell-gateway
This creates a drop-in override that persists across package upgrades.
# FILES
*/usr/bin/openshell-gateway*
: Gateway binary.
*/usr/lib/systemd/user/openshell-gateway.service*
: Systemd user unit file.
*/usr/libexec/openshell/init-pki.sh*
: PKI bootstrap script.
*/usr/libexec/openshell/init-gateway-env.sh*
: Gateway environment file generator.
*~/.config/openshell/gateway.env*
: Gateway environment configuration (generated on first start).
*~/.local/state/openshell/tls/*
: Auto-generated TLS certificates.
*~/.local/state/openshell/gateway.db*
: SQLite database for gateway state.
*~/.config/openshell/gateways/openshell/mtls/*
: Client mTLS certificates for CLI auto-discovery.
# EXAMPLES
Start the gateway as a systemd user service:
systemctl --user enable --now openshell-gateway
Check gateway health from the CLI:
openshell gateway add --local https://127.0.0.1:8080
openshell status
Override the API port via a systemd drop-in:
systemctl --user edit openshell-gateway
# Add: [Service]
# Add: Environment=OPENSHELL_SERVER_PORT=9090
# SEE ALSO
**openshell**(1), **openshell-gateway.env**(5), **systemctl**(1),
**journalctl**(1), **loginctl**(1), **podman**(1)
Full documentation: *https://docs.nvidia.com/openshell/*
+168
View File
@@ -0,0 +1,168 @@
---
title: OPENSHELL-GATEWAY.ENV
section: 5
header: OpenShell Manual
footer: openshell-gateway
date: 2025
---
# NAME
openshell-gateway.env - OpenShell gateway environment configuration
# DESCRIPTION
The **openshell-gateway.env** file contains environment variables that
configure the OpenShell gateway server when running as a systemd user
service. It is generated automatically on first start by
**init-gateway-env.sh** and is not overwritten on subsequent starts or
package upgrades.
The file uses the standard systemd **EnvironmentFile** format: one
**KEY=VALUE** pair per line. Lines beginning with **#** are comments.
Shell variable expansion is not performed.
# LOCATION
The file is located at:
~/.config/openshell/gateway.env
The systemd user unit reads it via:
EnvironmentFile=-~/.config/openshell/gateway.env
The **-** prefix means the service starts normally if the file does not
exist (the unit has built-in defaults for all required settings except
the SSH handshake secret).
# VARIABLES
## Required
**OPENSHELL_SSH_HANDSHAKE_SECRET**
: Shared HMAC secret for gateway-to-sandbox SSH handshake
authentication. Auto-generated as a 32-byte hex string on first
start. To regenerate: **openssl rand -hex 32**.
## Gateway
**OPENSHELL_BIND_ADDRESS** (default: 0.0.0.0)
: IP address to bind all listeners to. The RPM default of **0.0.0.0**
exposes the gateway on all network interfaces; mTLS must remain
enabled to prevent unauthenticated access. Set to **127.0.0.1** for
local-only access.
**OPENSHELL_SERVER_PORT** (default: 8080)
: Port for the multiplexed gRPC/HTTP API.
**OPENSHELL_HEALTH_PORT** (default: 0)
: Port for unauthenticated health endpoints (/healthz, /readyz).
Set to a non-zero value to enable a dedicated health listener.
**OPENSHELL_METRICS_PORT** (default: 0)
: Port for Prometheus metrics endpoint (/metrics). Set to a
non-zero value to enable a dedicated metrics listener.
**OPENSHELL_LOG_LEVEL** (default: info)
: Log verbosity: **trace**, **debug**, **info**, **warn**, **error**.
**OPENSHELL_DRIVERS** (default: podman)
: Compute driver for sandbox management. Options: **podman**,
**docker**, **kubernetes**. The RPM unit defaults to **podman**.
**OPENSHELL_DB_URL** (default: sqlite://$XDG_STATE_HOME/openshell/gateway.db)
: SQLite database URL for gateway state persistence.
**OPENSHELL_DISABLE_GATEWAY_AUTH** (default: unset)
: Set to **true** to disable mTLS client certificate verification.
## TLS
**OPENSHELL_TLS_CERT** (default: auto-generated path)
: Path to server TLS certificate.
**OPENSHELL_TLS_KEY** (default: auto-generated path)
: Path to server TLS private key.
**OPENSHELL_TLS_CLIENT_CA** (default: auto-generated path)
: Path to CA certificate for client certificate verification.
**OPENSHELL_DISABLE_TLS** (default: unset)
: Set to **true** to disable TLS entirely and listen on plaintext
HTTP. Not recommended for production. When the bind address is
**0.0.0.0** (the RPM default), disabling TLS exposes the API to the
entire network without authentication. Restrict
**OPENSHELL_BIND_ADDRESS** to **127.0.0.1** or place the gateway
behind a TLS-terminating reverse proxy.
**OPENSHELL_PODMAN_TLS_CA** (default: auto-generated path)
: CA certificate bind-mounted into sandbox containers.
**OPENSHELL_PODMAN_TLS_CERT** (default: auto-generated path)
: Client certificate bind-mounted into sandbox containers.
**OPENSHELL_PODMAN_TLS_KEY** (default: auto-generated path)
: Client private key bind-mounted into sandbox containers.
## Images
**OPENSHELL_SUPERVISOR_IMAGE** (default: ghcr.io/nvidia/openshell/supervisor:latest)
: OCI image containing the supervisor binary, mounted read-only
into sandbox containers.
**OPENSHELL_SANDBOX_IMAGE** (default: ghcr.io/nvidia/openshell-community/sandboxes/base:latest)
: Default OCI image for sandbox containers.
**OPENSHELL_SANDBOX_IMAGE_PULL_POLICY** (default: missing)
: When to pull sandbox images: **always** (every sandbox creation),
**missing** (only if not cached locally), **never** (use cached
only), **newer** (pull if a newer version exists).
## Podman Driver
**OPENSHELL_PODMAN_SOCKET** (default: $XDG_RUNTIME_DIR/podman/podman.sock)
: Path to the Podman API Unix socket.
**OPENSHELL_NETWORK_NAME** (default: openshell)
: Name of the Podman bridge network for sandbox containers. Created
automatically if it does not exist.
**OPENSHELL_STOP_TIMEOUT** (default: 10)
: Seconds to wait after SIGTERM before sending SIGKILL when stopping
a sandbox container.
# EXAMPLES
Change the API port to 9090:
OPENSHELL_SERVER_PORT=9090
Pin sandbox images to a specific version:
OPENSHELL_SUPERVISOR_IMAGE=ghcr.io/nvidia/openshell/supervisor:v0.0.37
OPENSHELL_SANDBOX_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:v0.0.37
Air-gapped deployment (pre-loaded images, no registry access):
OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=never
Enable debug logging:
OPENSHELL_LOG_LEVEL=debug
Use externally-managed TLS certificates:
OPENSHELL_TLS_CERT=/etc/pki/tls/certs/openshell.crt
OPENSHELL_TLS_KEY=/etc/pki/tls/private/openshell.key
OPENSHELL_TLS_CLIENT_CA=/etc/pki/tls/certs/openshell-ca.crt
Disable TLS (behind a reverse proxy):
OPENSHELL_DISABLE_TLS=true
# SEE ALSO
**openshell-gateway**(8), **openshell**(1), **systemd.exec**(5)
Full documentation: *https://docs.nvidia.com/openshell/*
+217
View File
@@ -0,0 +1,217 @@
---
title: OPENSHELL
section: 1
header: OpenShell Manual
footer: openshell
date: 2025
---
# NAME
openshell - CLI for managing OpenShell sandboxes, gateways, and providers
# SYNOPSIS
**openshell** \[*OPTIONS*\] *COMMAND* \[*ARGS*\]
# DESCRIPTION
**openshell** is the command-line interface for OpenShell, a platform
providing safe, sandboxed runtimes for autonomous AI agents. It manages
the gateway control plane, sandbox lifecycle, credential providers,
network policies, and inference routing.
The CLI communicates with a gateway server over gRPC. The gateway can
run as a systemd user service (RPM deployment with Podman driver), a
Docker container with embedded K3s, or behind a cloud reverse proxy.
# COMMANDS
## Gateway Management
**gateway start**
: Deploy a new gateway using Docker (not applicable to RPM deployments;
use **systemctl --user start openshell-gateway** instead).
**gateway stop**
: Stop a Docker-managed gateway (use **systemctl --user stop
openshell-gateway** for RPM deployments).
**gateway destroy** \[**--name** *NAME*\]
: Destroy a gateway. For RPM deployments, this removes the CLI
registration only.
**gateway add** *ENDPOINT* \[**--local**\] \[**--name** *NAME*\] \[**--remote** *USER@HOST*\]
: Register an existing gateway with the CLI.
**gateway select** \[*NAME*\]
: List registered gateways or switch the active gateway.
**gateway info** \[**--name** *NAME*\]
: Show deployment details for a gateway.
**gateway login**
: Re-authenticate with a cloud gateway.
**status**
: Check the health of the active gateway.
## Sandbox Management
**sandbox create** \[**--from** *IMAGE*\] \[**--policy** *FILE*\] \[**--provider** *NAME*\] \[**--gpu**\] \[**--upload** *SRC:DST*\] \[**--forward** *PORT*\] \[**--** *COMMAND*\]
: Create a new sandbox. If no gateway exists, auto-bootstraps one
(Docker mode only).
**sandbox list** \[**--selector** *LABEL*\]
: List all sandboxes on the active gateway.
**sandbox get** *NAME*
: Show details for a sandbox.
**sandbox delete** *NAME* \| **--all**
: Delete one or all sandboxes.
**sandbox connect** *NAME* \[**--editor** *EDITOR*\]
: SSH into a running sandbox.
**sandbox exec** **-n** *NAME* \[**--workdir** *DIR*\] **--** *COMMAND*
: Execute a command in a sandbox.
**sandbox upload** *NAME* *LOCAL* *REMOTE*
: Upload files to a sandbox.
**sandbox download** *NAME* *REMOTE* *LOCAL*
: Download files from a sandbox.
## Policy Management
**policy get** *SANDBOX* \[**--full**\]
: Show the active policy for a sandbox.
**policy set** *SANDBOX* **--policy** *FILE* \[**--wait**\]
: Apply a policy to a sandbox.
**policy update** *SANDBOX* \[**--add-endpoint** *SPEC*\] \[**--add-allow** *RULE*\]
: Incrementally update a sandbox policy.
**policy list** *SANDBOX*
: Show policy revision history.
**policy prove** **--policy** *FILE* \[**--credentials** *FILE*\]
: Verify policy properties.
## Provider Management
**provider create** **--name** *NAME* **--type** *TYPE* \[**--from-existing**\] \[**--credential** *KEY=VALUE*\]
: Create a credential provider.
**provider list**
: List all providers.
**provider get** *NAME*
: Show provider details.
**provider update** *NAME* \[**--from-existing**\] \[**--credential** *KEY=VALUE*\]
: Update provider credentials.
**provider delete** *NAME*
: Delete a provider.
## Inference Routing
**inference set** **--provider** *NAME* **--model** *MODEL*
: Configure inference routing.
**inference get**
: Show current inference configuration.
**inference update** \[**--model** *MODEL*\]
: Update inference configuration.
## Other
**logs** *SANDBOX* \[**--tail**\]
: View sandbox logs.
**forward start** *PORT* *SANDBOX* \[**-d**\]
: Start port forwarding to a sandbox.
**forward stop** *PORT*
: Stop port forwarding.
**forward list**
: List active port forwards.
**term**
: Open the real-time TUI dashboard.
**doctor check** \| **logs** \| **exec** \| **llm.txt**
: Diagnostic tools (Docker/K3s mode only; see **TROUBLESHOOTING**
section for RPM alternatives).
**completions** *SHELL*
: Generate shell completions (bash, zsh, fish).
# GLOBAL OPTIONS
**-g**, **--gateway** *NAME*
: Target a specific gateway by name.
**--gateway-endpoint** *URL*
: Connect to a gateway by URL directly.
**-h**, **--help**
: Print help information.
**-V**, **--version**
: Print version.
# ENVIRONMENT
**OPENSHELL_GATEWAY**
: Default gateway name (overrides active gateway).
**OPENSHELL_GATEWAY_ENDPOINT**
: Direct gateway URL (bypasses metadata lookup).
**ANTHROPIC_API_KEY**, **OPENAI_API_KEY**, **OPENROUTER_API_KEY**
: API keys discovered by auto-provider creation.
**GITHUB_TOKEN**, **GH_TOKEN**
: GitHub credentials for provider auto-discovery.
# FILES
*~/.config/openshell/gateways/*
: Gateway metadata and mTLS certificates.
*~/.config/openshell/active_gateway*
: Name of the currently active gateway.
# EXAMPLES
Register the local RPM gateway and create a sandbox:
openshell gateway add --local https://127.0.0.1:8080
openshell sandbox create -- claude
List sandboxes and connect to one:
openshell sandbox list
openshell sandbox connect my-sandbox
Create a provider from a local environment variable:
openshell provider create --name openai --type openai --from-existing
Check gateway health:
openshell status
# SEE ALSO
**openshell-gateway**(8), **openshell-gateway.env**(5)
Full documentation: *https://docs.nvidia.com/openshell/*
Run **openshell** *COMMAND* **--help** for detailed help on any command.
+253
View File
@@ -0,0 +1,253 @@
# OpenShell Gateway Configuration (RPM)
Configuration reference for the OpenShell gateway when installed via
the RPM package on Fedora and RHEL systems.
For first-time setup, see QUICKSTART.md. For troubleshooting, see
TROUBLESHOOTING.md.
## TLS (mTLS)
The RPM enables mutual TLS by default. The gateway requires a valid
client certificate for all API connections, protecting the API even
though it listens on all interfaces (`0.0.0.0`).
### Auto-generated certificates
On first start, the `init-pki.sh` script generates certificates using
OpenSSL:
| File | Purpose | Location |
|------|---------|----------|
| CA certificate | Root of trust | `~/.local/state/openshell/tls/ca.crt` |
| CA private key | Signs server and client certs | `~/.local/state/openshell/tls/ca.key` |
| Server certificate | Gateway TLS identity | `~/.local/state/openshell/tls/server/tls.crt` |
| Server private key | Gateway TLS key | `~/.local/state/openshell/tls/server/tls.key` |
| Client certificate | CLI and sandbox identity | `~/.local/state/openshell/tls/client/tls.crt` |
| Client private key | CLI and sandbox key | `~/.local/state/openshell/tls/client/tls.key` |
Client certificates are also copied to the CLI auto-discovery directory:
```
~/.config/openshell/gateways/openshell/mtls/
ca.crt
tls.crt
tls.key
```
The CLI automatically discovers these certificates when connecting to a
gateway on `localhost` or `127.0.0.1`.
### Server certificate SANs
The auto-generated server certificate includes these Subject Alternative
Names:
- `localhost`
- `openshell`
- `openshell.openshell.svc`
- `openshell.openshell.svc.cluster.local`
- `host.containers.internal`
- `host.docker.internal`
- `127.0.0.1`
To connect from a remote machine, you need externally-managed
certificates with additional SANs. See "Remote CLI access" in
TROUBLESHOOTING.md.
### Using externally-managed certificates
To use certificates from an external CA or cert-manager:
1. Place the server cert, key, and CA cert on the filesystem.
1. Edit `~/.config/openshell/gateway.env` or use
`systemctl --user edit openshell-gateway` to override:
```shell
OPENSHELL_TLS_CERT=/path/to/server/tls.crt
OPENSHELL_TLS_KEY=/path/to/server/tls.key
OPENSHELL_TLS_CLIENT_CA=/path/to/ca.crt
```
1. Place the client cert where the CLI expects it:
```
~/.config/openshell/gateways/openshell/mtls/
ca.crt
tls.crt
tls.key
```
### Rotating certificates
Delete the TLS state directory and restart the gateway:
```shell
rm -rf ~/.local/state/openshell/tls
systemctl --user restart openshell-gateway
```
The gateway regenerates the PKI on next start.
### Disabling TLS
> **WARNING:** The RPM gateway binds to all interfaces (`0.0.0.0`) by
> default. With TLS disabled, the gateway API is exposed to the entire
> network with **no authentication**. Any host that can reach the
> gateway port has full access, including the ability to create
> sandboxes, execute arbitrary code, and access configured credentials.
> Only disable TLS when the gateway is behind a TLS-terminating reverse
> proxy that enforces its own authentication. When disabling TLS without
> a reverse proxy, restrict `OPENSHELL_BIND_ADDRESS` to `127.0.0.1`.
To disable TLS (not recommended for production):
1. Edit `~/.config/openshell/gateway.env`:
```shell
OPENSHELL_DISABLE_TLS=true
```
1. Comment out the `OPENSHELL_TLS_*` and `OPENSHELL_PODMAN_TLS_*`
variables if they are set.
1. Restart the gateway.
## Sandbox TLS
When mTLS is enabled, the Podman driver bind-mounts the client
certificates into each sandbox container so the supervisor process can
establish an mTLS connection back to the gateway.
The following environment variables control the host-side paths of the
client certificates that are mounted into sandbox containers:
| Variable | Description |
|----------|-------------|
| `OPENSHELL_PODMAN_TLS_CA` | CA certificate (host path) |
| `OPENSHELL_PODMAN_TLS_CERT` | Client certificate (host path) |
| `OPENSHELL_PODMAN_TLS_KEY` | Client private key (host path) |
Inside the container, the supervisor reads them from:
- `/etc/openshell/tls/client/ca.crt`
- `/etc/openshell/tls/client/tls.crt`
- `/etc/openshell/tls/client/tls.key`
On SELinux-enabled systems, the Podman driver automatically applies the
`:z` relabel option to these bind mounts. No manual SELinux
configuration is required.
## Configuration reference
All settings are controlled via environment variables. The user unit
reads from `~/.config/openshell/gateway.env` (generated on first start)
and from `Environment=` directives in the systemd unit.
Values in `gateway.env` override the unit defaults. Use
`systemctl --user edit openshell-gateway` to add overrides that persist
across package upgrades.
### Gateway settings
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENSHELL_BIND_ADDRESS` | `0.0.0.0` | IP address to bind all listeners to. The default exposes the gateway on all interfaces; mTLS must remain enabled to prevent unauthenticated access. Set to `127.0.0.1` for local-only access. |
| `OPENSHELL_SERVER_PORT` | `8080` | Port for the gRPC/HTTP API |
| `OPENSHELL_HEALTH_PORT` | `0` (disabled) | Port for unauthenticated health endpoints (`/healthz`, `/readyz`). Set to a non-zero value to enable. |
| `OPENSHELL_METRICS_PORT` | `0` (disabled) | Port for Prometheus metrics (`/metrics`). Set to a non-zero value to enable. |
| `OPENSHELL_LOG_LEVEL` | `info` | Log level: `trace`, `debug`, `info`, `warn`, `error` |
| `OPENSHELL_DRIVERS` | `podman` | Compute driver (`podman`, `docker`, `kubernetes`) |
| `OPENSHELL_DB_URL` | `sqlite://$XDG_STATE_HOME/openshell/gateway.db` | SQLite database URL for state persistence |
| `OPENSHELL_SSH_HANDSHAKE_SECRET` | (auto-generated) | Shared secret for sandbox SSH authentication |
| `OPENSHELL_DISABLE_GATEWAY_AUTH` | (unset) | Set to `true` to skip mTLS client certificate checks |
### TLS settings
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENSHELL_TLS_CERT` | (auto-generated path) | Server TLS certificate |
| `OPENSHELL_TLS_KEY` | (auto-generated path) | Server TLS private key |
| `OPENSHELL_TLS_CLIENT_CA` | (auto-generated path) | CA for client certificate verification |
| `OPENSHELL_DISABLE_TLS` | (unset) | Set to `true` to disable TLS |
| `OPENSHELL_PODMAN_TLS_CA` | (auto-generated path) | CA cert mounted into sandbox containers |
| `OPENSHELL_PODMAN_TLS_CERT` | (auto-generated path) | Client cert mounted into sandbox containers |
| `OPENSHELL_PODMAN_TLS_KEY` | (auto-generated path) | Client key mounted into sandbox containers |
### Sandbox settings
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENSHELL_SUPERVISOR_IMAGE` | `ghcr.io/nvidia/openshell/supervisor:latest` | Supervisor binary OCI image |
| `OPENSHELL_SANDBOX_IMAGE` | `ghcr.io/nvidia/openshell-community/sandboxes/base:latest` | Default sandbox base image |
| `OPENSHELL_SANDBOX_IMAGE_PULL_POLICY` | `missing` | Image pull policy: `always`, `missing`, `never`, `newer` |
### Podman driver settings
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENSHELL_PODMAN_SOCKET` | `$XDG_RUNTIME_DIR/podman/podman.sock` | Podman API Unix socket path |
| `OPENSHELL_NETWORK_NAME` | `openshell` | Podman bridge network name for sandbox containers |
| `OPENSHELL_STOP_TIMEOUT` | `10` | Container stop timeout in seconds (SIGTERM then SIGKILL) |
### Image management
The gateway pulls container images automatically on first sandbox
creation. The default pull policy is `missing`, which means images are
pulled once and then cached by Podman.
To update cached images:
```shell
podman pull ghcr.io/nvidia/openshell/supervisor:latest
podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest
```
Or set `OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=always` to pull on every
sandbox creation.
To pin specific image versions instead of `:latest`:
```shell
OPENSHELL_SUPERVISOR_IMAGE=ghcr.io/nvidia/openshell/supervisor:v0.0.37
OPENSHELL_SANDBOX_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:v0.0.37
```
For air-gapped environments:
1. On a connected machine, pull and save the images:
```shell
podman pull ghcr.io/nvidia/openshell/supervisor:latest
podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest
podman save -o supervisor.tar ghcr.io/nvidia/openshell/supervisor:latest
podman save -o sandbox.tar ghcr.io/nvidia/openshell-community/sandboxes/base:latest
```
1. Transfer the tarballs to the air-gapped host and load them:
```shell
podman load -i supervisor.tar
podman load -i sandbox.tar
```
1. Set pull policy to `never`:
```shell
OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=never
```
## File locations
| Purpose | Path |
|---------|------|
| Gateway binary | `/usr/bin/openshell-gateway` |
| CLI binary | `/usr/bin/openshell` |
| Systemd user unit | `/usr/lib/systemd/user/openshell-gateway.service` |
| PKI bootstrap script | `/usr/libexec/openshell/init-pki.sh` |
| Env generator script | `/usr/libexec/openshell/init-gateway-env.sh` |
| TLS certificates | `~/.local/state/openshell/tls/` |
| CLI client certs | `~/.config/openshell/gateways/openshell/mtls/` |
| Gateway database | `~/.local/state/openshell/gateway.db` |
| Gateway configuration | `~/.config/openshell/gateway.env` |
+164
View File
@@ -0,0 +1,164 @@
# OpenShell RPM Quick Start
Get from `dnf install` to a running sandbox in five minutes.
## Prerequisites
### Podman (rootless)
The gateway uses rootless Podman for sandbox containers. Verify
Podman is installed and the cgroup version is v2:
```shell
podman --version
podman info --format '{{.Host.CgroupsVersion}}'
```
The cgroup version must be `v2`. If it reports `v1`, enable the
unified cgroup hierarchy and reboot:
```shell
sudo grubby --update-kernel=ALL --args="systemd.unified_cgroup_hierarchy=1"
sudo reboot
```
### Subordinate UID/GID ranges
Rootless containers require subordinate UID/GID mappings:
```shell
grep $USER /etc/subuid /etc/subgid
```
If empty, add entries:
```shell
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
```
### Podman socket
The gateway communicates with Podman over its API socket. Enable
socket activation:
```shell
systemctl --user enable --now podman.socket
```
### Network access
The gateway pulls container images from ghcr.io on first sandbox
creation. Ensure the host can reach ghcr.io over HTTPS (port 443).
For air-gapped environments, pre-load images with `podman pull` and
set `OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=never` in
`~/.config/openshell/gateway.env`. See CONFIGURATION.md for details.
## Start the gateway
```shell
systemctl --user enable --now openshell-gateway
```
On first start, the gateway automatically generates:
- A self-signed PKI bundle (CA, server cert, client cert) for mTLS
- An SSH handshake secret for sandbox authentication
- A commented configuration file at `~/.config/openshell/gateway.env`
> **Note:** The gateway binds to all interfaces (`0.0.0.0`) by default.
> Mutual TLS (mTLS) is enabled automatically on first start, requiring a
> valid client certificate for every connection. Do not disable TLS
> without restricting the bind address to `127.0.0.1`. See
> CONFIGURATION.md for details.
Verify the service is running:
```shell
systemctl --user status openshell-gateway
```
## Register the gateway with the CLI
The CLI needs to know where the gateway is. Register it:
```shell
openshell gateway add --local https://127.0.0.1:8080
```
This discovers the pre-provisioned mTLS certificates at
`~/.config/openshell/gateways/openshell/mtls/` and sets the gateway
as active.
Verify the connection:
```shell
openshell status
```
## Persist across reboots
By default, user services stop when you log out. To keep the gateway
running after logout and across reboots:
```shell
sudo loginctl enable-linger $USER
```
Without this, the gateway and all running sandboxes are killed when
your login session ends. This is required for any headless or
production use.
## Create your first sandbox
Set your API key in the environment, then create a sandbox:
```shell
export ANTHROPIC_API_KEY=sk-...
openshell sandbox create -- claude
```
The CLI detects the agent, prompts to create a credential provider
from your local environment, pulls the sandbox image from ghcr.io,
and connects you to the running sandbox.
Other agents:
```shell
openshell sandbox create -- opencode
openshell sandbox create -- codex
```
## Set up providers manually
If you prefer to configure providers before creating sandboxes:
```shell
# Create a provider from a local environment variable
openshell provider create --name anthropic --type anthropic --from-existing
# Or supply the credential directly
openshell provider create --name openai --type openai \
--credential OPENAI_API_KEY=sk-...
# List configured providers
openshell provider list
```
## Configure inference routing (optional)
To route inference requests through a specific provider and model:
```shell
openshell inference set --provider openai --model gpt-4
openshell inference get
```
## Next steps
- See CONFIGURATION.md for TLS settings, environment variables, and
file locations.
- See TROUBLESHOOTING.md for CLI compatibility notes, remote access,
and common issues.
- Run `man openshell` for the CLI reference.
- Run `man openshell-gateway` for the gateway daemon reference.
+230
View File
@@ -0,0 +1,230 @@
# OpenShell RPM Troubleshooting
Troubleshooting guide, CLI compatibility notes, remote access setup,
and upgrade procedures for the RPM deployment.
## CLI compatibility
The RPM installs the gateway as a systemd user service with the Podman
compute driver. The published online docs and some CLI commands assume
a Docker/K3s deployment model. This section clarifies which commands
work, which do not, and what to use instead.
### Commands that work normally
All sandbox, provider, policy, inference, and settings commands
communicate with the gateway over gRPC and work identically regardless
of deployment mode:
```
openshell status
openshell sandbox create|list|get|delete|connect|exec
openshell logs <sandbox>
openshell provider create|list|get|update|delete
openshell policy get|set|update|list|prove
openshell inference set|get|update
openshell settings get|set
openshell forward start|stop|list
openshell term
openshell gateway add|select|info
openshell gateway destroy (removes CLI registration only)
```
### Commands that do not apply
These commands manage Docker container lifecycle and are not applicable
to the RPM/systemd deployment. Use the systemd equivalents instead.
| CLI command | RPM alternative |
|-------------|-----------------|
| `openshell gateway start` | `systemctl --user start openshell-gateway` |
| `openshell gateway stop` | `systemctl --user stop openshell-gateway` |
| `openshell doctor check` | `systemctl --user status openshell-gateway` |
| `openshell doctor logs` | `journalctl --user -u openshell-gateway` |
| `openshell doctor logs --tail` | `journalctl --user -u openshell-gateway -f` |
| `openshell doctor exec` | Not applicable (no K3s container) |
### Building from local Dockerfiles
`openshell sandbox create --from ./Dockerfile` builds via Docker and
pushes into K3s containerd. With the Podman driver, build the image
with Podman and reference it directly:
```shell
podman build -t my-sandbox ./my-dir
openshell sandbox create --from localhost/my-sandbox
```
## Remote CLI access
The auto-generated server certificate only includes SANs for
`localhost`, `127.0.0.1`, and Podman-internal names. To connect from a
different machine, choose one of the following approaches.
### Option 1: SSH tunnel (simplest)
Forward the gateway port over SSH and connect via localhost:
```shell
# On the remote CLI machine:
ssh -L 8080:127.0.0.1:8080 user@gateway-host
# In another terminal on the same machine:
# Copy the client certs from the gateway host first:
scp -r user@gateway-host:~/.config/openshell/gateways/openshell/mtls/ \
~/.config/openshell/gateways/openshell/mtls/
openshell gateway add --local https://127.0.0.1:8080
openshell status
```
### Option 2: Externally-managed certificates
Generate certificates that include the server's hostname or IP in the
SANs. See "Using externally-managed certificates" in CONFIGURATION.md.
After placing the server and client certs, register from the remote
CLI:
```shell
# Copy client certs to the remote CLI machine
mkdir -p ~/.config/openshell/gateways/openshell/mtls/
cp ca.crt tls.crt tls.key ~/.config/openshell/gateways/openshell/mtls/
openshell gateway add --local https://<gateway-hostname>:8080
```
### Firewall
For remote access, open the gateway port in firewalld:
```shell
sudo firewall-cmd --add-port=8080/tcp --permanent
sudo firewall-cmd --reload
```
For localhost-only access (the default use case), no firewall changes
are needed. Loopback traffic is not filtered by firewalld.
mTLS prevents unauthenticated access even when the port is open to the
network.
## Common issues
### "No active gateway"
The CLI cannot find a registered gateway. This happens when the
gateway is running but has not been registered with the CLI.
```shell
openshell gateway add --local https://127.0.0.1:8080
```
### Gateway fails to start
Check the journal for error details:
```shell
journalctl --user -u openshell-gateway --no-pager -n 50
```
Common causes:
**cgroups v1 detected.** The Podman driver requires cgroups v2.
Check the version:
```shell
stat -fc %T /sys/fs/cgroup
```
Expected output: `cgroup2fs`. If it shows `tmpfs`, enable cgroups v2:
```shell
sudo grubby --update-kernel=ALL --args="systemd.unified_cgroup_hierarchy=1"
sudo reboot
```
**Podman socket not available.** Ensure socket activation is enabled:
```shell
systemctl --user enable --now podman.socket
systemctl --user status podman.socket
```
**TLS certificate errors.** If certs are corrupted, regenerate them:
```shell
rm -rf ~/.local/state/openshell/tls
systemctl --user restart openshell-gateway
```
### Sandbox creation fails
**subuid/subgid missing.** Rootless Podman requires subordinate
UID/GID ranges. If the journal shows warnings about `/etc/subuid` or
container creation fails:
```shell
grep $USER /etc/subuid /etc/subgid
# If empty:
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
```
**Image pull failure.** Verify ghcr.io is reachable:
```shell
podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest
```
### Images not updating
The default image pull policy is `missing` -- images are pulled once
and cached. To update:
```shell
podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest
podman pull ghcr.io/nvidia/openshell/supervisor:latest
```
Or set `OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=always` in
`~/.config/openshell/gateway.env` and restart the gateway.
### Gateway stops on logout
Enable lingering so the service survives logout:
```shell
sudo loginctl enable-linger $USER
```
## SELinux
No SELinux configuration is required on stock Fedora or RHEL. The
Podman driver automatically applies the `:z` relabel option to TLS
bind mounts when SELinux is detected, allowing sandbox containers to
read the certificates through the MAC policy.
## Upgrading
After upgrading the RPM packages:
```shell
sudo dnf update openshell openshell-gateway
systemctl --user restart openshell-gateway
```
The SQLite database schema is auto-migrated on startup. Running
sandboxes are stopped during the restart.
The `gateway.env` file is not overwritten during upgrades. The
`init-gateway-env.sh` script is idempotent and only generates the file
on first start. New configuration options from newer versions can be
added manually by referencing CONFIGURATION.md or running
`openshell-gateway --help`.
To pick up new container images after an upgrade:
```shell
podman pull ghcr.io/nvidia/openshell/supervisor:latest
podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest
```
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Generate the gateway environment configuration file on first start.
#
# Called from the systemd ExecStartPre directive to bootstrap the
# gateway configuration. Idempotent: exits immediately if the file
# already exists.
#
# Usage:
# init-gateway-env.sh <env-file>
#
# The generated file contains an auto-generated SSH handshake secret
# and commented defaults for all gateway environment variables.
set -euo pipefail
ENV_FILE="${1:?Usage: init-gateway-env.sh <env-file>}"
# ── Idempotent: skip if env file already exists ─────────────────────
if [ -f "${ENV_FILE}" ]; then
exit 0
fi
# ── Create parent directory ─────────────────────────────────────────
mkdir -p "$(dirname "${ENV_FILE}")"
# ── Generate SSH handshake secret ───────────────────────────────────
SECRET=$(od -An -tx1 -N32 /dev/urandom | tr -dc 0-9a-f)
# ── Write environment file ──────────────────────────────────────────
cat > "${ENV_FILE}" << EOF
# OpenShell Gateway Environment Configuration
# Generated on first start. Edit freely; this file is not overwritten.
#
# Run 'openshell-gateway --help' for the full list of options.
# See /usr/share/doc/openshell-gateway/ for guides.
# ---- Required ----
# Shared secret for gateway-to-sandbox SSH handshake authentication.
# Auto-generated on first start. To regenerate:
# openssl rand -hex 32
OPENSHELL_SSH_HANDSHAKE_SECRET=${SECRET}
# ---- Optional (uncomment to override defaults) ----
# Database URL for gateway state persistence.
# Default for the user unit: sqlite://\$XDG_STATE_HOME/openshell/gateway.db
#OPENSHELL_DB_URL=sqlite:///path/to/gateway.db
# Compute driver: podman (default for RPM), docker, kubernetes.
#OPENSHELL_DRIVERS=podman
# Bind address. 0.0.0.0 listens on all interfaces; mTLS prevents
# unauthenticated access.
#OPENSHELL_BIND_ADDRESS=0.0.0.0
# API port (default: 8080).
#OPENSHELL_SERVER_PORT=8080
# Log level: trace, debug, info, warn, error.
#OPENSHELL_LOG_LEVEL=info
# ---- Images ----
# Supervisor binary OCI image (mounted read-only into sandboxes).
#OPENSHELL_SUPERVISOR_IMAGE=ghcr.io/nvidia/openshell/supervisor:latest
# Default sandbox base image.
#OPENSHELL_SANDBOX_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest
# Image pull policy: always, missing (default), never, newer.
# Use 'always' to pick up new tags automatically.
# Use 'never' for air-gapped environments with pre-loaded images.
#OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=missing
# ---- TLS (mTLS enabled by default) ----
# PKI is auto-generated by init-pki.sh on first start. Client certs are
# placed in ~/.config/openshell/gateways/openshell/mtls/ so the CLI
# discovers them automatically.
#
# To use externally-managed certs, uncomment and edit the paths below.
# To rotate certs, delete ~/.local/state/openshell/tls/ and restart.
# WARNING: Disabling TLS with the default bind address (0.0.0.0) exposes
# the gateway API to the entire network with NO authentication. Only
# disable TLS when behind a TLS-terminating reverse proxy, or restrict
# OPENSHELL_BIND_ADDRESS to 127.0.0.1.
#OPENSHELL_DISABLE_TLS=true
# Server TLS (gateway listens with these certs).
#OPENSHELL_TLS_CERT=\$XDG_STATE_HOME/openshell/tls/server/tls.crt
#OPENSHELL_TLS_KEY=\$XDG_STATE_HOME/openshell/tls/server/tls.key
#OPENSHELL_TLS_CLIENT_CA=\$XDG_STATE_HOME/openshell/tls/ca.crt
# Podman driver: client certs bind-mounted into sandbox containers.
#OPENSHELL_PODMAN_TLS_CA=\$XDG_STATE_HOME/openshell/tls/ca.crt
#OPENSHELL_PODMAN_TLS_CERT=\$XDG_STATE_HOME/openshell/tls/client/tls.crt
#OPENSHELL_PODMAN_TLS_KEY=\$XDG_STATE_HOME/openshell/tls/client/tls.key
# ---- Podman driver ----
# Podman API Unix socket path.
#OPENSHELL_PODMAN_SOCKET=\$XDG_RUNTIME_DIR/podman/podman.sock
# Podman bridge network name for sandbox containers.
#OPENSHELL_NETWORK_NAME=openshell
# Container stop timeout in seconds (SIGTERM then SIGKILL).
#OPENSHELL_STOP_TIMEOUT=10
EOF
chmod 600 "${ENV_FILE}"
echo "Gateway environment generated: ${ENV_FILE}"
+197
View File
@@ -0,0 +1,197 @@
#!/bin/bash
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Generate a self-signed PKI bundle for the OpenShell gateway.
#
# Called from the systemd ExecStartPre directive to bootstrap mTLS on
# first start. Idempotent: exits immediately if all cert files exist.
# Detects and recovers from partial PKI state (e.g. interrupted runs).
#
# All files are generated in a temporary staging directory first and
# moved into place only after the full PKI is complete, preventing
# partial state from persisting across failures.
#
# Usage:
# init-pki.sh <pki-dir>
#
# Output layout:
# <pki-dir>/ca.crt CA certificate
# <pki-dir>/ca.key CA private key (mode 0600)
# <pki-dir>/server/tls.crt Server certificate
# <pki-dir>/server/tls.key Server private key (mode 0600)
# <pki-dir>/client/tls.crt Client certificate
# <pki-dir>/client/tls.key Client private key (mode 0600)
#
# Client certs are also copied to the CLI's auto-discovery directory:
# $XDG_CONFIG_HOME/openshell/gateways/openshell/mtls/{ca.crt,tls.crt,tls.key}
set -euo pipefail
PKI_DIR="${1:?Usage: init-pki.sh <pki-dir>}"
# ── Resolve CLI cert directory ───────────────────────────────────────
CLI_MTLS_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/openshell/gateways/openshell/mtls"
# ── Required PKI files ───────────────────────────────────────────────
PKI_FILES=(
"${PKI_DIR}/ca.crt"
"${PKI_DIR}/ca.key"
"${PKI_DIR}/server/tls.crt"
"${PKI_DIR}/server/tls.key"
"${PKI_DIR}/client/tls.crt"
"${PKI_DIR}/client/tls.key"
)
CLI_FILES=(
"${CLI_MTLS_DIR}/ca.crt"
"${CLI_MTLS_DIR}/tls.crt"
"${CLI_MTLS_DIR}/tls.key"
)
# ── Idempotent: skip if all PKI files exist ──────────────────────────
all_pki_exist=true
for f in "${PKI_FILES[@]}"; do
if [ ! -f "$f" ]; then
all_pki_exist=false
break
fi
done
if [ "$all_pki_exist" = true ]; then
# PKI is complete. Ensure CLI copies also exist (they may have been
# deleted independently, e.g. user cleared their config directory).
cli_ok=true
for f in "${CLI_FILES[@]}"; do
if [ ! -f "$f" ]; then
cli_ok=false
break
fi
done
if [ "$cli_ok" = false ]; then
echo "PKI exists but CLI auto-discovery certs missing; re-copying..."
mkdir -p "${CLI_MTLS_DIR}"
cp "${PKI_DIR}/ca.crt" "${CLI_MTLS_DIR}/ca.crt"
cp "${PKI_DIR}/client/tls.crt" "${CLI_MTLS_DIR}/tls.crt"
cp "${PKI_DIR}/client/tls.key" "${CLI_MTLS_DIR}/tls.key"
chmod 600 "${CLI_MTLS_DIR}/tls.key"
fi
exit 0
fi
# ── Partial state recovery ───────────────────────────────────────────
# If some PKI files exist but not all, a previous run was interrupted.
# Remove the partial state so we can regenerate cleanly.
partial=false
for f in "${PKI_FILES[@]}"; do
if [ -f "$f" ]; then
partial=true
break
fi
done
if [ "$partial" = true ]; then
echo "WARNING: Partial PKI detected in ${PKI_DIR}, regenerating..."
rm -f "${PKI_DIR}/ca.crt" "${PKI_DIR}/ca.key" "${PKI_DIR}/ca.srl"
rm -rf "${PKI_DIR}/server" "${PKI_DIR}/client"
fi
# ── Temporary workspace (cleaned up on exit) ─────────────────────────
WORK=$(mktemp -d)
trap 'rm -rf "${WORK}"' EXIT
# Stage directory mirrors the final PKI layout.
STAGE="${WORK}/pki"
mkdir -p "${STAGE}/server" "${STAGE}/client"
# ── Server certificate SANs ─────────────────────────────────────────
# These must match what the supervisor connects to. The CLI also
# connects using localhost/127.0.0.1 by default.
cat > "${WORK}/server-san.cnf" <<'EOF'
[req]
distinguished_name = req_dn
req_extensions = v3_req
prompt = no
[req_dn]
O = openshell
CN = openshell-server
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = openshell
DNS.3 = openshell.openshell.svc
DNS.4 = openshell.openshell.svc.cluster.local
DNS.5 = host.containers.internal
DNS.6 = host.docker.internal
IP.1 = 127.0.0.1
EOF
# ── Generate CA (into staging) ───────────────────────────────────────
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout "${STAGE}/ca.key" \
-out "${STAGE}/ca.crt" \
-days 3650 -nodes \
-subj "/O=openshell/CN=openshell-ca" \
2>/dev/null
chmod 600 "${STAGE}/ca.key"
# ── Generate server certificate (into staging) ───────────────────────
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout "${STAGE}/server/tls.key" \
-out "${WORK}/server.csr" \
-nodes \
-config "${WORK}/server-san.cnf" \
2>/dev/null
openssl x509 -req \
-in "${WORK}/server.csr" \
-CA "${STAGE}/ca.crt" -CAkey "${STAGE}/ca.key" -CAcreateserial \
-out "${STAGE}/server/tls.crt" \
-days 3650 \
-extensions v3_req \
-extfile "${WORK}/server-san.cnf" \
2>/dev/null
chmod 600 "${STAGE}/server/tls.key"
# ── Generate client certificate (into staging) ───────────────────────
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout "${STAGE}/client/tls.key" \
-out "${WORK}/client.csr" \
-nodes \
-subj "/O=openshell/CN=openshell-client" \
2>/dev/null
openssl x509 -req \
-in "${WORK}/client.csr" \
-CA "${STAGE}/ca.crt" -CAkey "${STAGE}/ca.key" -CAcreateserial \
-out "${STAGE}/client/tls.crt" \
-days 3650 \
2>/dev/null
chmod 600 "${STAGE}/client/tls.key"
# ── Move staged PKI into final location ──────────────────────────────
# Create parent directories and move files individually. Using mv on
# individual files rather than whole directories so we do not clobber
# the target directory if it already exists.
mkdir -p "${PKI_DIR}/server" "${PKI_DIR}/client"
mv "${STAGE}/ca.crt" "${PKI_DIR}/ca.crt"
mv "${STAGE}/ca.key" "${PKI_DIR}/ca.key"
mv "${STAGE}/server/tls.crt" "${PKI_DIR}/server/tls.crt"
mv "${STAGE}/server/tls.key" "${PKI_DIR}/server/tls.key"
mv "${STAGE}/client/tls.crt" "${PKI_DIR}/client/tls.crt"
mv "${STAGE}/client/tls.key" "${PKI_DIR}/client/tls.key"
# ── Copy client certs to CLI auto-discovery directory ────────────────
# The CLI automatically looks for certs at:
# $XDG_CONFIG_HOME/openshell/gateways/<name>/mtls/{ca.crt,tls.crt,tls.key}
# For localhost gateways, <name> defaults to "openshell".
mkdir -p "${CLI_MTLS_DIR}"
cp "${PKI_DIR}/ca.crt" "${CLI_MTLS_DIR}/ca.crt"
cp "${PKI_DIR}/client/tls.crt" "${CLI_MTLS_DIR}/tls.crt"
cp "${PKI_DIR}/client/tls.key" "${CLI_MTLS_DIR}/tls.key"
chmod 600 "${CLI_MTLS_DIR}/tls.key"
echo "PKI bootstrap complete: ${PKI_DIR}"
+2 -2
View File
@@ -20,7 +20,7 @@
#
# Prerequisites:
# - Rootless Podman service running (systemctl --user start podman.socket)
# - Supervisor sideload image built (mise run build:docker:supervisor-sideload)
# - Supervisor image built (mise run build:docker:supervisor)
# - Sandbox base image available locally
set -euo pipefail
@@ -71,7 +71,7 @@ SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-}"
# Verify the supervisor image exists locally.
if ! podman image exists "${SUPERVISOR_IMAGE}" 2>/dev/null; then
echo "ERROR: supervisor image '${SUPERVISOR_IMAGE}' not found locally."
echo "Build it with: mise run build:docker:supervisor-sideload"
echo "Build it with: mise run build:docker:supervisor"
exit 1
fi
+30 -1
View File
@@ -1,4 +1,4 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
[[tools."cargo:cargo-zigbuild"]]
version = "0.22.3"
@@ -128,14 +128,30 @@ backend = "aqua:k3d-io/k3d"
checksum = "sha256:0b8110f2229631af7402fb828259330985918b08fefd38b7f1b788a1c8687216"
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-linux-arm64"
[tools.k3d."platforms.linux-arm64-musl"]
checksum = "sha256:0b8110f2229631af7402fb828259330985918b08fefd38b7f1b788a1c8687216"
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-linux-arm64"
[tools.k3d."platforms.linux-x64"]
checksum = "sha256:dbaa79a76ace7f4ca230a1ff41dc7d8a5036a8ad0309e9c54f9bf3836dbe853e"
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-linux-amd64"
[tools.k3d."platforms.linux-x64-musl"]
checksum = "sha256:dbaa79a76ace7f4ca230a1ff41dc7d8a5036a8ad0309e9c54f9bf3836dbe853e"
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-linux-amd64"
[tools.k3d."platforms.macos-arm64"]
checksum = "sha256:8da468daa7dc7cf7cdd4735f90a9bb05179fa27858250f62e3d8cdf5b5ca0698"
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-darwin-arm64"
[tools.k3d."platforms.macos-x64"]
checksum = "sha256:fd0f8e9e8ea4d8bc3674572ca6ed0833b639bf57c43c708616d937377324cfea"
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-darwin-amd64"
[tools.k3d."platforms.windows-x64"]
checksum = "sha256:655d2aadcb1f0a0dd196c5cbc564687ba945a9547c8e82c9fc532051fb260e22"
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-windows-amd64.exe"
[[tools.kubectl]]
version = "1.35.4"
backend = "aqua:kubernetes/kubernetes/kubectl"
@@ -273,12 +289,25 @@ backend = "aqua:GoogleContainerTools/skaffold"
[tools.skaffold."platforms.linux-arm64"]
url = "https://storage.googleapis.com/skaffold/releases/v2.19.0/skaffold-linux-arm64"
[tools.skaffold."platforms.linux-arm64-musl"]
url = "https://storage.googleapis.com/skaffold/releases/v2.19.0/skaffold-linux-arm64"
[tools.skaffold."platforms.linux-x64"]
checksum = "blake3:de828fa7e9d89de048a6e89425cbdac7199d59a9fb0750b44ffe31734c074104"
url = "https://storage.googleapis.com/skaffold/releases/v2.19.0/skaffold-linux-amd64"
[tools.skaffold."platforms.linux-x64-musl"]
url = "https://storage.googleapis.com/skaffold/releases/v2.19.0/skaffold-linux-amd64"
[tools.skaffold."platforms.macos-arm64"]
url = "https://storage.googleapis.com/skaffold/releases/v2.19.0/skaffold-darwin-arm64"
[tools.skaffold."platforms.macos-x64"]
url = "https://storage.googleapis.com/skaffold/releases/v2.19.0/skaffold-darwin-amd64"
[tools.skaffold."platforms.windows-x64"]
url = "https://storage.googleapis.com/skaffold/releases/v2.19.0/skaffold-windows-amd64.exe"
[[tools.uv]]
version = "0.10.12"
backend = "aqua:astral-sh/uv"
+275
View File
@@ -0,0 +1,275 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
%global crate openshell
# Cargo/Rust builds with vendored deps do not produce debugsource listings
# in the format redhat-rpm-config expects (especially on EPEL).
%global debug_package %{nil}
Name: openshell
Version: 0.0.37
Release: 1.20260505111703438211.rpm.100.gec0e2ce3%{?dist}
Summary: Safe, sandboxed runtimes for autonomous AI agents
License: Apache-2.0
URL: https://github.com/NVIDIA/OpenShell
Source0: openshell-0.0.37.tar.gz
Source1: openshell-0.0.37-vendor.tar.xz
ExclusiveArch: x86_64 aarch64
# Rust build dependencies
# NOTE: MSRV is 1.88 (Rust edition 2024). As of mid-2025, this requires
# Fedora Rawhide or newer. Stable Fedora and EPEL-10 may ship older Rust;
# adjust targets in .packit.yaml accordingly or provide a supplementary
# Rust toolchain via additional_repos in the COPR build config.
BuildRequires: rust >= 1.88
BuildRequires: cargo
BuildRequires: cargo-rpm-macros >= 25
BuildRequires: gcc
BuildRequires: gcc-c++
BuildRequires: make
BuildRequires: cmake
BuildRequires: pkg-config
BuildRequires: clang-devel
BuildRequires: z3-devel
BuildRequires: systemd-rpm-macros
# Man page generation
BuildRequires: pandoc
# Python sub-package build dependencies
BuildRequires: python3-devel
# Runtime: container runtime for gateway lifecycle (start/stop/destroy).
# Podman is preferred; Docker is also supported via --container-runtime flag.
Recommends: podman
%description
OpenShell provides safe, sandboxed runtimes for autonomous AI agents.
It offers a CLI for managing gateways, sandboxes, and providers with
policy-enforced egress routing, credential proxying, and privacy-aware
LLM inference routing.
# --- Gateway sub-package ---
%package gateway
Summary: OpenShell gateway server with Podman sandbox driver
Requires: podman
Requires: openssl
Requires: %{name} = %{version}-%{release}
%description gateway
OpenShell gateway server providing the control-plane API for sandbox
lifecycle management. This package configures the gateway to use the
Podman compute driver, pulling sandbox and supervisor images from
ghcr.io/nvidia/openshell.
# --- Python SDK sub-package ---
%package -n python3-%{name}
Summary: OpenShell Python SDK for agent execution and management
# Use Recommends instead of Requires because Fedora 43+ ships older
# versions of grpcio (1.48) and protobuf (3.19) than the SDK needs.
# Users on distros with older packages can install these via pip/uv.
Recommends: python3-cloudpickle >= 3.0
Recommends: python3-grpcio >= 1.60
Recommends: python3-protobuf >= 4.25
Recommends: %{name}
%description -n python3-%{name}
Python SDK for OpenShell providing programmatic access to sandbox
management, agent execution, and inference routing via gRPC.
%prep
%autosetup -n %{name}-%{version}
# Extract vendored Cargo dependencies and configure offline build
tar xf %{SOURCE1}
%cargo_prep -v vendor
# Patch workspace version from placeholder to actual version
sed -i 's/^version = "0.0.0"/version = "%{version}"/' Cargo.toml
grep -q 'version = "%{version}"' Cargo.toml || (echo "ERROR: Cargo.toml version patch failed" && exit 1)
%build
# Build the CLI and gateway binaries
export CARGO_BUILD_JOBS=%{_smp_build_ncpus}
# Set the default container image tag so compiled-in image refs point at
# real tags in the ghcr.io/nvidia/openshell registry.
export OPENSHELL_IMAGE_TAG=latest
cargo build --release --bin openshell --bin openshell-gateway
# Generate vendored crate manifest and license metadata.
# cargo-vendor.txt is consumed by an RPM generator (from cargo-rpm-macros)
# to emit Provides: bundled(crate(...)) = version for every vendored dep.
%cargo_vendor_manifest
%{cargo_license_summary}
%{cargo_license} > LICENSE.dependencies
# Build man pages from markdown
pandoc -s -t man deploy/man/openshell.1.md -o openshell.1
pandoc -s -t man deploy/man/openshell-gateway.8.md -o openshell-gateway.8
pandoc -s -t man deploy/man/openshell-gateway.env.5.md -o openshell-gateway.env.5
%install
# --- CLI binary ---
install -Dpm 0755 target/release/%{name} %{buildroot}%{_bindir}/%{name}
# --- Gateway binary ---
install -Dpm 0755 target/release/%{name}-gateway %{buildroot}%{_bindir}/%{name}-gateway
# --- Gateway systemd user unit (rootless Podman) ---
# Installed to the systemd user unit directory so any user can run:
# systemctl --user enable --now openshell-gateway.service
# Podman socket activation provides the container API.
install -d %{buildroot}%{_userunitdir}
cat > %{buildroot}%{_userunitdir}/%{name}-gateway.service << 'EOF'
[Unit]
Description=OpenShell Gateway (user)
Documentation=https://github.com/NVIDIA/OpenShell
After=podman.socket
Requires=podman.socket
[Service]
Type=exec
# Self-contained defaults for rootless operation with mTLS.
#
# PKI and gateway.env are auto-generated on first start. Client certs
# are placed in ~/.config/openshell/gateways/openshell/mtls/ so the
# CLI discovers them automatically.
# See /usr/share/doc/openshell-gateway/ for details.
# Auto-generate PKI on first start if not present.
# %%S expands to $XDG_STATE_HOME (~/.local/state) in user units.
ExecStartPre=%{_libexecdir}/openshell/init-pki.sh %%S/openshell/tls
# Auto-generate gateway.env (SSH handshake secret + commented config
# reference) on first start if not present.
# %%E expands to $XDG_CONFIG_HOME (~/.config) in user units.
ExecStartPre=%{_libexecdir}/openshell/init-gateway-env.sh %%E/openshell/gateway.env
EnvironmentFile=-%%E/openshell/gateway.env
Environment=OPENSHELL_BIND_ADDRESS=0.0.0.0
Environment=OPENSHELL_DRIVERS=podman
Environment=OPENSHELL_DB_URL=sqlite://%%S/openshell/gateway.db
Environment=OPENSHELL_SUPERVISOR_IMAGE=ghcr.io/nvidia/openshell/supervisor:latest
Environment=OPENSHELL_SANDBOX_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest
# mTLS: auto-generated certs in the state directory.
Environment=OPENSHELL_TLS_CERT=%%S/openshell/tls/server/tls.crt
Environment=OPENSHELL_TLS_KEY=%%S/openshell/tls/server/tls.key
Environment=OPENSHELL_TLS_CLIENT_CA=%%S/openshell/tls/ca.crt
# Podman driver: client certs bind-mounted into sandbox containers.
Environment=OPENSHELL_PODMAN_TLS_CA=%%S/openshell/tls/ca.crt
Environment=OPENSHELL_PODMAN_TLS_CERT=%%S/openshell/tls/client/tls.crt
Environment=OPENSHELL_PODMAN_TLS_KEY=%%S/openshell/tls/client/tls.key
ExecStart=/usr/bin/openshell-gateway
StateDirectory=openshell
Restart=on-failure
RestartSec=5
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
PrivateTmp=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=default.target
EOF
# --- PKI bootstrap script and gateway env generator ---
install -d %{buildroot}%{_libexecdir}/%{name}
install -pm 0755 deploy/rpm/init-pki.sh %{buildroot}%{_libexecdir}/%{name}/init-pki.sh
install -pm 0755 deploy/rpm/init-gateway-env.sh %{buildroot}%{_libexecdir}/%{name}/init-gateway-env.sh
# --- Gateway documentation ---
install -d %{buildroot}%{_docdir}/%{name}-gateway
install -pm 0644 deploy/rpm/QUICKSTART.md %{buildroot}%{_docdir}/%{name}-gateway/QUICKSTART.md
install -pm 0644 deploy/rpm/CONFIGURATION.md %{buildroot}%{_docdir}/%{name}-gateway/CONFIGURATION.md
install -pm 0644 deploy/rpm/TROUBLESHOOTING.md %{buildroot}%{_docdir}/%{name}-gateway/TROUBLESHOOTING.md
# --- Man pages ---
install -Dpm 0644 openshell.1 %{buildroot}%{_mandir}/man1/openshell.1
install -Dpm 0644 openshell-gateway.8 %{buildroot}%{_mandir}/man8/openshell-gateway.8
install -Dpm 0644 openshell-gateway.env.5 %{buildroot}%{_mandir}/man5/openshell-gateway.env.5
# --- Python SDK ---
# Install Python SDK modules (test files are intentionally excluded)
install -d %{buildroot}%{python3_sitelib}/%{name}
install -d %{buildroot}%{python3_sitelib}/%{name}/_proto
install -pm 0644 python/%{name}/__init__.py %{buildroot}%{python3_sitelib}/%{name}/
install -pm 0644 python/%{name}/sandbox.py %{buildroot}%{python3_sitelib}/%{name}/
install -pm 0644 python/%{name}/_proto/__init__.py %{buildroot}%{python3_sitelib}/%{name}/_proto/
install -pm 0644 python/%{name}/_proto/*.py %{buildroot}%{python3_sitelib}/%{name}/_proto/
# Create dist-info so importlib.metadata can resolve the package version
install -d %{buildroot}%{python3_sitelib}/%{name}-%{version}.dist-info
cat > %{buildroot}%{python3_sitelib}/%{name}-%{version}.dist-info/METADATA << EOF
Metadata-Version: 2.1
Name: %{name}
Version: 0.0.37
Summary: OpenShell Python SDK for agent execution and management
License: Apache-2.0
Requires-Python: >=3.12
Requires-Dist: cloudpickle>=3.0
Requires-Dist: grpcio>=1.60
Requires-Dist: protobuf>=4.25
EOF
# INSTALLER marker per PEP 376
echo "rpm" > %{buildroot}%{python3_sitelib}/%{name}-%{version}.dist-info/INSTALLER
# RECORD can be empty for RPM-managed installs
touch %{buildroot}%{python3_sitelib}/%{name}-%{version}.dist-info/RECORD
%check
# Smoke-test the CLI binary
%{buildroot}%{_bindir}/%{name} --version
# Smoke-test the gateway binary
%{buildroot}%{_bindir}/%{name}-gateway --version
# Smoke-test the Python SDK version metadata via importlib.metadata.
# We query the dist-info directly rather than importing the package because
# the full import pulls in grpcio and other runtime deps not present in the
# build environment.
PYTHONPATH=%{buildroot}%{python3_sitelib} %{python3} -c "from importlib.metadata import version; v = version('openshell'); print(v); assert v == '%{version}', f'expected %{version}, got {v}'"
%post gateway
%systemd_user_post %{name}-gateway.service
%preun gateway
%systemd_user_preun %{name}-gateway.service
%postun gateway
%systemd_user_postun_with_restart %{name}-gateway.service
%files
%license LICENSE
%license LICENSE.dependencies
%license cargo-vendor.txt
%doc README.md
%{_bindir}/%{name}
%{_mandir}/man1/openshell.1*
%files gateway
%license LICENSE
%license LICENSE.dependencies
%license cargo-vendor.txt
%doc %{_docdir}/%{name}-gateway/QUICKSTART.md
%doc %{_docdir}/%{name}-gateway/CONFIGURATION.md
%doc %{_docdir}/%{name}-gateway/TROUBLESHOOTING.md
%{_bindir}/%{name}-gateway
%{_userunitdir}/%{name}-gateway.service
%{_libexecdir}/%{name}/init-pki.sh
%{_libexecdir}/%{name}/init-gateway-env.sh
%{_mandir}/man8/openshell-gateway.8*
%{_mandir}/man5/openshell-gateway.env.5*
%files -n python3-%{name}
%license LICENSE
%{python3_sitelib}/%{name}/
%{python3_sitelib}/%{name}-%{version}.dist-info/
%changelog
%autochangelog
+1 -12
View File
@@ -9,7 +9,6 @@ depends = [
"build:docker:gateway",
"build:docker:cluster",
"build:docker:supervisor",
"build:docker:supervisor-sideload",
]
hide = true
@@ -29,15 +28,10 @@ run = "tasks/scripts/docker-build-image.sh gateway"
hide = true
["build:docker:supervisor"]
description = "Build the standalone supervisor Docker image (Ubuntu-based, for K8s pods)"
description = "Build the supervisor image (FROM scratch, binary only)"
run = "tasks/scripts/docker-build-image.sh supervisor"
hide = true
["build:docker:supervisor-sideload"]
description = "Build the supervisor sideload image (FROM scratch, for Podman image-volume mount)"
run = "tasks/scripts/docker-build-image.sh supervisor-output"
hide = true
["build:docker:cluster"]
description = "Build the k3s cluster image (component images pulled at runtime from registry)"
run = "tasks/scripts/docker-build-image.sh cluster"
@@ -53,11 +47,6 @@ description = "Alias for build:docker:supervisor"
depends = ["build:docker:supervisor"]
hide = true
["docker:build:supervisor-sideload"]
description = "Alias for build:docker:supervisor-sideload"
depends = ["build:docker:supervisor-sideload"]
hide = true
["docker:build:cluster"]
description = "Alias for build:docker:cluster"
depends = ["build:docker:cluster"]
+7 -3
View File
@@ -43,7 +43,7 @@ required_prebuilt_binaries() {
gateway)
echo "openshell-gateway"
;;
supervisor|cluster|supervisor-output)
supervisor|cluster|supervisor-sideload|supervisor-output)
echo "openshell-sandbox"
;;
esac
@@ -90,7 +90,7 @@ ensure_prebuilt_binaries() {
fi
}
TARGET=${1:?"Usage: docker-build-image.sh <gateway|supervisor|cluster|supervisor-output> [extra-args...]"}
TARGET=${1:?"Usage: docker-build-image.sh <gateway|supervisor|cluster|supervisor-builder|supervisor-output> [extra-args...]"}
shift
DOCKERFILE="deploy/docker/Dockerfile.images"
@@ -118,10 +118,14 @@ case "${TARGET}" in
IMAGE_NAME="openshell/cluster"
DOCKER_TARGET="cluster"
;;
supervisor-builder)
DOCKER_TARGET="supervisor-builder"
;;
supervisor-output)
# Backward-compat alias: same as "supervisor".
IS_FINAL_IMAGE=1
IMAGE_NAME="openshell/supervisor"
DOCKER_TARGET="supervisor-output"
DOCKER_TARGET="supervisor"
;;
*)
echo "Error: unsupported target '${TARGET}'" >&2
+1 -1
View File
@@ -137,7 +137,7 @@ if [[ "${HOST_OS}" == "Linux" && "${HOST_ARCH}" == "${DAEMON_ARCH}" ]]; then
cp "${ROOT}/target/${SUPERVISOR_TARGET}/debug/openshell-sandbox" "${SUPERVISOR_BIN}"
else
# Cross-compile through the prebuilt-binary staging helper, then use the
# supervisor-output stage to extract just the openshell-sandbox binary.
# supervisor stage to extract just the openshell-sandbox binary.
#
# This task is gated on a working Docker daemon above, so pin the
# container-engine helper to docker — otherwise it auto-detects podman
+1 -1
View File
@@ -48,7 +48,7 @@ run = "uv run pytest -o python_files='test_*.py' -m gpu -n ${E2E_PARALLEL:-1} e2
["e2e:podman"]
description = "Start a Podman-backed gateway and run smoke e2e (requires rootless Podman; pass -- --port=N to override)"
depends = ["build:docker:supervisor-sideload"]
depends = ["build:docker:supervisor"]
run = "e2e/rust/e2e-podman.sh"
["e2e:vm"]