fix(snap): require mTLS for the snap gateway (#3726)

* fix(snap): require mTLS for the snap gateway

Replace the installer opt-in with an authenticated snap gateway. The wrapper
no longer forces plaintext, so the gateway serves TLS from the bundle it
already generates in $SNAP_COMMON/tls. The install hook writes a config that
enables mTLS user auth instead of unauthenticated access, and a new
post-refresh hook migrates the exact legacy default on existing installs.

install.sh waits for the gateway, detects whether it serves TLS, copies the
client bundle into the target user's snap state directory, and registers the
gateway over HTTPS. Older plaintext snap revisions still register over HTTP
with a warning. The release canary asserts mTLS auth and HTTPS registration.

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(snap): pass config preflight and detect the mTLS gateway reliably

An explicit [openshell.gateway.mtls_auth] table fails config preflight,
which validates mTLS auth before the local TLS bundle supplies the client
CA. Write a default that pins the Docker driver instead; with the wrapper's
TLS bundle the gateway requires client certificates and enables mTLS user
auth automatically, as the native packages do.

The mTLS gateway rejects TLS handshakes without a client certificate, and
it still answers plaintext loopback HTTP for sandbox service routing, so the
installer could misdetect it as a legacy plaintext gateway. Probe HTTPS with
the root-owned client bundle, and treat a gateway as legacy only when a
plaintext gRPC Health call succeeds.

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* chore(snap): simplify install hook comment

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(snap): migrate insecure gateway configs on refresh

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* refactor(snap): simplify mTLS detection and config migration

Detect the mTLS snap from the installed revision's post-refresh hook instead
of probing plaintext gRPC, and drop the scheme global. Remove the installer's
pre-hook config fallback, which is dead now that every channel ships the
install hook and which wrote the insecure default. Give the install hook a
single write path with a simple backup name, and shorten the manual client
certificate steps.

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(snap): stop keeping a copy of replaced insecure configs

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* feat(install): make the snap an opt-in install method

Stop selecting the OpenShell snap just because the snap command exists. Linux
installs default to the Debian or RPM package; OPENSHELL_INSTALL_METHOD=snap
(or deb, rpm) selects the package explicitly. Hosts that already have the
OpenShell snap keep refreshing it rather than gaining a second gateway on the
same port. The release canary and snap repro script opt in explicitly.

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(snap): let the gateway auto-detect its compute driver

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(snap): restart the gateway after refresh

Published revisions use refresh-mode: endure, and snapd honors the old
revision's setting during a refresh, so the plaintext gateway kept running
with the migrated config unused until a manual restart. Restart the gateway
from the post-refresh hook so the mTLS config takes effect immediately.

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* docs(snap): drop refresh notes from the snap description

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* docs(snap): trim snap refresh notes from installation docs

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

---------

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
Drew Newberry
2026-09-26 01:50:02 +00:00
committed by GitHub
parent f155899527
commit a67567e583
15 changed files with 405 additions and 190 deletions
+7
View File
@@ -195,6 +195,8 @@ jobs:
name: Ubuntu Snap with system Docker
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
env:
OPENSHELL_INSTALL_METHOD: snap
timeout-minutes: 20
steps:
- name: Install snapd
@@ -232,6 +234,9 @@ jobs:
sudo snap connections openshell | grep -E '^docker +openshell:docker +:docker +'
openshell --version
sudo snap services openshell
sudo journalctl -b -u snap.openshell.gateway.service --no-pager |
grep -F "mTLS user authentication enabled"
openshell gateway list | grep -F "https://127.0.0.1:17670"
openshell status
- name: Create and exercise a sandbox
@@ -262,6 +267,8 @@ jobs:
name: Ubuntu Snap Docker preflight
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
env:
OPENSHELL_INSTALL_METHOD: snap
timeout-minutes: 20
steps:
- name: Install snapd
+19 -13
View File
@@ -324,24 +324,30 @@ for direct executable installation on every environment. Release Dev and
Release Tag run Ubuntu conformance through the Debian package, while Fedora
continues using direct executable installation until RPM coverage is available.
The release canary separately exercises the public installer on Ubuntu. The
OpenShell Snap requires a compatible, preinstalled non-Snap Docker daemon. Its
positive canary uses system Docker; negative preflight coverage verifies that
the installer rejects both missing Docker and the Docker Snap before installing
OpenShell. Its Debian lane removes snapd before running the installer so Snap
precedence cannot change the package under test.
Explicit release tags and the `pre` alias bypass Snap selection and use the
native Debian or RPM package path even when `snap` is available. The `pre` alias
installer selects the OpenShell Snap only with `OPENSHELL_INSTALL_METHOD=snap`
or when the Snap is already installed; otherwise it uses the native Debian or
RPM package. The Snap requires a compatible, preinstalled non-Snap Docker
daemon. Its positive canary uses system Docker; negative preflight coverage
verifies that the installer rejects both missing Docker and the Docker Snap
before installing OpenShell.
Explicit release tags and the `pre` alias always use the native Debian or RPM
package path. The `pre` alias
checks matching Git tags in version order, then looks up the exact platform
artifact and verifies the release run instead of listing every repository
artifact.
Snapd runs the gateway as a root-owned system service. Its generated client
certificates reside in root-owned snap state and are unavailable to ordinary CLI
users, so the Snap uses plaintext loopback transport and enables unauthenticated
local users by default. Debian and RPM packages instead run systemd user services
and use user-owned mTLS material. Bootstrap creates the default configuration
only when it is missing. Sandbox-to-gateway sessions remain authenticated with
gateway-minted JWTs.
certificates reside in root-owned snap state. The installer copies the client
bundle into the target user's private Snap state and registers the TLS endpoint;
direct Snap installs require the same enrollment. The install and post-refresh
hooks replace configs that explicitly enable plaintext or unauthenticated access
with the secure default. Snap refreshes
restart the gateway so the migrated config takes effect immediately.
Debian and RPM packages instead run systemd user services with user-owned mTLS
material. Sandbox-to-gateway sessions remain authenticated with gateway-minted
JWTs.
The Debian qualification profile keeps candidate-image overrides outside the
operator-owned gateway configuration: it writes a harness-owned file under
`/var/lib/openshell-qualification` and selects it through the packaged systemd
+13 -11
View File
@@ -38,7 +38,7 @@ curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh |
OPENSHELL_VERSION=pre sh
```
The installer checks prerelease tags from newest to oldest, selects an unexpired artifact from a successful release run for the current platform, and downloads only that artifact. Installed packages keep the candidate's exact version, such as `0.1.0-pre.3`. Prerelease tags do not create entries on the GitHub Releases page. On Linux, prereleases and explicit release tags use Debian or RPM packages even if `snap` is installed.
The installer checks prerelease tags from newest to oldest, selects an unexpired artifact from a successful release run for the current platform, and downloads only that artifact. Installed packages keep the candidate's exact version, such as `0.1.0-pre.3`. Prerelease tags do not create entries on the GitHub Releases page. On Linux, prereleases and explicit release tags use Debian or RPM packages.
The rolling [`dev` release](https://github.com/NVIDIA/OpenShell/releases/tag/dev) does not require GitHub authentication:
@@ -86,7 +86,7 @@ The gateway reads `~/.config/openshell/gateway.toml` if it exists, otherwise the
## Linux
The script uses the [Snap](#snap) package when `snap` is available. Otherwise, or when you set `OPENSHELL_VERSION` to a release tag, it installs a Debian package on Debian and Ubuntu or an RPM package on Fedora and RHEL. Linux packages require glibc 2.28 or newer.
The script installs a Debian package on Debian and Ubuntu or an RPM package on Fedora and RHEL. Set `OPENSHELL_INSTALL_METHOD=snap` to install the [Snap](#snap) package instead; hosts that already have the OpenShell snap keep refreshing it. Linux packages require glibc 2.28 or newer.
The gateway runs as a systemd user service at `https://127.0.0.1:17670` and reads `~/.config/openshell/gateway.toml`.
@@ -110,20 +110,22 @@ The snap requires Docker Engine installed from your distribution or Docker's pac
sudo snap install openshell
```
The snap does not migrate existing Debian, RPM, or Homebrew installs. Remove any existing installation first, then rerun the script with `OPENSHELL_ACK_BREAKING_UPGRADE=1`.
The snap does not migrate existing Debian, RPM, or Homebrew installs. Remove any existing installation first, then rerun the script with `OPENSHELL_INSTALL_METHOD=snap OPENSHELL_ACK_BREAKING_UPGRADE=1`.
The gateway runs as a system service at `http://127.0.0.1:17670` and reads `/var/snap/openshell/common/gateway.toml`.
<Warning>
The snap gateway allows unauthenticated access from the local host. Any local user or process can operate it. Do not expose it beyond the local host.
</Warning>
Snap refreshes do not restart the gateway, so active sandboxes keep running. Restart it to pick up a new version:
The gateway runs as a system service at `https://127.0.0.1:17670` and reads `/var/snap/openshell/common/gateway.toml`. It requires a client certificate. The install script copies that certificate to the installing user's Snap state and registers the gateway automatically. If you installed with `sudo snap install openshell`, give each trusted user the certificate and register the gateway from that user's account:
```shell
sudo systemctl restart snap.openshell.gateway
d=~/snap/openshell/common/.local/state/openshell/tls
mkdir -p -m 700 "$d" "$d/client"
sudo install -o "$USER" -m 600 /var/snap/openshell/common/tls/ca.crt "$d/"
sudo install -o "$USER" -m 600 -t "$d/client" \
/var/snap/openshell/common/tls/client/tls.crt /var/snap/openshell/common/tls/client/tls.key
openshell gateway add https://127.0.0.1:17670 --local --name openshell
openshell status
```
Keep the client key private.
To install a locally built snap, connect its interfaces manually:
```shell
+69 -52
View File
@@ -61,21 +61,22 @@ ENVIRONMENT VARIABLES:
OPENSHELL_ACK_BREAKING_UPGRADE
Set to 1 only after backing up and cleaning up a
pre-v0.0.37 or non-snap installation.
OPENSHELL_INSTALL_METHOD
Linux package to install: snap, deb, or rpm. Unset
selects deb or rpm from the host package manager.
NOTES:
When OPENSHELL_VERSION is unset, this resolves the latest tagged release
from ${GITHUB_URL}/releases/latest.
On Linux, the installer uses the OpenShell snap when the snap command is
available and OPENSHELL_VERSION is unset or dev. Snap installs use
latest/stable by default and latest/edge for dev. Explicit release tags
and prereleases use Debian or RPM packages. The OpenShell snap requires a
running Docker Engine installed from a system package or Docker's package
Linux installs the Debian package on amd64/arm64 or the RPM packages on
x86_64/aarch64, depending on the host package manager. Set
OPENSHELL_INSTALL_METHOD=snap to install the OpenShell snap instead; hosts
that already have the OpenShell snap keep refreshing it. Snap installs use
latest/stable by default and latest/edge for dev, and do not support
explicit release tags or prereleases. The OpenShell snap requires a running
Docker Engine installed from a system package or Docker's package
repository. The Docker snap is not currently compatible with OpenShell.
For explicit versions or without snap, Linux installs the Debian package
on amd64/arm64 or the RPM packages on x86_64/aarch64, depending on the
host package manager.
macOS installs the release Homebrew formula on Apple Silicon and starts a
brew services-backed local gateway.
EOF
@@ -658,9 +659,20 @@ local_gateway_endpoint() {
}
linux_package_method() {
case "${OPENSHELL_INSTALL_METHOD:-}" in
snap | deb | rpm)
echo "$OPENSHELL_INSTALL_METHOD"
return 0
;;
'') ;;
*) error "unsupported OPENSHELL_INSTALL_METHOD=${OPENSHELL_INSTALL_METHOD}; use snap, deb, or rpm" ;;
esac
# Keep refreshing an existing snap install instead of adding a second
# gateway on the same port.
case "${OPENSHELL_VERSION:-}" in
'' | dev)
if has_cmd snap; then
if has_cmd snap && snap list openshell >/dev/null 2>&1; then
echo "snap"
return 0
fi
@@ -1220,43 +1232,6 @@ openshell_snap_channel() {
esac
}
ensure_snap_gateway_config() {
_config_file="${1:-/var/snap/openshell/common/gateway.toml}"
as_root sh -c '
set -eu
config_file=$1
if [ -e "$config_file" ] || [ -L "$config_file" ]; then
exit 0
fi
config_dir=${config_file%/*}
mkdir -p "$config_dir"
umask 077
temporary_file=$(mktemp "${config_file}.tmp.XXXXXX")
trap '\''rm -f "$temporary_file"'\'' 0 HUP INT TERM
cat >"$temporary_file" <<'\''EOF'\''
[openshell]
version = 2
[openshell.gateway]
[openshell.gateway.auth]
allow_unauthenticated_users = true
EOF
if ! ln "$temporary_file" "$config_file"; then
if [ -e "$config_file" ] || [ -L "$config_file" ]; then
exit 0
fi
exit 1
fi
rm -f "$temporary_file"
trap - 0 HUP INT TERM
' sh "$_config_file"
}
wait_for_docker_daemon() {
_timeout="${OPENSHELL_INSTALL_DOCKER_TIMEOUT:-30}"
_elapsed=0
@@ -1280,9 +1255,39 @@ wait_for_docker_daemon() {
error "Docker daemon did not become reachable within ${_timeout}s"
}
# Copy the snap gateway's client bundle into the target user's snap state
# directory, where `openshell gateway add --local` imports it. Root only reads
# the source files; the target user writes the copies into their own home.
copy_snap_client_bundle() {
_src="${OPENSHELL_SNAP_TLS_DIR:-/var/snap/openshell/common/tls}"
_dst="${TARGET_HOME}/snap/openshell/common/.local/state/openshell/tls"
as_target_user mkdir -p "${_dst}/client"
as_target_user chmod 700 "$_dst" "${_dst}/client"
for _file in ca.crt client/tls.crt client/tls.key; do
as_root cat "${_src}/${_file}" |
as_target_user sh -c 'umask 077; cat >"$1"' sh "${_dst}/${_file}"
as_target_user chmod 600 "${_dst}/${_file}"
done
}
# Snap revisions that require mTLS ship the post-refresh hook that migrates
# older plaintext configs.
snap_gateway_uses_mtls() {
[ -e "${OPENSHELL_SNAP_DIR:-/snap/openshell/current}/meta/hooks/post-refresh" ]
}
register_snap_gateway() {
_register_bin="${OPENSHELL_REGISTER_BIN:-/snap/bin/openshell}"
_endpoint="http://127.0.0.1:${LOCAL_GATEWAY_PORT}"
if snap_gateway_uses_mtls; then
_endpoint="https://127.0.0.1:${LOCAL_GATEWAY_PORT}"
info "copying the gateway client certificate for ${TARGET_USER}..."
copy_snap_client_bundle
else
_endpoint="http://127.0.0.1:${LOCAL_GATEWAY_PORT}"
warn "this OpenShell snap revision serves plaintext HTTP without client authentication; any local user can operate the gateway"
fi
if _add_output="$(as_target_user "$_register_bin" gateway add "$_endpoint" --local --name openshell 2>&1)"; then
[ -z "$_add_output" ] || print_gateway_add_output "$_add_output"
@@ -1304,15 +1309,28 @@ register_snap_gateway() {
esac
}
# The mTLS gateway rejects TLS handshakes without a client certificate, so
# probe it with the root-owned client bundle.
wait_for_snap_gateway_listener() {
_timeout="${OPENSHELL_INSTALL_GATEWAY_TIMEOUT:-30}"
_elapsed=0
_last_output=""
_probe_url="http://127.0.0.1:${LOCAL_GATEWAY_PORT}/"
_tls_dir="${OPENSHELL_SNAP_TLS_DIR:-/var/snap/openshell/common/tls}"
if snap_gateway_uses_mtls; then
_probe_url="https://127.0.0.1:${LOCAL_GATEWAY_PORT}/"
_probe_as=as_root
set -- --cacert "${_tls_dir}/ca.crt" \
--cert "${_tls_dir}/client/tls.crt" --key "${_tls_dir}/client/tls.key"
else
_probe_url="http://127.0.0.1:${LOCAL_GATEWAY_PORT}/"
_probe_as=""
set --
fi
info "waiting for local gateway listener to become reachable..."
while [ "$_elapsed" -lt "$_timeout" ]; do
if _last_output="$(curl -sS --max-time 2 -o /dev/null "$_probe_url" 2>&1)"; then
if _last_output="$($_probe_as curl -sS --max-time 2 "$@" -o /dev/null "$_probe_url" 2>&1)"; then
info "local gateway listener is reachable"
return 0
fi
@@ -1350,13 +1368,12 @@ Install Docker Engine from a system package or Docker's package repository, then
as_root snap install openshell --channel="$_channel"
fi
ensure_snap_gateway_config
as_root snap restart openshell.gateway
info "installed OpenShell snap from ${_channel}"
wait_for_snap_gateway_listener
info "registering local gateway as ${TARGET_USER}..."
register_snap_gateway
wait_for_snap_gateway_listener
OPENSHELL_REGISTER_BIN="/snap/bin/openshell"
wait_for_local_gateway_status
}
+2 -2
View File
@@ -99,7 +99,7 @@ for attempt in $(seq 1 "${attempts}"); do
echo "==> install.sh Snap ${mode} reproduction attempt ${attempt}/${attempts}"
if [ "${mode}" != system-docker ]; then
output=$(mktemp)
if OPENSHELL_VERSION=dev sh "${install_script}" >"${output}" 2>&1; then
if OPENSHELL_INSTALL_METHOD=snap OPENSHELL_VERSION=dev sh "${install_script}" >"${output}" 2>&1; then
echo "install.sh unexpectedly succeeded in ${mode} mode" >&2
cat "${output}" >&2
rm -f "${output}"
@@ -127,7 +127,7 @@ for attempt in $(seq 1 "${attempts}"); do
fi
sandbox="snap-${attempt}-$$"
if ! OPENSHELL_VERSION=dev sh "${install_script}" ||
if ! OPENSHELL_INSTALL_METHOD=snap OPENSHELL_VERSION=dev sh "${install_script}" ||
! sudo snap list openshell >/dev/null ||
! snap info openshell | grep -Eq '^tracking: +latest/edge$' ||
! docker_is_ready ||
+5 -1
View File
@@ -155,7 +155,11 @@ def test_snap_wrapper_uses_optional_gateway_config_without_generating_toml() ->
'export OPENSHELL_DB_URL="${OPENSHELL_DB_URL:-sqlite:${SNAP_COMMON}/gateway.db?mode=rwc}"'
in wrapper
)
assert 'export OPENSHELL_DISABLE_TLS="${OPENSHELL_DISABLE_TLS:-true}"' in wrapper
assert "OPENSHELL_DISABLE_TLS" not in wrapper
assert (
'export OPENSHELL_LOCAL_TLS_DIR="${OPENSHELL_LOCAL_TLS_DIR:-${SNAP_COMMON}/tls}"'
in wrapper
)
assert (
'exec "${SNAP}/bin/openshell-gateway" --config "$CANONICAL_CONFIG_FILE" "$@"'
in wrapper
+1
View File
@@ -72,6 +72,7 @@ Common findings:
- `No active gateway`: register one with `openshell gateway add <endpoint>`.
- Connection refused: gateway process is not running, service exposure is wrong, or a port-forward/proxy is not active.
- TLS/certificate errors: the endpoint scheme or trust chain is wrong, a local mTLS bundle does not match the gateway CA, or TLS termination does not match the gateway listener.
- A Snap refresh restarts the gateway with its migrated mTLS config. The secure Snap gateway uses `https://127.0.0.1:17670` and requires a client bundle in the user's Snap state. Refresh replaces insecure configs without keeping a copy; follow the published Snap installation steps to re-register an old HTTP client.
- `Unauthenticated` from an edge or OIDC gateway: refresh stored credentials with `openshell gateway login [name]`, then retry. Use `gateway logout` only when intentionally clearing local credentials.
- A direct development endpoint with a private or self-signed certificate can be isolated with `--gateway-endpoint <url> --gateway-insecure`; do not persist or recommend insecure verification for shared gateways.
+16 -17
View File
@@ -2,10 +2,22 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Create the mTLS default and replace insecure configs on refresh.
set -eu
config_file="${SNAP_COMMON}/gateway.toml"
if [ -e "$config_file" ] || [ -L "$config_file" ]; then
insecure='^[[:space:]]*(allow_unauthenticated_users|disable_tls)[[:space:]]*=[[:space:]]*true([[:space:]#]|$)'
# Keep secure operator configs, symlinks, and directories. Replace a config
# that explicitly allows plaintext or anonymous access, even if it has other
# edits.
if [ -L "$config_file" ]; then
exit 0
elif [ -f "$config_file" ]; then
grep -Eq "$insecure" "$config_file" || exit 0
echo "openshell: replacing insecure gateway config with the mTLS default" >&2
elif [ -e "$config_file" ]; then
exit 0
fi
@@ -13,24 +25,11 @@ mkdir -p "$SNAP_COMMON"
umask 077
temporary_file=$(mktemp "${config_file}.tmp.XXXXXX")
trap 'rm -f "$temporary_file"' 0 HUP INT TERM
cat >"$temporary_file" <<'EOF'
cat >"$temporary_file" <<'CONFIG'
[openshell]
version = 2
[openshell.gateway]
[openshell.gateway.auth]
allow_unauthenticated_users = true
EOF
# A hard link publishes the config atomically without replacing a path created
# concurrently. SNAP_COMMON and the temporary file are on the same filesystem.
if ! ln "$temporary_file" "$config_file"; then
if [ -e "$config_file" ] || [ -L "$config_file" ]; then
exit 0
fi
exit 1
fi
rm -f "$temporary_file"
CONFIG
mv -f "$temporary_file" "$config_file"
trap - 0 HUP INT TERM
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Replace insecure gateway configs on refresh, then restart the gateway so the
# new config takes effect even when the previous revision used
# refresh-mode: endure and kept its plaintext gateway running.
set -eu
"${SNAP}/meta/hooks/install"
snapctl restart "${SNAP_INSTANCE_NAME}.gateway"
+19 -20
View File
@@ -29,18 +29,18 @@ description: |
sudo systemctl reset-failed snap.openshell.gateway.service
sudo snap restart openshell.gateway
2. Verify the gateway and register it locally:
2. Give your user the gateway client certificate and register the gateway.
The gateway requires mTLS; only users holding this certificate can use
it, so copy it only for trusted users:
snap services openshell.gateway
d=~/snap/openshell/common/.local/state/openshell/tls
mkdir -p -m 700 "$d" "$d/client"
sudo install -o "$USER" -m 600 /var/snap/openshell/common/tls/ca.crt "$d/"
sudo install -o "$USER" -m 600 -t "$d/client" \
/var/snap/openshell/common/tls/client/tls.crt /var/snap/openshell/common/tls/client/tls.key
openshell gateway add https://127.0.0.1:17670 --local --name openshell
openshell status
openshell gateway add http://127.0.0.1:17670 --local --name openshell-gateway
After a snap refresh, restart the gateway to pick up the new revision:
sudo snap restart openshell.gateway
Restarting the gateway will interrupt active sandbox sessions. The gateway
is not restarted automatically to avoid disconnecting running sandboxes.
base: core24
grade: stable
@@ -88,17 +88,16 @@ apps:
gateway:
command: bin/openshell-gateway-wrapper
daemon: simple
# refresh-mode: endure prevents snapd from restarting the gateway daemon
# during snap refreshes, which would kill active sandbox sessions.
# Operators must manually restart the service after a refresh if needed.
refresh-mode: endure
# Snapd runs this daemon as root, so ordinary CLI users cannot read the
# client certificate generated in root-owned snap state. The wrapper sets
# OPENSHELL_DISABLE_TLS=true for the loopback-only listener and uses
# $SNAP_COMMON/gateway.db. Before startup it bootstraps package-managed
# credentials and validates the selected operator-provided config without
# creating or rewriting it. A nonempty OPENSHELL_GATEWAY_CONFIG takes
# precedence over gateway.toml.
# Refresh must activate the migrated mTLS config immediately. This
# interrupts active sandbox sessions.
refresh-mode: restart
# Snapd runs this daemon as root. The wrapper serves TLS from the bundle
# generated in $SNAP_COMMON/tls, and the default config requires client
# certificates; the installer copies the client bundle to the target
# user. The wrapper uses $SNAP_COMMON/gateway.db. Before startup it
# bootstraps package-managed credentials and validates the selected
# operator-provided config without creating or rewriting it. A nonempty
# OPENSHELL_GATEWAY_CONFIG takes precedence over gateway.toml.
environment:
XDG_DATA_HOME: "$SNAP_COMMON"
XDG_RUNTIME_DIR: "$SNAP_COMMON"
+8 -8
View File
@@ -3,16 +3,16 @@
# SPDX-License-Identifier: Apache-2.0
# Snap wrapper for openshell-gateway. Sets snap-specific defaults:
# - OPENSHELL_DB_URL -> sqlite:$SNAP_COMMON/gateway.db (overridable)
# - OPENSHELL_DISABLE_TLS -> true
# It bootstraps package-managed credentials and validates, but never creates or
# rewrites, an operator-provided config before starting the gateway.
# - OPENSHELL_DB_URL -> sqlite:$SNAP_COMMON/gateway.db (overridable)
# - OPENSHELL_LOCAL_TLS_DIR -> $SNAP_COMMON/tls (overridable)
# The gateway serves TLS from the generated bundle and requires client
# certificates. It bootstraps package-managed credentials and validates, but
# never creates or rewrites, an operator-provided config before starting.
set -eu
CANONICAL_CONFIG_FILE="${SNAP_COMMON}/gateway.toml"
export OPENSHELL_DB_URL="${OPENSHELL_DB_URL:-sqlite:${SNAP_COMMON}/gateway.db?mode=rwc}"
export OPENSHELL_DISABLE_TLS="${OPENSHELL_DISABLE_TLS:-true}"
export OPENSHELL_LOCAL_TLS_DIR="${OPENSHELL_LOCAL_TLS_DIR:-${SNAP_COMMON}/tls}"
# Mirror clap's CLI-over-environment precedence so preflight always inspects
@@ -64,9 +64,9 @@ if [ "$expect_config_path" = true ] || { [ "$config_seen" = true ] && [ -z "$cli
exit 2
fi
# Docker sandboxes require gateway-minted, launch-scoped credentials for the
# supervisor. Generate the local JWT bundle alongside the otherwise-unused TLS
# material; generate-certs is idempotent and preserves an existing bundle.
# Generate the local TLS bundle and the JWT bundle used for launch-scoped
# supervisor credentials; generate-certs is idempotent and preserves an
# existing bundle.
"${SNAP}/bin/openshell-gateway" generate-certs \
--output-dir "$OPENSHELL_LOCAL_TLS_DIR" \
--server-san host.openshell.internal
+124 -51
View File
@@ -100,25 +100,30 @@ assert_glibc_preflight_fails \
"OpenShell Linux packages require glibc >= 2.28; detected musl or unsupported libc." \
setup_ldd_musl
# snap_state: 0 = no snap command, 1 = snap command only,
# installed = the OpenShell snap is already installed.
assert_linux_package_method() {
local name=$1
local requested_version=$2
local snap_present=$3
local dpkg_present=$4
local rpm_present=$5
local expected=$6
local install_method=$2
local requested_version=$3
local snap_state=$4
local dpkg_present=$5
local rpm_present=$6
local expected=$7
local actual
actual="$(
export OPENSHELL_INSTALL_METHOD="$install_method"
export OPENSHELL_VERSION="$requested_version"
has_cmd() {
case "$1" in
snap) [ "$snap_present" = "1" ] ;;
snap) [ "$snap_state" != "0" ] ;;
dpkg) [ "$dpkg_present" = "1" ] ;;
rpm) [ "$rpm_present" = "1" ] ;;
*) return 1 ;;
esac
}
snap() { [ "$*" = "list openshell" ] && [ "$snap_state" = "installed" ]; }
linux_package_method
)"
if [ "$actual" != "$expected" ]; then
@@ -127,16 +132,30 @@ assert_linux_package_method() {
fi
}
assert_linux_package_method "snap takes precedence over deb and rpm" "" 1 1 1 snap
assert_linux_package_method "dev uses snap" dev 1 1 1 snap
assert_linux_package_method "pre uses deb despite snap" pre 1 1 1 deb
assert_linux_package_method "numbered prerelease uses deb despite snap" v0.1.0-pre.3 1 1 1 deb
assert_linux_package_method "pre uses rpm despite snap" pre 1 0 1 rpm
assert_linux_package_method "pinned stable uses deb despite snap" v1.2.3 1 1 1 deb
assert_linux_package_method "pinned stable uses rpm despite snap" v1.2.3 1 0 1 rpm
assert_linux_package_method "deb is selected without snap" "" 0 1 1 deb
assert_linux_package_method "dev uses deb without snap" dev 0 1 1 deb
assert_linux_package_method "rpm is selected without snap or deb" "" 0 0 1 rpm
assert_linux_package_method "deb is the default despite snap" "" "" 1 1 1 deb
assert_linux_package_method "rpm is the default despite snap" "" "" 1 0 1 rpm
assert_linux_package_method "dev uses deb despite snap" "" dev 1 1 1 deb
assert_linux_package_method "snap is opt-in" snap "" 1 1 1 snap
assert_linux_package_method "snap opt-in with dev" snap dev 1 1 1 snap
assert_linux_package_method "explicit deb" deb "" installed 0 1 deb
assert_linux_package_method "explicit rpm" rpm "" 1 1 1 rpm
assert_linux_package_method "existing snap install keeps refreshing" "" "" installed 1 1 snap
assert_linux_package_method "existing snap install keeps refreshing dev" "" dev installed 1 1 snap
assert_linux_package_method "pre uses deb despite existing snap" "" pre installed 1 1 deb
assert_linux_package_method "numbered prerelease uses deb despite existing snap" "" v0.1.0-pre.3 installed 1 1 deb
assert_linux_package_method "pinned stable uses rpm despite existing snap" "" v1.2.3 installed 0 1 rpm
assert_linux_package_method "deb is selected without snap" "" "" 0 1 1 deb
assert_linux_package_method "rpm is selected without snap or deb" "" "" 0 0 1 rpm
if (OPENSHELL_INSTALL_METHOD=flatpak linux_package_method) >"$out" 2>"$err"; then
echo "FAIL: unsupported OPENSHELL_INSTALL_METHOD should be rejected" >&2
exit 1
fi
if ! grep -Fq "unsupported OPENSHELL_INSTALL_METHOD=flatpak" "$err"; then
echo "FAIL: unsupported OPENSHELL_INSTALL_METHOD was not explained" >&2
cat "$err" >&2
exit 1
fi
if ! (
find_existing_native_openshell_bin() { return 1; }
@@ -269,7 +288,6 @@ assert_snap_install_flow() {
as_root() { printf 'root:%s\n' "$*"; }
set_linux_target_runtime_dir() { :; }
wait_for_docker_daemon() { printf '%s\n' "wait:docker"; }
ensure_snap_gateway_config() { printf '%s\n' "ensure:gateway-config"; }
register_snap_gateway() { printf '%s\n' "register:gateway"; }
wait_for_snap_gateway_listener() { printf '%s\n' "wait:gateway-listener"; }
wait_for_local_gateway_status() { printf '%s\n' "wait:gateway-status"; }
@@ -293,10 +311,9 @@ assert_snap_install_flow \
1 0 "" \
"wait:docker
root:snap install openshell --channel=latest/stable
ensure:gateway-config
root:snap restart openshell.gateway
register:gateway
wait:gateway-listener
register:gateway
wait:gateway-status"
assert_snap_install_flow \
@@ -304,10 +321,9 @@ assert_snap_install_flow \
1 1 "" \
"wait:docker
root:snap refresh openshell --channel=latest/stable
ensure:gateway-config
root:snap restart openshell.gateway
register:gateway
wait:gateway-listener
register:gateway
wait:gateway-status"
assert_snap_install_rejected() {
@@ -363,34 +379,6 @@ assert_snap_install_rejected \
1 1 \
"the Docker snap is not currently compatible with OpenShell"
snap_config_dir="${tmpdir}/snap-config"
snap_config="${snap_config_dir}/gateway.toml"
if ! (as_root() { "$@"; }; ensure_snap_gateway_config "$snap_config"); then
echo "FAIL: Snap gateway config bootstrap should create a missing config" >&2
exit 1
fi
if ! grep -Fq 'allow_unauthenticated_users = true' "$snap_config"; then
echo "FAIL: Snap gateway config must permit the plaintext local CLI" >&2
exit 1
fi
if [[ -z $(find "$snap_config" -perm 600) ]]; then
echo "FAIL: Snap gateway config must be mode 0600" >&2
exit 1
fi
printf '\noperator setting = true\n' >>"$snap_config"
cp "$snap_config" "${tmpdir}/snap-config-before"
(as_root() { "$@"; }; ensure_snap_gateway_config "$snap_config")
cmp -s "${tmpdir}/snap-config-before" "$snap_config"
broken_config="${tmpdir}/broken-gateway.toml"
ln -s "${tmpdir}/missing-gateway.toml" "$broken_config"
(as_root() { "$@"; }; ensure_snap_gateway_config "$broken_config")
if [[ $(readlink "$broken_config") != "${tmpdir}/missing-gateway.toml" ]]; then
echo "FAIL: Snap gateway config bootstrap replaced a broken operator symlink" >&2
exit 1
fi
attempts_file="${tmpdir}/docker-attempts"
root_probes_file="${tmpdir}/docker-root-probes"
printf '0\n' >"$attempts_file"
@@ -450,7 +438,11 @@ registration_calls_file="${tmpdir}/registration-calls"
: >"$registration_calls_file"
if ! (
as_target_user() { printf 'target:%s\n' "$*" >>"$registration_calls_file"; }
copy_snap_client_bundle() { printf 'copy:client-bundle\n' >>"$registration_calls_file"; }
print_gateway_add_output() { :; }
info() { :; }
TARGET_USER=test-user
snap_gateway_uses_mtls() { return 0; }
register_snap_gateway
) >"$out" 2>"$err"; then
echo "FAIL: Snap gateway registration should succeed" >&2
@@ -458,12 +450,93 @@ if ! (
exit 1
fi
registration_calls="$(cat "$registration_calls_file")"
if [ "$registration_calls" != "target:/snap/bin/openshell gateway add http://127.0.0.1:17670 --local --name openshell" ]; then
echo "FAIL: Snap gateway registration must use the Snap CLI as the target user" >&2
if [ "$registration_calls" != "copy:client-bundle
target:/snap/bin/openshell gateway add https://127.0.0.1:17670 --local --name openshell" ]; then
echo "FAIL: mTLS Snap gateway registration must copy the client bundle and use HTTPS" >&2
printf '%s\n' "$registration_calls" >&2
exit 1
fi
: >"$registration_calls_file"
if ! (
as_target_user() { printf 'target:%s\n' "$*" >>"$registration_calls_file"; }
copy_snap_client_bundle() { printf 'copy:client-bundle\n' >>"$registration_calls_file"; }
print_gateway_add_output() { :; }
snap_gateway_uses_mtls() { return 1; }
register_snap_gateway
) >"$out" 2>"$err"; then
echo "FAIL: legacy plaintext Snap gateway registration should succeed" >&2
cat "$err" >&2 || true
exit 1
fi
registration_calls="$(cat "$registration_calls_file")"
if [ "$registration_calls" != "target:/snap/bin/openshell gateway add http://127.0.0.1:17670 --local --name openshell" ]; then
echo "FAIL: legacy Snap gateway registration must use HTTP without copying certificates" >&2
printf '%s\n' "$registration_calls" >&2
exit 1
fi
if ! grep -Fq "without client authentication" "$err"; then
echo "FAIL: legacy Snap gateway registration must warn about unauthenticated access" >&2
exit 1
fi
assert_snap_listener_probe() {
local name=$1
local uses_mtls=$2
local expected=$3
local actual
actual="$(
as_root() { printf 'root:'; "$@"; }
curl() { printf '%s\n' "$*"; }
snap_gateway_uses_mtls() { [ "$uses_mtls" = "1" ]; }
info() { :; }
OPENSHELL_SNAP_TLS_DIR=/tls
wait_for_snap_gateway_listener >/dev/null
printf '%s\n' "$_last_output"
)"
if [ "$actual" != "$expected" ]; then
echo "FAIL: ${name}: expected ${expected}, got ${actual}" >&2
exit 1
fi
}
assert_snap_listener_probe "mTLS snap probes HTTPS with the client bundle as root" 1 \
"root:-sS --max-time 2 --cacert /tls/ca.crt --cert /tls/client/tls.crt --key /tls/client/tls.key -o /dev/null https://127.0.0.1:17670/"
assert_snap_listener_probe "legacy snap probes plaintext HTTP" 0 \
"-sS --max-time 2 -o /dev/null http://127.0.0.1:17670/"
snap_tls_src="${tmpdir}/snap-tls"
mkdir -p "${snap_tls_src}/client"
printf 'ca\n' >"${snap_tls_src}/ca.crt"
printf 'cert\n' >"${snap_tls_src}/client/tls.crt"
printf 'key\n' >"${snap_tls_src}/client/tls.key"
snap_user_home="${tmpdir}/snap-user-home"
mkdir -p "${snap_user_home}/snap/openshell/common/.local/state/openshell/tls/client"
printf 'old key\n' >"${snap_user_home}/snap/openshell/common/.local/state/openshell/tls/client/tls.key"
chmod 644 "${snap_user_home}/snap/openshell/common/.local/state/openshell/tls/client/tls.key"
(
as_root() { "$@"; }
as_target_user() { "$@"; }
TARGET_HOME="$snap_user_home"
OPENSHELL_SNAP_TLS_DIR="$snap_tls_src" copy_snap_client_bundle
)
snap_user_tls="${snap_user_home}/snap/openshell/common/.local/state/openshell/tls"
for file in ca.crt client/tls.crt client/tls.key; do
if ! cmp -s "${snap_tls_src}/${file}" "${snap_user_tls}/${file}"; then
echo "FAIL: Snap client bundle copy missing ${file}" >&2
exit 1
fi
if [[ -z $(find "${snap_user_tls}/${file}" -perm 600) ]]; then
echo "FAIL: Snap client bundle ${file} must be mode 0600" >&2
exit 1
fi
done
if [[ -z $(find "$snap_user_tls" -maxdepth 0 -perm 700) ]]; then
echo "FAIL: Snap client bundle directory must be mode 0700" >&2
exit 1
fi
if [ "$(PLATFORM=darwin local_gateway_endpoint)" != "https://localhost:17670" ]; then
echo "FAIL: macOS local gateway endpoint must use a TLS-compatible loopback hostname" >&2
exit 1
+9 -2
View File
@@ -123,7 +123,14 @@ if [[ ! -x "$snap_install_hook" ]]; then
echo "FAIL: Snap install hook must be executable" >&2
exit 1
fi
assert_contains "$snap_install_hook" 'allow_unauthenticated_users = true'
assert_not_contains "$snap_install_hook" 'compute_driver'
assert_not_contains "$snap_install_hook" 'allow_unauthenticated_users = true'
assert_contains "$snapcraft" 'refresh-mode: restart'
if [[ ! -x "$(dirname "$snap_install_hook")/post-refresh" ]]; then
echo "FAIL: Snap post-refresh hook must be executable" >&2
exit 1
fi
assert_not_contains "$ROOT/tasks/scripts/snap-gateway-wrapper.sh" 'OPENSHELL_DISABLE_TLS'
bash "$ROOT/tasks/scripts/test-snap-install-hook.sh" "$snap_install_hook"
assert_not_contains "$snap_install_docs" "snap connect openshell:home"
assert_not_contains "$snap_install_docs" "snap connect openshell:network"
@@ -132,7 +139,7 @@ assert_contains "$snap_install_docs" "snap connect openshell:docker :docker"
assert_contains "$snap_canary" "install.sh | sh"
assert_contains "$snap_canary" "ubuntu-snap-system-docker:"
assert_contains "$snap_canary" "ubuntu-snap-docker-preflight:"
assert_contains "$snap_repro" 'OPENSHELL_VERSION=dev sh "${install_script}"'
assert_contains "$snap_repro" 'OPENSHELL_INSTALL_METHOD=snap OPENSHELL_VERSION=dev sh "${install_script}"'
assert_contains "$snap_repro" "system-docker"
assert_contains "$snap_repro" "missing-docker"
assert_contains "$snap_repro" "docker-snap"
+13 -13
View File
@@ -82,9 +82,9 @@ cp "$override" "$work/override-before"
: >"$log"
run_wrapper "$override"
assert_log "config preflight -- --trace
env:$override|sqlite:$common/gateway.db?mode=rwc|true
env:$override|sqlite:$common/gateway.db?mode=rwc|
--trace
env:$override|sqlite:$common/gateway.db?mode=rwc|true"
env:$override|sqlite:$common/gateway.db?mode=rwc|"
cmp -s "$work/override-before" "$override"
cli_config="$work/cli.toml"
@@ -98,9 +98,9 @@ env \
FAKE_GATEWAY_LOG="$log" \
"$wrapper" --trace --config "$cli_config"
assert_log "config preflight -- --trace --config $cli_config
env:$override|sqlite:$common/gateway.db?mode=rwc|true
env:$override|sqlite:$common/gateway.db?mode=rwc|
--trace --config $cli_config
env:$override|sqlite:$common/gateway.db?mode=rwc|true"
env:$override|sqlite:$common/gateway.db?mode=rwc|"
cmp -s "$work/cli-before" "$cli_config"
: >"$log"
@@ -115,7 +115,7 @@ if env \
exit 1
fi
assert_log "config preflight -- --config=$cli_config
env:$override|sqlite:$common/gateway.db?mode=rwc|true"
env:$override|sqlite:$common/gateway.db?mode=rwc|"
cmp -s "$work/cli-before" "$cli_config"
: >"$log"
@@ -130,7 +130,7 @@ if env \
exit 1
fi
assert_log "config preflight -- --grpc-rate-limit-requests 10
env:$override|sqlite:$common/gateway.db?mode=rwc|true"
env:$override|sqlite:$common/gateway.db?mode=rwc|"
for invalid_selector in terminator nested-config; do
: >"$log"
@@ -162,9 +162,9 @@ env \
FAKE_GATEWAY_LOG="$log" \
"$wrapper" --config=--dash-leading
assert_log "config preflight -- --config=--dash-leading
env:$override|sqlite:$common/gateway.db?mode=rwc|true
env:$override|sqlite:$common/gateway.db?mode=rwc|
--config=--dash-leading
env:$override|sqlite:$common/gateway.db?mode=rwc|true"
env:$override|sqlite:$common/gateway.db?mode=rwc|"
canonical="$common/gateway.toml"
printf 'valid schema-v2\n' >"$canonical"
@@ -172,18 +172,18 @@ cp "$canonical" "$work/canonical-before"
: >"$log"
run_wrapper unset
assert_log "config preflight -- --config $canonical --trace
env:|sqlite:$common/gateway.db?mode=rwc|true
env:|sqlite:$common/gateway.db?mode=rwc|
--config $canonical --trace
env:|sqlite:$common/gateway.db?mode=rwc|true"
env:|sqlite:$common/gateway.db?mode=rwc|"
cmp -s "$work/canonical-before" "$canonical"
rm "$canonical"
: >"$log"
run_wrapper unset
assert_log "config preflight -- --trace
env:|sqlite:$common/gateway.db?mode=rwc|true
env:|sqlite:$common/gateway.db?mode=rwc|
--trace
env:|sqlite:$common/gateway.db?mode=rwc|true"
env:|sqlite:$common/gateway.db?mode=rwc|"
assert_preflight_failure() {
local name=$1
@@ -193,7 +193,7 @@ assert_preflight_failure() {
exit 1
fi
assert_log "config preflight -- --config $canonical --trace
env:|sqlite:$common/gateway.db?mode=rwc|true"
env:|sqlite:$common/gateway.db?mode=rwc|"
}
printf 'legacy version = 1\n' >"$canonical"
+88
View File
@@ -15,6 +15,14 @@ cat >"$expected" <<'EOF'
[openshell]
version = 2
[openshell.gateway]
EOF
legacy="${work}/legacy.toml"
cat >"$legacy" <<'EOF'
[openshell]
version = 2
[openshell.gateway]
[openshell.gateway.auth]
@@ -34,6 +42,81 @@ cp "$common/gateway.toml" "${work}/operator-before"
SNAP_COMMON="$common" "$hook"
cmp -s "${work}/operator-before" "$common/gateway.toml"
common="${work}/legacy"
mkdir -p "$common"
cp "$legacy" "$common/gateway.toml"
chmod 644 "$common/gateway.toml"
SNAP_COMMON="$common" "$hook"
if ! cmp -s "$expected" "$common/gateway.toml"; then
echo "FAIL: install hook must migrate the legacy unauthenticated config" >&2
exit 1
fi
if [[ -z $(find "$common/gateway.toml" -perm 600) ]]; then
echo "FAIL: migrated config must be mode 0600" >&2
exit 1
fi
common="${work}/legacy-edited"
mkdir -p "$common"
cp "$legacy" "$common/gateway.toml"
printf '\n# operator note\n' >>"$common/gateway.toml"
cp "$common/gateway.toml" "${work}/legacy-edited-before"
SNAP_COMMON="$common" "$hook"
cmp -s "$expected" "$common/gateway.toml"
common="${work}/custom-insecure"
mkdir -p "$common"
cat >"$common/gateway.toml" <<'EOF'
[openshell]
version = 2
[openshell.gateway]
compute_driver = "docker"
disable_tls = true # old local override
[openshell.gateway.auth]
allow_unauthenticated_users = true # old local override
EOF
cp "$common/gateway.toml" "${work}/custom-insecure-before"
SNAP_COMMON="$common" "$hook"
cmp -s "$expected" "$common/gateway.toml"
common="${work}/custom-secure"
mkdir -p "$common"
cat >"$common/gateway.toml" <<'EOF'
[openshell]
version = 2
[openshell.gateway]
compute_driver = "docker"
# allow_unauthenticated_users = true
EOF
cp "$common/gateway.toml" "${work}/custom-secure-before"
SNAP_COMMON="$common" "$hook"
cmp -s "${work}/custom-secure-before" "$common/gateway.toml"
common="${work}/post-refresh"
mkdir -p "$common" "${work}/snap/meta/hooks"
cp "$hook" "${work}/snap/meta/hooks/install"
cp "${work}/legacy-edited-before" "$common/gateway.toml"
mkdir -p "${work}/bin"
cat >"${work}/bin/snapctl" <<EOF
#!/bin/sh
printf '%s\\n' "\$*" >>"${work}/snapctl.log"
EOF
chmod 755 "${work}/bin/snapctl"
PATH="${work}/bin:$PATH" SNAP="${work}/snap" SNAP_COMMON="$common" \
SNAP_INSTANCE_NAME=openshell "${hook_dir}/post-refresh"
if ! cmp -s "$expected" "$common/gateway.toml"; then
echo "FAIL: post-refresh hook must migrate an edited insecure config" >&2
exit 1
fi
if [[ $(cat "${work}/snapctl.log") != "restart openshell.gateway" ]]; then
echo "FAIL: post-refresh hook must restart the gateway" >&2
cat "${work}/snapctl.log" >&2
exit 1
fi
common="${work}/broken-link"
mkdir -p "$common"
ln -s "${work}/missing-target" "$common/gateway.toml"
@@ -51,4 +134,9 @@ if [[ ! -d "$common/gateway.toml" ]]; then
exit 1
fi
if [[ -n $(find "$work" -name 'gateway.toml.pre-mtls*') ]]; then
echo "FAIL: install hook must not keep copies of replaced configs" >&2
exit 1
fi
echo "Snap install hook tests passed"