From 4cbe991814252470295eb44fc361adea68fb33cd Mon Sep 17 00:00:00 2001 From: Oliver Calder Date: Thu, 1 Oct 2026 16:26:01 -0500 Subject: [PATCH 1/2] feat(snap): run gateway as a user service by default Replace the existing system `gateway` service with separate `user-gateway` and `system-gateway` services. For new installs, only the `user-gateway` service is enabled. For existing installs, only the `system-gateway` service is enabled. Existing installs continue to have a one-time migration which removes legacy insecure configurations. It is up to `install.sh` or users to manually copy mTLS credentials from the root-owned `$SNAP_COMMON/tls` to the invoking user's OpenShell snap directory. This is explained in the snap description in `snapcraft.yaml`, visible in the Snap Store listing and via `snap info openshell`. Service enablement is now managed by a new snap configuration option named `gateway-mode`, so users can switch from one mode to another via e.g. `sudo snap set openshell gateway-mode=user`. The `install` and `post-refresh` hooks select which service to start by setting this mode. If the `gateway-mode` is already set, then we know the one-time migration has already taken place. Signed-off-by: Oliver Calder --- .agents/skills/test-release-canary/SKILL.md | 2 +- .github/workflows/release-canary.yml | 18 +- CI.md | 2 +- deploy/man/openshell-gateway.8.md | 4 +- docs/about/installation.mdx | 36 +++- docs/how-it-works/gateways/configuration.mdx | 8 +- install.sh | 138 ++++++++++++-- nix/test-guest/scripts/snap-gateway-repro.sh | 10 +- python/openshell/release_formula_test.py | 4 + skills/debug-openshell-cluster/SKILL.md | 2 +- snap/hooks/configure | 28 +++ snap/hooks/install | 10 + snap/hooks/post-refresh | 40 ++-- snapcraft.yaml | 80 ++++++-- tasks/scripts/snap-gateway-wrapper.sh | 7 +- tasks/scripts/test-install-sh.sh | 112 +++++++++-- tasks/scripts/test-packaging-assets.sh | 40 +++- tasks/scripts/test-snap-configure-hook.sh | 72 +++++++ tasks/scripts/test-snap-gateway-wrapper.sh | 27 +++ tasks/scripts/test-snap-install-hook.sh | 25 +++ tasks/scripts/test-snap-post-refresh-hook.sh | 189 +++++++++---------- 21 files changed, 654 insertions(+), 200 deletions(-) create mode 100755 snap/hooks/configure create mode 100755 snap/hooks/install create mode 100755 tasks/scripts/test-snap-configure-hook.sh create mode 100755 tasks/scripts/test-snap-install-hook.sh diff --git a/.agents/skills/test-release-canary/SKILL.md b/.agents/skills/test-release-canary/SKILL.md index 1f6965873c..ed5491e87f 100644 --- a/.agents/skills/test-release-canary/SKILL.md +++ b/.agents/skills/test-release-canary/SKILL.md @@ -143,7 +143,7 @@ Loopback registration auto-derives the gateway name to `openshell` if `--name` i | `macos`/`ubuntu-deb`/`fedora` job fails on `install.sh` | Dev release missing an asset, checksum mismatch, or `install.sh` regression on this branch. | Job log around the `curl … install.sh \| sh` step. | | Sandbox create or exec fails | Published sandbox and supervisor artifacts are missing, incompatible, or cannot establish the protected runtime channel. | Gateway logs plus Docker, Podman, VM, Snap, or Kubernetes runtime diagnostics for the job. | | `macos`/`ubuntu-deb`/`fedora` job fails on `openshell status` | Local gateway service did not start (systemd/brew/podman). Often a driver issue. | Service logs in the job log; `OPENSHELL_COMPUTE_DRIVER` env in the "Ensure …" step. | -| `ubuntu-snap-system-docker` fails during `install.sh` | System Docker was unavailable, the edge revision or automatic interfaces were unavailable, or the gateway did not become reachable. | Failure diagnostics dump system Docker, snap service/connection/change state, gateway and snapd journals, snap logs, and port 17670 listeners. | +| `ubuntu-snap-system-docker` fails during `install.sh` | System Docker was unavailable to the runner user, the edge revision or automatic interfaces were unavailable, or the user gateway did not become reachable. | Failure diagnostics dump system Docker, snap service/connection/change state, user and legacy gateway journals, snap logs, and port 17670 listeners. | | `ubuntu-snap-system-docker` fails during the prover checks | The prover artifact is missing or packaged for the wrong architecture, `openshell.prover` is not exposed or confined to read the test policies, or its solver linkage is not runnable. | The `Verify Snap installation` and `Check a policy boundary with the Snap prover` steps, plus `snap info openshell` and `snap connections openshell`. | | `ubuntu-snap-docker-preflight` unexpectedly succeeds | The installer no longer fails before installing the OpenShell snap when Docker is absent or supplied by the Docker snap. | Inspect `install.log`, `docker-snap.log`, `snap list`, and snapd changes. | | `kubernetes` job fails on `helm install --wait` | Chart did not deploy in 5 min — usually image pull failure or readiness probe failing. | "Diagnostics on failure" step dumps `helm status`, manifest, pod describe, pod logs. | diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 286d685672..a88a0a4d4a 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -221,7 +221,7 @@ jobs: - name: Install and check status run: | set -euo pipefail - sudo systemctl set-environment \ + systemctl --user set-environment \ "OPENSHELL_TELEMETRY_ENABLED=${OPENSHELL_TELEMETRY_ENABLED}" curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/${{ github.event.workflow_run.head_sha || github.sha }}/install.sh | sh sudo snap list openshell @@ -234,8 +234,12 @@ jobs: sudo snap connections openshell | grep -E '^docker +openshell:docker +:docker +' openshell --version openshell.prover --version - sudo snap services openshell - sudo journalctl -b -u snap.openshell.gateway.service --no-pager | + snap services openshell + systemctl --user is-enabled --quiet snap.openshell.user-gateway.service + systemctl --user is-active snap.openshell.user-gateway.service + ! systemctl is-enabled --quiet snap.openshell.system-gateway.service + ! systemctl is-active --quiet snap.openshell.system-gateway.service + journalctl --user -u snap.openshell.user-gateway.service --no-pager | grep -F "mTLS user authentication enabled" openshell gateway list | grep -F "https://127.0.0.1:17670" openshell status @@ -280,10 +284,12 @@ jobs: sudo snap services openshell sudo snap connections openshell sudo snap changes - sudo systemctl status snap.openshell.gateway.service --no-pager - sudo journalctl -b -u snap.openshell.gateway.service --no-pager -n 300 + systemctl --user status snap.openshell.user-gateway.service --no-pager + journalctl --user -u snap.openshell.user-gateway.service --no-pager -n 300 + sudo systemctl status snap.openshell.system-gateway.service --no-pager + sudo journalctl -b -u snap.openshell.system-gateway.service --no-pager -n 300 sudo journalctl -b -u snapd.service --no-pager -n 300 - sudo snap logs openshell.gateway -n=300 + snap logs openshell.user-gateway -n=300 sudo ss -ltnp '( sport = :17670 )' ubuntu-snap-docker-preflight: diff --git a/CI.md b/CI.md index cb7121b2cf..fed7be9554 100644 --- a/CI.md +++ b/CI.md @@ -491,7 +491,7 @@ These workflows run after merge to publish dev/tagged artifacts and verify them. |---|---| | `.github/workflows/release-dev.yml` | Publishes the rolling `dev` build on every push to `main`. Builds gateway, sandbox, and supervisor images and binaries, packages, wheels, and pushes the Helm chart as `oci://ghcr.io/nvidia/openshell/helm-chart:0.0.0-dev` (plus an immutable `0.0.0-dev.` pin). Also dispatchable manually. | | `.github/workflows/release-tag.yml` | Publishes tagged stable releases and manually dispatched pre-releases. Its automatic tag trigger excludes `-pre.*`. Protobuf, security, and integration failures do not block pre-release artifact publication. Stable publication requires the currently implemented qualification profile to pass; the summary identifies the remaining RFC 0014 coverage. | -| `.github/workflows/release-canary.yml` | Smoke-tests published dev artifacts in the `macos`, `ubuntu-deb`, `ubuntu-snap-system-docker`, `fedora`, and `kubernetes` (kind + Helm) jobs. Each job reaches its gateway and creates, exercises, and deletes a sandbox. The Snap lanes verify a compatible system Docker lifecycle and `ubuntu-snap-docker-preflight` tests fail-fast behavior when Docker is absent or supplied by the Docker snap. The positive Snap lane also runs a local policy containment check with the packaged prover. It runs automatically after `Release Dev` succeeds and supports manual dispatch (`gh workflow run release-canary.yml --ref `). See the `test-release-canary` skill for the playbook and local kind reproduction. | +| `.github/workflows/release-canary.yml` | Smoke-tests published dev artifacts in the `macos`, `ubuntu-deb`, `ubuntu-snap-system-docker`, `fedora`, and `kubernetes` (kind + Helm) jobs. Each job reaches its gateway and creates, exercises, and deletes a sandbox. The `ubuntu-snap-system-docker` job verifies a compatible system Docker lifecycle, while `ubuntu-snap-docker-preflight` tests fail-fast behavior when Docker is absent or supplied by the Docker snap. The positive Snap lane also runs a local policy containment check with the packaged prover. It runs automatically after `Release Dev` succeeds and supports manual dispatch (`gh workflow run release-canary.yml --ref `). See the `test-release-canary` skill for the playbook and local kind reproduction. | ## Required status contexts diff --git a/deploy/man/openshell-gateway.8.md b/deploy/man/openshell-gateway.8.md index 9be010095a..6e28e72ef7 100644 --- a/deploy/man/openshell-gateway.8.md +++ b/deploy/man/openshell-gateway.8.md @@ -138,7 +138,9 @@ The Debian and Ubuntu systemd user unit runs preflight before certificate generation, while retaining its EnvironmentFile and bare ExecStart behavior. The Snap wrapper replays its effective daemon arguments through preflight. It first uses a nonempty OPENSHELL_GATEWAY_CONFIG. Otherwise it passes the canonical -SNAP_COMMON/gateway.toml path whenever it exists or is a symlink. A broken symlink +service-specific gateway.toml path whenever it exists or is a symlink. The legacy +system service uses SNAP_COMMON/gateway.toml; the user service uses +SNAP_USER_COMMON/.config/openshell/gateway.toml. A broken symlink fails preflight before the gateway is started. Correct or manually migrate an operator-owned v1 file, then run preflight again before restarting the service. diff --git a/docs/about/installation.mdx b/docs/about/installation.mdx index 957c4e2316..17a8b934c4 100644 --- a/docs/about/installation.mdx +++ b/docs/about/installation.mdx @@ -117,31 +117,51 @@ The snap installs the standalone policy prover as `openshell.prover`. The The prover reads local policy files through the `home` interface and does not connect to the gateway. -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: +The gateway runs at `https://127.0.0.1:17670`. It reads `~/snap/openshell/common/.config/openshell/gateway.toml` when that file exists and stores its database and TLS material under `~/snap/openshell/common`. Register it from the same user account: ```shell +snap services openshell.user-gateway +openshell gateway add https://127.0.0.1:17670 --local --name openshell +openshell status +``` + +Existing installations upgraded from older revisions retain their state under `/var/snap/openshell/common` through the compatibility service `openshell.system-gateway`. The installer preserves that service model and updates the installing user's registration when required. + +An automatic refresh or direct `snap refresh` cannot enroll a particular local user. If `snap get openshell gateway-mode` reports `system`, wait for the compatibility service to be active, then copy its client credentials into each trusted user's Snap state and replace that user's old HTTP registration: + +```shell +snap services openshell.system-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 +sudo install -o "$USER" -g "$(id -gn)" -m 600 \ + /var/snap/openshell/common/tls/ca.crt "$d/" +sudo install -o "$USER" -g "$(id -gn)" -m 600 \ + /var/snap/openshell/common/tls/client/tls.crt "$d/client/" +sudo install -o "$USER" -g "$(id -gn)" -m 600 \ + /var/snap/openshell/common/tls/client/tls.key "$d/client/" +openshell gateway list +openshell gateway remove openshell gateway add https://127.0.0.1:17670 --local --name openshell openshell status ``` -Keep the client key private. +Use the name shown by `openshell gateway list` for ``. Older direct Snap instructions may have used `openshell-gateway`, while the install script uses `openshell`. -To install a locally built snap, connect its interfaces manually. The gateway may reach systemd's start limit before Docker is connected, so reset the failed unit and restart the gateway after connecting the interfaces: +Switch between the user-owned and compatibility services with `sudo snap set openshell gateway-mode=user` or `sudo snap set openshell gateway-mode=system`. + +To install a locally built snap, connect its interfaces manually. The user gateway may reach systemd's start limit before Docker is connected, so reset the failed unit and restart it from the affected user session after connecting the interfaces: ```shell sudo snap install ./openshell_*.snap --dangerous sudo snap connect openshell:log-observe sudo snap connect openshell:system-observe sudo snap connect openshell:docker :docker -sudo systemctl reset-failed snap.openshell.gateway.service -sudo snap restart openshell.gateway +systemctl --user reset-failed snap.openshell.user-gateway.service +snap start openshell.user-gateway ``` +For an installation upgraded from a legacy system gateway, use `sudo systemctl reset-failed snap.openshell.system-gateway.service` and `sudo snap restart openshell.system-gateway` instead. + ## Kubernetes Deploy the gateway to a cluster with the OpenShell Helm chart. See [Kubernetes Setup](/kubernetes/setup). diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 4cfd902daa..5c82f88d81 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -31,7 +31,7 @@ Package-managed gateways use either built-in defaults or a package-seeded TOML f | Homebrew | `$XDG_CONFIG_HOME/openshell/gateway.toml` when it exists, otherwise the Homebrew prefix config such as `/opt/homebrew/var/openshell/gateway.toml`. | | Debian/Ubuntu | `$XDG_CONFIG_HOME/openshell/gateway.toml`, usually `~/.config/openshell/gateway.toml` for the systemd user service. | | Fedora/RHEL RPM | `$XDG_CONFIG_HOME/openshell/gateway.toml`, usually `~/.config/openshell/gateway.toml`; the systemd user service seeds this file from the packaged template on first start. | -| Snap | `$SNAP_COMMON/gateway.toml`, usually `/var/snap/openshell/common/gateway.toml`. | +| Snap | New installations use `$SNAP_USER_COMMON/.config/openshell/gateway.toml`, usually `~/snap/openshell/common/.config/openshell/gateway.toml`. Upgraded legacy installations retain `$SNAP_COMMON/gateway.toml`, usually `/var/snap/openshell/common/gateway.toml`. | The Fedora/RHEL RPM template leaves `[openshell.gateway].bind_address` unset. The gateway therefore uses its built-in `127.0.0.1:17670` primary listener. Host-networked Podman supervisors use that same loopback endpoint, so the primary listener does not need a wildcard address. Set `bind_address` explicitly only when clients must reach the primary multiplexed API through another interface. @@ -1268,8 +1268,10 @@ Debian and Ubuntu run preflight from the systemd user unit before local certific generation. The unit still loads the `gateway.env` environment file and starts the gateway with no configuration arguments. Snap replays the exact effective daemon arguments through preflight. It gives a nonempty `OPENSHELL_GATEWAY_CONFIG` -precedence; otherwise it validates and passes its canonical -`SNAP_COMMON/gateway.toml` only when that path exists in the filesystem. A broken +precedence; otherwise it validates and passes its canonical service-specific +path only when that path exists in the filesystem. The legacy system service +uses `$SNAP_COMMON/gateway.toml`; the user service uses +`$SNAP_USER_COMMON/.config/openshell/gateway.toml`. A broken symlink is therefore rejected instead of being treated as absent. Package startup does not modify an operator-owned v1 file. Back it up, follow diff --git a/install.sh b/install.sh index 83b7c479be..1a77ba94cd 100755 --- a/install.sh +++ b/install.sh @@ -970,13 +970,18 @@ dump_snap_gateway_diagnostics() { info "OpenShell snap service status:" as_root snap services openshell >&2 || true + info "OpenShell snap user service status for ${TARGET_USER}:" + as_target_user snap services openshell.user-gateway >&2 || true info "OpenShell snap connections:" - as_root snap connections openshell >&2 || true + snap connections openshell >&2 || true if has_cmd journalctl; then - info "last ${_lines} lines from the OpenShell snap gateway journal:" - as_root journalctl -b -u snap.openshell.gateway.service --no-pager -n "$_lines" >&2 || true + info "last ${_lines} lines from the legacy OpenShell snap gateway journal:" + journalctl -b -u snap.openshell.system-gateway.service --no-pager -n "$_lines" >&2 || true + info "last ${_lines} lines from the OpenShell snap user gateway journal:" + as_target_user journalctl --user -u snap.openshell.user-gateway --no-pager -n "$_lines" >&2 || true fi - as_root snap logs openshell.gateway -n="$_lines" >&2 || true + as_root snap logs openshell.system-gateway -n="$_lines" >&2 || true + as_target_user snap logs openshell.user-gateway -n="$_lines" >&2 || true } dump_homebrew_gateway_diagnostics() { @@ -1249,12 +1254,40 @@ wait_for_docker_daemon() { [ -z "$_last_output" ] || printf '%s\n' "$_last_output" >&2 if snap list docker >/dev/null 2>&1; then - as_root snap services docker >&2 || true - as_root snap changes >&2 || true + snap services docker >&2 || true + snap changes >&2 || true fi error "Docker daemon did not become reachable within ${_timeout}s" } +wait_for_user_docker_daemon() { + _timeout="${OPENSHELL_INSTALL_DOCKER_TIMEOUT:-30}" + _elapsed=0 + _last_output="" + + info "waiting for Docker daemon to become reachable as ${TARGET_USER}..." + while [ "$_elapsed" -lt "$_timeout" ]; do + if _last_output="$(as_target_user docker info 2>&1)"; then + info "Docker daemon is reachable as ${TARGET_USER}" + return 0 + fi + sleep 1 + _elapsed=$((_elapsed + 1)) + done + + [ -z "$_last_output" ] || printf '%s\n' "$_last_output" >&2 + error "Docker daemon did not become reachable as ${TARGET_USER} within ${_timeout}s. Ensure ${TARGET_USER} can access the Docker socket, then log out and back in after changing group membership." +} + +snap_gateway_mode() { + _mode="$(as_root snap get openshell gateway-mode 2>/dev/null || true)" + case "$_mode" in + user | system) printf '%s\n' "$_mode" ;; + '') error "OpenShell snap gateway mode was not initialized" ;; + *) error "unsupported OpenShell snap gateway mode: ${_mode}" ;; + esac +} + # 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. @@ -1277,7 +1310,7 @@ snap_gateway_uses_mtls() { [ -e "${OPENSHELL_SNAP_DIR:-/snap/openshell/current}/meta/hooks/post-refresh" ] } -register_snap_gateway() { +register_system_snap_gateway() { _register_bin="${OPENSHELL_REGISTER_BIN:-/snap/bin/openshell}" if snap_gateway_uses_mtls; then @@ -1309,9 +1342,33 @@ register_snap_gateway() { esac } +register_user_snap_gateway() { + _register_bin="${OPENSHELL_REGISTER_BIN:-/snap/bin/openshell}" + _endpoint="https://127.0.0.1:${LOCAL_GATEWAY_PORT}" + + 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" + return 0 + else + _add_status=$? + fi + + case "$_add_output" in + *"already exists"*) + info "local gateway already exists; removing and re-adding it..." + remove_snap_gateway_registration + as_target_user "$_register_bin" gateway add "$_endpoint" --local --name openshell + ;; + *) + printf '%s\n' "$_add_output" >&2 + return "$_add_status" + ;; + 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() { +wait_for_system_snap_gateway_listener() { _timeout="${OPENSHELL_INSTALL_GATEWAY_TIMEOUT:-30}" _elapsed=0 _last_output="" @@ -1343,6 +1400,32 @@ wait_for_snap_gateway_listener() { error "local gateway listener did not become reachable at ${_probe_url} within ${_timeout}s" } +wait_for_user_snap_gateway_listener() { + _timeout="${OPENSHELL_INSTALL_GATEWAY_TIMEOUT:-30}" + _elapsed=0 + _last_output="" + _tls_dir="${TARGET_HOME}/snap/openshell/common/.local/state/openshell/tls" + _probe_url="https://127.0.0.1:${LOCAL_GATEWAY_PORT}/" + + info "waiting for local gateway listener to become reachable..." + while [ "$_elapsed" -lt "$_timeout" ]; do + if _last_output="$(as_target_user curl -sS --max-time 2 \ + --cacert "${_tls_dir}/ca.crt" \ + --cert "${_tls_dir}/client/tls.crt" \ + --key "${_tls_dir}/client/tls.key" \ + -o /dev/null "$_probe_url" 2>&1)"; then + info "local gateway listener is reachable" + return 0 + fi + sleep 1 + _elapsed=$((_elapsed + 1)) + done + + [ -z "$_last_output" ] || printf '%s\n' "$_last_output" >&2 + dump_local_gateway_diagnostics + error "local gateway listener did not become reachable at ${_probe_url} within ${_timeout}s" +} + install_linux_snap() { require_cmd snap set_linux_target_runtime_dir @@ -1355,25 +1438,50 @@ Remove the Docker snap and install Docker Engine from a system package or Docker error "Docker is required before installing the OpenShell snap. Install Docker Engine from a system package or Docker's package repository, then rerun this installer. The Docker snap is not currently compatible with OpenShell." fi + if snap list openshell >/dev/null 2>&1; then + _existing_snap=true + _existing_mode="$(as_root snap get openshell gateway-mode 2>/dev/null || true)" + else + _existing_snap=false + _existing_mode=user + fi + info "using existing Docker installation" - wait_for_docker_daemon + if [ "$_existing_mode" = user ]; then + wait_for_user_docker_daemon + else + wait_for_docker_daemon + fi _channel="$(openshell_snap_channel)" - if snap list openshell >/dev/null 2>&1; then + if [ "$_existing_snap" = true ]; then info "refreshing OpenShell snap from ${_channel}..." as_root snap refresh openshell --channel="$_channel" - warn "restarting the OpenShell gateway to use the refreshed snap; active sandbox sessions will be interrupted" else info "installing OpenShell snap from ${_channel}..." as_root snap install openshell --channel="$_channel" fi - as_root snap restart openshell.gateway + _gateway_mode="$(snap_gateway_mode)" + if [ "$_gateway_mode" = system ]; then + warn "restarting the OpenShell gateway to use the refreshed snap; active sandbox sessions will be interrupted" + as_root snap restart openshell.system-gateway + elif [ "$_existing_snap" = true ]; then + warn "restarting the OpenShell user gateway to use the refreshed snap; active sandbox sessions will be interrupted" + # as root, snap restart automatically restarts the given user services for + # all active users (there should just be one), and is forwards-compatible + # with planned changes to the snap user daemon commands. + as_root snap restart openshell.user-gateway + fi - info "installed OpenShell snap from ${_channel}" - wait_for_snap_gateway_listener info "registering local gateway as ${TARGET_USER}..." - register_snap_gateway + if [ "$_gateway_mode" = system ]; then + wait_for_system_snap_gateway_listener + register_system_snap_gateway + else + wait_for_user_snap_gateway_listener + register_user_snap_gateway + fi OPENSHELL_REGISTER_BIN="/snap/bin/openshell" wait_for_local_gateway_status } diff --git a/nix/test-guest/scripts/snap-gateway-repro.sh b/nix/test-guest/scripts/snap-gateway-repro.sh index 8dd09fc206..34fd95cca0 100755 --- a/nix/test-guest/scripts/snap-gateway-repro.sh +++ b/nix/test-guest/scripts/snap-gateway-repro.sh @@ -85,12 +85,16 @@ diagnostics() { sudo journalctl -b -u docker.service --no-pager -n 300 >&2 || true fi sudo snap services openshell >&2 || true + snap services openshell.user-gateway >&2 || true sudo snap connections openshell >&2 || true sudo snap changes >&2 || true - sudo systemctl status snap.openshell.gateway.service --no-pager >&2 || true - sudo journalctl -b -u snap.openshell.gateway.service --no-pager -n 300 >&2 || true + sudo systemctl status snap.openshell.system-gateway.service --no-pager >&2 || true + sudo journalctl -b -u snap.openshell.system-gateway.service --no-pager -n 300 >&2 || true + systemctl --user status snap.openshell.user-gateway.service --no-pager >&2 || true + journalctl --user -u snap.openshell.user-gateway.service --no-pager -n 300 >&2 || true sudo journalctl -b -u snapd.service --no-pager -n 300 >&2 || true - sudo snap logs openshell.gateway -n=300 >&2 || true + sudo snap logs openshell.system-gateway -n=300 >&2 || true + snap logs openshell.user-gateway -n=300 >&2 || true sudo ss -ltnp '( sport = :17670 )' >&2 || true } diff --git a/python/openshell/release_formula_test.py b/python/openshell/release_formula_test.py index b845c83090..d29d91ac08 100644 --- a/python/openshell/release_formula_test.py +++ b/python/openshell/release_formula_test.py @@ -151,6 +151,10 @@ def test_snap_wrapper_uses_optional_gateway_config_without_generating_toml() -> ) assert "init-gateway-config.sh" not in wrapper + assert ( + 'CANONICAL_CONFIG_FILE="${OPENSHELL_SNAP_CONFIG_FILE:-${SNAP_COMMON}/gateway.toml}"' + in wrapper + ) assert ( 'export OPENSHELL_DB_URL="${OPENSHELL_DB_URL:-sqlite:${SNAP_COMMON}/gateway.db?mode=rwc}"' in wrapper diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index ab774e3a88..473578f1de 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -72,7 +72,7 @@ Common findings: - `No active gateway`: register one with `openshell gateway add `. - 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 CLI mTLS bundle does not match the gateway CA, a supervisor is missing the gateway CA, or TLS termination does not match the gateway listener. Workloads and supervisors should not contain a user TLS client certificate or private key. -- 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. +- Snap installations normally use `openshell.user-gateway` and user-owned mTLS state. Upgraded legacy installations retain their system-owned state through `openshell.system-gateway`; the first refresh removes an explicitly insecure system config and records `gateway-mode=system`, 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 --gateway-insecure`; do not persist or recommend insecure verification for shared gateways. diff --git a/snap/hooks/configure b/snap/hooks/configure new file mode 100755 index 0000000000..17869ff044 --- /dev/null +++ b/snap/hooks/configure @@ -0,0 +1,28 @@ +#!/bin/sh +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Apply the configured gateway service model. This hook also makes +# `snap set openshell gateway-mode=` switch services. + +set -eu + +gateway_mode=$(snapctl get gateway-mode) +case "$gateway_mode" in + user) + snapctl stop --disable "${SNAP_INSTANCE_NAME}.system-gateway" + snapctl start --enable "${SNAP_INSTANCE_NAME}.user-gateway" + ;; + system) + snapctl stop --disable "${SNAP_INSTANCE_NAME}.user-gateway" + snapctl start --enable "${SNAP_INSTANCE_NAME}.system-gateway" + ;; + '') + echo "openshell: gateway-mode is not configured" >&2 + exit 1 + ;; + *) + echo "openshell: unsupported gateway-mode: ${gateway_mode}" >&2 + exit 1 + ;; +esac diff --git a/snap/hooks/install b/snap/hooks/install new file mode 100755 index 0000000000..22b40425f3 --- /dev/null +++ b/snap/hooks/install @@ -0,0 +1,10 @@ +#!/bin/sh +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Fresh installations use the per-user gateway. The configure hook applies the +# service selection after this transaction commits. + +set -eu + +snapctl set gateway-mode=user diff --git a/snap/hooks/post-refresh b/snap/hooks/post-refresh index 14fbe5d482..cf95f0bb28 100755 --- a/snap/hooks/post-refresh +++ b/snap/hooks/post-refresh @@ -2,29 +2,35 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Remove insecure gateway configs on refresh, then restart the gateway so the -# default config takes effect even when the previous revision used -# refresh-mode: endure and kept its plaintext gateway running. +# One-time migration for installations that predate per-user snap gateways. +# Existing installations retain the system gateway; fresh installations record +# gateway-mode=user in the install hook and skip this migration. The configure +# hook applies the selected service after this transaction commits. set -eu +gateway_mode=$(snapctl get gateway-mode) +case "$gateway_mode" in + user | system) + exit 0 + ;; + '') + ;; + *) + echo "openshell: unsupported gateway-mode: ${gateway_mode}" >&2 + exit 1 + ;; +esac + config_file="${SNAP_COMMON}/gateway.toml" insecure='^[[:space:]]*(allow_unauthenticated_users|disable_tls)[[:space:]]*=[[:space:]]*true([[:space:]#]|$)' -# Keep secure operator configs, symlinks, and directories. Remove a config -# that explicitly allows plaintext or anonymous access, even if it has other -# edits. -if [ ! -e "$config_file" ]; then - exit 0 -elif [ -L "$config_file" ]; then - exit 0 -elif [ -f "$config_file" ]; then - grep -Eq "$insecure" "$config_file" || exit 0 +# Keep secure operator configs and directories. Remove a regular config, or +# only the symlink to one, when it explicitly allows plaintext or anonymous +# access. Never modify a symlink target. +if [ -f "$config_file" ] && grep -Eq "$insecure" "$config_file"; then echo "openshell: removing insecure gateway config to use the mTLS default" >&2 -elif [ -e "$config_file" ]; then - exit 0 + rm -f "$config_file" fi -rm -f "$config_file" - -snapctl restart "${SNAP_INSTANCE_NAME}.gateway" +snapctl set gateway-mode=system diff --git a/snapcraft.yaml b/snapcraft.yaml index 7a7340d857..19286b29bf 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -13,7 +13,7 @@ description: | The OpenShell snap ships a CLI (`openshell`), a terminal UI (`openshell.term`), a standalone policy prover (`openshell.prover`), and a - managed gateway daemon (`openshell.gateway`). + managed gateway daemon (`openshell.user-gateway`). **Setup instructions** @@ -23,26 +23,43 @@ description: | Snap Store installations automatically connect the required interfaces. - If the OpenShell snap was installed before Docker was running, it may - have hit the systemd start limit when trying to repeatedly start and - connect to docker. If this is the case, it can be resolved by doing: + If the OpenShell snap was installed before Docker was running, its user + service may have hit the systemd start limit. Resolve this from the + affected user's session after Docker is available: - sudo systemctl reset-failed snap.openshell.gateway.service - sudo snap restart openshell.gateway + systemctl --user reset-failed snap.openshell.user-gateway.service + snap start openshell.user-gateway - 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: + 2. Register the per-user gateway after it provisions its mTLS credentials: - 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 + snap services openshell.user-gateway openshell gateway add https://127.0.0.1:17670 --local --name openshell openshell status + **Legacy upgrades** + + Installations upgraded from older revisions retain their existing gateway + state under the compatibility service `openshell.system-gateway`. + + If `snap get openshell gateway-mode` reports `system` after an automatic or + direct `snap refresh`, copy the system gateway's client credentials into + each trusted user's Snap state and replace that user's old HTTP gateway + registration. Wait for `openshell.system-gateway` to be active first: + + snap services openshell.system-gateway + d=~/snap/openshell/common/.local/state/openshell/tls + mkdir -p -m 700 "$d" "$d/client" + sudo install -o "$USER" -g "$(id -gn)" -m 600 \ + /var/snap/openshell/common/tls/ca.crt "$d/" + sudo install -o "$USER" -g "$(id -gn)" -m 600 \ + /var/snap/openshell/common/tls/client/tls.crt "$d/client/" + sudo install -o "$USER" -g "$(id -gn)" -m 600 \ + /var/snap/openshell/common/tls/client/tls.key "$d/client/" + openshell gateway list + openshell gateway remove + openshell gateway add https://127.0.0.1:17670 --local --name openshell + openshell status + base: core24 grade: stable confinement: strict @@ -92,17 +109,19 @@ apps: - openshell-prover plugs: - home - gateway: + system-gateway: command: bin/openshell-gateway-wrapper daemon: simple + daemon-scope: system + install-mode: disable # 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. + # The installer restarts the selected service after an explicit refresh. refresh-mode: endure - # Snapd runs this daemon as root. The wrapper serves TLS from the bundle + # This compatibility daemon runs 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 + # certificates; the legacy installer path 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. @@ -115,6 +134,27 @@ apps: - network - network-bind - system-observe + user-gateway: + command: bin/openshell-gateway-wrapper + daemon: simple + daemon-scope: user + install-mode: disable + refresh-mode: endure + # The shared wrapper uses these user-owned paths instead of its legacy + # $SNAP_COMMON defaults. The CLI uses the same XDG config and state roots. + environment: + XDG_CONFIG_HOME: "$SNAP_USER_COMMON/.config" + XDG_DATA_HOME: "$SNAP_USER_COMMON/.local/share" + XDG_STATE_HOME: "$SNAP_USER_COMMON/.local/state" + OPENSHELL_SNAP_CONFIG_FILE: "$SNAP_USER_COMMON/.config/openshell/gateway.toml" + OPENSHELL_DB_URL: "sqlite:$SNAP_USER_COMMON/gateway.db?mode=rwc" + OPENSHELL_LOCAL_TLS_DIR: "$SNAP_USER_COMMON/.local/state/openshell/tls" + plugs: + - docker + - log-observe + - network + - network-bind + - system-observe parts: openshell: diff --git a/tasks/scripts/snap-gateway-wrapper.sh b/tasks/scripts/snap-gateway-wrapper.sh index a047cf72e3..a5df1eb02a 100755 --- a/tasks/scripts/snap-gateway-wrapper.sh +++ b/tasks/scripts/snap-gateway-wrapper.sh @@ -3,15 +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_LOCAL_TLS_DIR -> $SNAP_COMMON/tls (overridable) +# - OPENSHELL_SNAP_CONFIG_FILE -> $SNAP_COMMON/gateway.toml (overridable) +# - 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" +CANONICAL_CONFIG_FILE="${OPENSHELL_SNAP_CONFIG_FILE:-${SNAP_COMMON}/gateway.toml}" export OPENSHELL_DB_URL="${OPENSHELL_DB_URL:-sqlite:${SNAP_COMMON}/gateway.db?mode=rwc}" export OPENSHELL_LOCAL_TLS_DIR="${OPENSHELL_LOCAL_TLS_DIR:-${SNAP_COMMON}/tls}" diff --git a/tasks/scripts/test-install-sh.sh b/tasks/scripts/test-install-sh.sh index 581251891c..ce00afdd3d 100755 --- a/tasks/scripts/test-install-sh.sh +++ b/tasks/scripts/test-install-sh.sh @@ -283,14 +283,33 @@ assert_snap_install_flow() { case "${1:-}:${2:-}" in list:docker) return 1 ;; list:openshell) [ "$openshell_present" = "1" ] ;; + get:openshell) printf '%s\n' system ;; *) command snap "$@" ;; esac } - as_root() { printf 'root:%s\n' "$*"; } + as_root() { + if [ "${1:-}:${2:-}" = snap:get ]; then + shift + snap "$@" + else + printf 'root:%s\n' "$*" + fi + } + as_target_user() { printf 'target:%s\n' "$*"; } set_linux_target_runtime_dir() { :; } wait_for_docker_daemon() { printf '%s\n' "wait:docker"; } - register_snap_gateway() { printf '%s\n' "register:gateway"; } - wait_for_snap_gateway_listener() { printf '%s\n' "wait:gateway-listener"; } + wait_for_user_docker_daemon() { printf '%s\n' "wait:user-docker"; } + snap_gateway_mode() { + if [ "$openshell_present" = 1 ]; then + printf '%s\n' system + else + printf '%s\n' user + fi + } + register_system_snap_gateway() { printf '%s\n' "register:system-gateway"; } + register_user_snap_gateway() { printf '%s\n' "register:user-gateway"; } + wait_for_system_snap_gateway_listener() { printf '%s\n' "wait:system-gateway-listener"; } + wait_for_user_snap_gateway_listener() { printf '%s\n' "wait:user-gateway-listener"; } wait_for_local_gateway_status() { printf '%s\n' "wait:gateway-status"; } info() { :; } export TARGET_USER=test-user @@ -308,24 +327,75 @@ assert_snap_install_flow() { fi } +assert_snap_user_refresh_flow() { + local calls + ( + has_cmd() { + case "$1" in + snap | docker) return 0 ;; + *) command -v "$1" >/dev/null 2>&1 ;; + esac + } + snap() { + case "${1:-}:${2:-}" in + list:docker) return 1 ;; + list:openshell) return 0 ;; + get:openshell) printf '%s\n' user ;; + *) command snap "$@" ;; + esac + } + as_root() { + if [ "${1:-}:${2:-}" = snap:get ]; then + shift + snap "$@" + else + printf 'root:%s\n' "$*" + fi + } + as_target_user() { printf 'target:%s\n' "$*"; } + set_linux_target_runtime_dir() { :; } + wait_for_user_docker_daemon() { printf '%s\n' "wait:user-docker"; } + snap_gateway_mode() { printf '%s\n' user; } + wait_for_user_snap_gateway_listener() { printf '%s\n' "wait:user-gateway-listener"; } + register_user_snap_gateway() { printf '%s\n' "register:user-gateway"; } + wait_for_local_gateway_status() { printf '%s\n' "wait:gateway-status"; } + info() { :; } + export TARGET_USER=test-user + install_linux_snap + ) >"$out" + calls="$(cat "$out")" + expected="wait:user-docker +root:snap refresh openshell --channel=latest/stable +root:snap restart openshell.user-gateway +wait:user-gateway-listener +register:user-gateway +wait:gateway-status" + if [ "$calls" != "$expected" ]; then + echo "FAIL: existing user-mode Snap refresh used the wrong service" >&2 + printf 'Expected:\n%s\nActual:\n%s\n' "$expected" "$calls" >&2 + exit 1 + fi +} + assert_snap_install_flow \ "existing Docker is reused" \ 1 0 "" \ - "wait:docker + "wait:user-docker root:snap install openshell --channel=latest/stable -root:snap restart openshell.gateway -wait:gateway-listener -register:gateway +wait:user-gateway-listener +register:user-gateway wait:gateway-status" +assert_snap_user_refresh_flow + assert_snap_install_flow \ "existing OpenShell snap is refreshed" \ 1 1 "" \ "wait:docker root:snap refresh openshell --channel=latest/stable -root:snap restart openshell.gateway -wait:gateway-listener -register:gateway +root:snap restart openshell.system-gateway +wait:system-gateway-listener +register:system-gateway wait:gateway-status" assert_snap_install_rejected() { @@ -445,7 +515,7 @@ if ! ( info() { :; } TARGET_USER=test-user snap_gateway_uses_mtls() { return 0; } - register_snap_gateway + register_system_snap_gateway ) >"$out" 2>"$err"; then echo "FAIL: Snap gateway registration should succeed" >&2 cat "$err" >&2 || true @@ -465,7 +535,7 @@ if ! ( 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 + register_system_snap_gateway ) >"$out" 2>"$err"; then echo "FAIL: legacy plaintext Snap gateway registration should succeed" >&2 cat "$err" >&2 || true @@ -494,7 +564,7 @@ assert_snap_listener_probe() { snap_gateway_uses_mtls() { [ "$uses_mtls" = "1" ]; } info() { :; } OPENSHELL_SNAP_TLS_DIR=/tls - wait_for_snap_gateway_listener >/dev/null + wait_for_system_snap_gateway_listener >/dev/null printf '%s\n' "$_last_output" )" if [ "$actual" != "$expected" ]; then @@ -539,6 +609,22 @@ if [[ -z $(find "$snap_user_tls" -maxdepth 0 -perm 700) ]]; then exit 1 fi +: >"$registration_calls_file" +if ! ( + as_target_user() { printf 'target:%s\n' "$*" >>"$registration_calls_file"; } + print_gateway_add_output() { :; } + register_user_snap_gateway +) >"$out" 2>"$err"; then + echo "FAIL: user Snap gateway registration should succeed" >&2 + cat "$err" >&2 || true + exit 1 +fi +if [[ $(cat "$registration_calls_file") != "target:/snap/bin/openshell gateway add https://127.0.0.1:17670 --local --name openshell" ]]; then + echo "FAIL: user Snap gateway registration must use its own local TLS bundle" >&2 + cat "$registration_calls_file" >&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 diff --git a/tasks/scripts/test-packaging-assets.sh b/tasks/scripts/test-packaging-assets.sh index 1d5a487a76..678480adb5 100755 --- a/tasks/scripts/test-packaging-assets.sh +++ b/tasks/scripts/test-packaging-assets.sh @@ -84,6 +84,8 @@ snap_workflow="${ROOT}/.github/workflows/snap-package.yml" snap_install_docs="${ROOT}/docs/about/installation.mdx" snap_canary="${ROOT}/.github/workflows/release-canary.yml" snap_repro="${ROOT}/nix/test-guest/scripts/snap-gateway-repro.sh" +snap_configure_hook="${ROOT}/snap/hooks/configure" +snap_install_hook="${ROOT}/snap/hooks/install" snap_post_refresh_hook="${ROOT}/snap/hooks/post-refresh" package_deb="${ROOT}/tasks/scripts/package-deb.sh" assert_file_exists "$snap_wrapper" @@ -92,6 +94,8 @@ assert_file_exists "$snap_workflow" assert_file_exists "$snap_install_docs" assert_file_exists "$snap_canary" assert_file_exists "$snap_repro" +assert_file_exists "$snap_configure_hook" +assert_file_exists "$snap_install_hook" assert_file_exists "$snap_post_refresh_hook" assert_file_exists "$package_deb" assert_contains "$service" "ExecStartPre=/usr/bin/openshell-gateway config preflight" @@ -114,6 +118,8 @@ for snap_file in \ "$snap_install_docs" \ "$snap_canary" \ "$snap_repro" \ + "$snap_configure_hook" \ + "$snap_install_hook" \ "$snap_post_refresh_hook"; do assert_not_contains "$snap_file" "docker:docker-daemon" assert_not_contains "$snap_file" "default-provider: docker" @@ -122,16 +128,35 @@ if [[ -e "${ROOT}/snap/hooks/connect-plug-docker" ]]; then echo "FAIL: obsolete Snap Docker connection hook must not exist" >&2 exit 1 fi -if [[ -e "${ROOT}/snap/hooks/install" ]]; then - echo "FAIL: obsolete Snap install hook must not exist" >&2 +if [[ ! -x "$snap_install_hook" ]]; then + echo "FAIL: Snap install hook must be executable" >&2 exit 1 fi +if [[ ! -x "$snap_configure_hook" ]]; then + echo "FAIL: Snap configure hook must be executable" >&2 + exit 1 +fi +assert_contains "$snapcraft" 'daemon-scope: system' +assert_contains "$snapcraft" 'daemon-scope: user' +assert_contains "$snapcraft" 'install-mode: disable' +assert_not_contains "$snapcraft" 'install-mode: enable' +if grep -Eq '^ gateway:$' "$snapcraft"; then + echo "FAIL: removed Snap gateway app must not remain declared" >&2 + exit 1 +fi +assert_contains "$snapcraft" ' system-gateway:' +assert_contains "$snapcraft" ' user-gateway:' +assert_contains "$snapcraft" 'OPENSHELL_SNAP_CONFIG_FILE: "$SNAP_USER_COMMON/.config/openshell/gateway.toml"' +assert_contains "$snapcraft" 'OPENSHELL_DB_URL: "sqlite:$SNAP_USER_COMMON/gateway.db?mode=rwc"' +assert_contains "$snapcraft" 'OPENSHELL_LOCAL_TLS_DIR: "$SNAP_USER_COMMON/.local/state/openshell/tls"' assert_contains "$snapcraft" 'refresh-mode: endure' if [[ ! -x "$snap_post_refresh_hook" ]]; 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-configure-hook.sh" "$snap_configure_hook" +bash "$ROOT/tasks/scripts/test-snap-install-hook.sh" "$snap_install_hook" bash "$ROOT/tasks/scripts/test-snap-post-refresh-hook.sh" "$snap_post_refresh_hook" assert_contains "$snap_workflow" 'name: openshell-prover-${{ matrix.rust_arch }}-unknown-linux-musl' assert_contains "$snap_workflow" 'chmod +x prebuilt/prover/openshell-prover' @@ -157,8 +182,15 @@ assert_not_contains "$snap_install_docs" "snap connect openshell:home" assert_not_contains "$snap_install_docs" "snap connect openshell:network" assert_not_contains "$snap_install_docs" "snap connect openshell:network-bind" assert_contains "$snap_install_docs" "snap connect openshell:docker :docker" -assert_contains "$snap_install_docs" "systemctl reset-failed snap.openshell.gateway.service" -assert_contains "$snap_install_docs" "snap restart openshell.gateway" +assert_contains "$snap_install_docs" "systemctl --user reset-failed snap.openshell.user-gateway.service" +assert_contains "$snap_install_docs" "snap start openshell.user-gateway" +assert_contains "$snap_install_docs" "openshell.user-gateway" +assert_contains "$snap_install_docs" "openshell.system-gateway" +assert_contains "$snap_install_docs" "/var/snap/openshell/common/tls" +assert_contains "$snap_install_docs" "openshell gateway remove " +assert_contains "$snap_install_docs" "openshell gateway add https://127.0.0.1:17670 --local --name openshell" +assert_not_contains "$snap_install_docs" "snap start --user" +assert_not_contains "$snap_install_docs" "snap services --user" assert_contains "$snap_canary" "install.sh | sh" assert_contains "$snap_canary" "ubuntu-snap-system-docker:" assert_contains "$snap_canary" "ubuntu-snap-docker-preflight:" diff --git a/tasks/scripts/test-snap-configure-hook.sh b/tasks/scripts/test-snap-configure-hook.sh new file mode 100755 index 0000000000..36be96261c --- /dev/null +++ b/tasks/scripts/test-snap-configure-hook.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +hook=${1:?Usage: test-snap-configure-hook.sh } +work=$(mktemp -d "${TMPDIR:-/tmp}/openshell snap configure hook.XXXXXX") +trap 'rm -rf "$work"' EXIT + +run_hook() { + local mode=$1 + rm -f "$work/snapctl.log" + mkdir -p "$work/bin" + cat >"$work/bin/snapctl" <>'$work/snapctl.log' +EOF + chmod 755 "$work/bin/snapctl" + PATH="$work/bin:$PATH" SNAP_INSTANCE_NAME=openshell "$hook" +} + +run_hook user +expected=$'stop --disable openshell.system-gateway\nstart --enable openshell.user-gateway' +[[ $(cat "$work/snapctl.log") == "$expected" ]] + +run_hook system +expected=$'stop --disable openshell.user-gateway\nstart --enable openshell.system-gateway' +[[ $(cat "$work/snapctl.log") == "$expected" ]] + +for mode in '' invalid; do + if run_hook "$mode" >"$work/out" 2>"$work/err"; then + echo "FAIL: configure hook accepted gateway mode '${mode}'" >&2 + exit 1 + fi + [[ ! -e "$work/snapctl.log" ]] +done +grep -Fq 'unsupported gateway-mode: invalid' "$work/err" + +mkdir -p "$work/failure-bin" +cat >"$work/failure-bin/snapctl" <>'$work/failure.log' +[ "\${1:-}" != stop ] +EOF +chmod 755 "$work/failure-bin/snapctl" +if PATH="$work/failure-bin:$PATH" SNAP_INSTANCE_NAME=openshell "$hook"; then + echo "FAIL: configure hook ignored inactive-service stop failure" >&2 + exit 1 +fi +[[ $(cat "$work/failure.log") == 'stop --disable openshell.system-gateway' ]] + +cat >"$work/failure-bin/snapctl" <>'$work/start-failure.log' +[ "\${1:-}" != start ] +EOF +chmod 755 "$work/failure-bin/snapctl" +if PATH="$work/failure-bin:$PATH" SNAP_INSTANCE_NAME=openshell "$hook"; then + echo "FAIL: configure hook ignored selected-service start failure" >&2 + exit 1 +fi +expected=$'stop --disable openshell.system-gateway\nstart --enable openshell.user-gateway' +[[ $(cat "$work/start-failure.log") == "$expected" ]] + +echo "Snap configure hook tests passed" diff --git a/tasks/scripts/test-snap-gateway-wrapper.sh b/tasks/scripts/test-snap-gateway-wrapper.sh index 691a1e7628..38cff1ba95 100755 --- a/tasks/scripts/test-snap-gateway-wrapper.sh +++ b/tasks/scripts/test-snap-gateway-wrapper.sh @@ -220,4 +220,31 @@ cp "$canonical/marker" "$work/marker-before" assert_preflight_failure nonregular cmp -s "$work/marker-before" "$canonical/marker" +user_common="$work/user-common" +user_config="$user_common/.config/openshell/gateway.toml" +user_tls="$user_common/.local/state/openshell/tls" +user_db="sqlite:$user_common/gateway.db?mode=rwc" +mkdir -p "$(dirname "$user_config")" +printf 'user config\n' >"$user_config" +: >"$log" +env -u OPENSHELL_GATEWAY_CONFIG \ + SNAP="$snap" \ + SNAP_COMMON="$common" \ + OPENSHELL_SNAP_CONFIG_FILE="$user_config" \ + OPENSHELL_DB_URL="$user_db" \ + OPENSHELL_LOCAL_TLS_DIR="$user_tls" \ + FAKE_GATEWAY_LOG="$log" \ + "$wrapper" --trace +printf '%s\n' \ + "generate-certs --output-dir $user_tls --server-san host.openshell.internal" \ + "config preflight -- --config $user_config --trace" \ + "env:|$user_db|" \ + "--config $user_config --trace" \ + "env:|$user_db|" >"$expected" +if ! cmp -s "$expected" "$log"; then + echo "FAIL: user gateway path overrides were not applied" >&2 + diff -u "$expected" "$log" >&2 + exit 1 +fi + echo "Snap gateway wrapper tests passed" diff --git a/tasks/scripts/test-snap-install-hook.sh b/tasks/scripts/test-snap-install-hook.sh new file mode 100755 index 0000000000..2815f95e6c --- /dev/null +++ b/tasks/scripts/test-snap-install-hook.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +hook=${1:?Usage: test-snap-install-hook.sh } +work=$(mktemp -d "${TMPDIR:-/tmp}/openshell snap install hook.XXXXXX") +trap 'rm -rf "$work"' EXIT + +mkdir -p "$work/bin" +cat >"$work/bin/snapctl" <>"$work/snapctl.log" +EOF +chmod 755 "$work/bin/snapctl" + +PATH="$work/bin:$PATH" SNAP_INSTANCE_NAME=openshell "$hook" +if [[ $(cat "$work/snapctl.log") != 'set gateway-mode=user' ]]; then + echo "FAIL: install hook did not initialize user gateway mode" >&2 + cat "$work/snapctl.log" >&2 + exit 1 +fi + +echo "Snap install hook tests passed" diff --git a/tasks/scripts/test-snap-post-refresh-hook.sh b/tasks/scripts/test-snap-post-refresh-hook.sh index 1f97ac2598..b8036fb506 100755 --- a/tasks/scripts/test-snap-post-refresh-hook.sh +++ b/tasks/scripts/test-snap-post-refresh-hook.sh @@ -4,144 +4,125 @@ set -euo pipefail -hook_input=${1:?Usage: test-snap-post-refresh-hook.sh } -hook_dir=$(cd "$(dirname "$hook_input")" && pwd) -hook="${hook_dir}/$(basename "$hook_input")" +hook=${1:?Usage: test-snap-post-refresh-hook.sh } work=$(mktemp -d "${TMPDIR:-/tmp}/openshell snap post-refresh hook.XXXXXX") trap 'rm -rf "$work"' EXIT -mkdir -p "${work}/bin" -cat >"${work}/bin/snapctl" <>"${work}/snapctl.log" -EOF -chmod 755 "${work}/bin/snapctl" - run_hook() { local common=$1 - - PATH="${work}/bin:$PATH" SNAP_COMMON="$common" SNAP_INSTANCE_NAME=openshell "$hook" -} - -assert_no_restart() { - local name=$1 - local common=$2 - - rm -f "${work}/snapctl.log" - run_hook "$common" - if [[ -e "${work}/snapctl.log" ]]; then - echo "FAIL: post-refresh hook restarted the gateway for ${name}" >&2 - cat "${work}/snapctl.log" >&2 - exit 1 - fi -} - -assert_removed_and_restarted() { - local name=$1 - local common=$2 - - rm -f "${work}/snapctl.log" - run_hook "$common" - if [[ -e "$common/gateway.toml" ]] || [[ -L "$common/gateway.toml" ]]; then - echo "FAIL: post-refresh hook did not remove ${name}" >&2 - exit 1 - fi - if [[ $(cat "${work}/snapctl.log") != "restart openshell.gateway" ]]; then - echo "FAIL: post-refresh hook did not restart the gateway once for ${name}" >&2 - cat "${work}/snapctl.log" >&2 - exit 1 - fi + local mode=$2 + rm -f "$work/snapctl.log" + mkdir -p "$work/bin" + cat >"$work/bin/snapctl" <>'$work/snapctl.log' +EOF + chmod 755 "$work/bin/snapctl" + PATH="$work/bin:$PATH" SNAP_COMMON="$common" SNAP_INSTANCE_NAME=openshell "$hook" } -common="${work}/missing" +for mode in user system; do + common="$work/$mode" + mkdir -p "$common" + printf '%s\n' 'disable_tls = true' >"$common/gateway.toml" + cp "$common/gateway.toml" "$work/$mode-before" + run_hook "$common" "$mode" + cmp -s "$work/$mode-before" "$common/gateway.toml" + [[ ! -e "$work/snapctl.log" ]] +done + +common="$work/missing" mkdir -p "$common" -assert_no_restart "a missing config" "$common" -if [[ -e "$common/gateway.toml" ]] || [[ -L "$common/gateway.toml" ]]; then - echo "FAIL: post-refresh hook created a missing config" >&2 - exit 1 -fi +run_hook "$common" "" +expected='set gateway-mode=system' +[[ $(cat "$work/snapctl.log") == "$expected" ]] -common="${work}/secure" +common="$work/secure" mkdir -p "$common" cat >"$common/gateway.toml" <<'EOF' [openshell] version = 2 - [openshell.gateway] -compute_driver = "docker" disable_tls = false - [openshell.gateway.auth] allow_unauthenticated_users = false -# allow_unauthenticated_users = true -EOF -cp "$common/gateway.toml" "${work}/secure-before" -assert_no_restart "a secure config" "$common" -cmp -s "${work}/secure-before" "$common/gateway.toml" - -common="${work}/unauthenticated" -mkdir -p "$common" -cat >"$common/gateway.toml" <<'EOF' -[openshell.gateway.auth] -allow_unauthenticated_users = true EOF -assert_removed_and_restarted "an unauthenticated config" "$common" - -common="${work}/tls-disabled" +cp "$common/gateway.toml" "$work/secure-before" +run_hook "$common" "" +cmp -s "$work/secure-before" "$common/gateway.toml" +[[ $(cat "$work/snapctl.log") == "$expected" ]] + +for setting in 'allow_unauthenticated_users = true' 'disable_tls = true # legacy'; do + common="$work/unsafe-${setting%% *}" + mkdir -p "$common" + printf '%s\n' "$setting" >"$common/gateway.toml" + run_hook "$common" "" + [[ ! -e "$common/gateway.toml" ]] + expected='set gateway-mode=system' + [[ $(cat "$work/snapctl.log") == "$expected" ]] +done + +common="$work/symlink" mkdir -p "$common" -cat >"$common/gateway.toml" <<'EOF' -[openshell.gateway] -disable_tls = true # old local override - -# operator note -EOF -assert_removed_and_restarted "an edited TLS-disabled config" "$common" - -common="${work}/broken-link" +printf '%s\n' 'disable_tls = true' >"$work/linked-config.toml" +ln -s "$work/linked-config.toml" "$common/gateway.toml" +run_hook "$common" "" +[[ ! -e "$common/gateway.toml" && ! -L "$common/gateway.toml" ]] +[[ -f "$work/linked-config.toml" ]] +expected='set gateway-mode=system' +[[ $(cat "$work/snapctl.log") == "$expected" ]] + +common="$work/broken-symlink" mkdir -p "$common" -ln -s "${work}/missing-target" "$common/gateway.toml" -assert_no_restart "a broken operator symlink" "$common" -if [[ $(readlink "$common/gateway.toml") != "${work}/missing-target" ]]; then - echo "FAIL: post-refresh hook replaced a broken operator symlink" >&2 - exit 1 -fi +ln -s "$work/missing-target" "$common/gateway.toml" +run_hook "$common" "" +[[ -L "$common/gateway.toml" ]] +[[ $(cat "$work/snapctl.log") == "$expected" ]] -common="${work}/existing-link" +common="$work/unknown" mkdir -p "$common" -printf '%s\n' 'disable_tls = true' >"${work}/linked-config.toml" -ln -s "${work}/linked-config.toml" "$common/gateway.toml" -assert_no_restart "an existing operator symlink" "$common" -if [[ $(readlink "$common/gateway.toml") != "${work}/linked-config.toml" ]]; then - echo "FAIL: post-refresh hook replaced an existing operator symlink" >&2 +if run_hook "$common" invalid >"$work/out" 2>"$work/err"; then + echo "FAIL: post-refresh hook accepted unknown service mode" >&2 exit 1 fi +grep -Fq 'unsupported gateway-mode: invalid' "$work/err" +[[ ! -e "$work/snapctl.log" ]] -common="${work}/directory" -mkdir -p "$common/gateway.toml" -assert_no_restart "an operator-owned directory" "$common" -if [[ ! -d "$common/gateway.toml" ]]; then - echo "FAIL: post-refresh hook replaced an operator-owned directory" >&2 +common="$work/get-failure" +mkdir -p "$common" "$work/get-failure-bin" +cat >"$work/get-failure-bin/snapctl" <<'EOF' +#!/bin/sh +exit 1 +EOF +chmod 755 "$work/get-failure-bin/snapctl" +if PATH="$work/get-failure-bin:$PATH" SNAP_COMMON="$common" \ + SNAP_INSTANCE_NAME=openshell "$hook"; then + echo "FAIL: post-refresh hook treated snapctl get failure as a missing mode" >&2 exit 1 fi -common="${work}/remove-failure" -mkdir -p "$common" "${work}/failing-bin" +common="$work/remove-failure" +mkdir -p "$common" "$work/remove-failure-bin" printf '%s\n' 'disable_tls = true' >"$common/gateway.toml" -cat >"${work}/failing-bin/rm" <<'EOF' +cat >"$work/remove-failure-bin/snapctl" <>'$work/remove-failure.log' +EOF +cat >"$work/remove-failure-bin/rm" <<'EOF' #!/bin/sh exit 1 EOF -chmod 755 "${work}/failing-bin/rm" -rm -f "${work}/snapctl.log" -if PATH="${work}/failing-bin:${work}/bin:$PATH" SNAP_COMMON="$common" \ +chmod 755 "$work/remove-failure-bin/snapctl" "$work/remove-failure-bin/rm" +if PATH="$work/remove-failure-bin:$PATH" SNAP_COMMON="$common" \ SNAP_INSTANCE_NAME=openshell "$hook"; then - echo "FAIL: post-refresh hook succeeded when config removal failed" >&2 - exit 1 -fi -if [[ -e "${work}/snapctl.log" ]]; then - echo "FAIL: post-refresh hook restarted after config removal failed" >&2 - cat "${work}/snapctl.log" >&2 + echo "FAIL: post-refresh hook ignored config removal failure" >&2 exit 1 fi +[[ ! -e "$work/remove-failure.log" ]] echo "Snap post-refresh hook tests passed" From 911a096a00bf3f77b0135f8ec440a7009b6ae381 Mon Sep 17 00:00:00 2001 From: Oliver Calder Date: Thu, 1 Oct 2026 18:46:17 -0500 Subject: [PATCH 2/2] fix(snap): simplify the snap gateway wrapper Signed-off-by: Oliver Calder --- docs/about/installation.mdx | 2 +- python/openshell/release_formula_test.py | 38 +-- snapcraft.yaml | 11 +- tasks/scripts/snap-gateway-wrapper.sh | 85 ++----- tasks/scripts/test-packaging-assets.sh | 21 +- tasks/scripts/test-snap-gateway-wrapper.sh | 260 ++++++--------------- 6 files changed, 114 insertions(+), 303 deletions(-) diff --git a/docs/about/installation.mdx b/docs/about/installation.mdx index 17a8b934c4..40b6c25cf3 100644 --- a/docs/about/installation.mdx +++ b/docs/about/installation.mdx @@ -117,7 +117,7 @@ The snap installs the standalone policy prover as `openshell.prover`. The The prover reads local policy files through the `home` interface and does not connect to the gateway. -The gateway runs at `https://127.0.0.1:17670`. It reads `~/snap/openshell/common/.config/openshell/gateway.toml` when that file exists and stores its database and TLS material under `~/snap/openshell/common`. Register it from the same user account: +The gateway runs at `https://127.0.0.1:17670`. It reads `~/snap/openshell/common/.config/openshell/gateway.toml` when that file exists. Its database defaults to `~/snap/openshell/common/.local/state/openshell/gateway/openshell.db`, and its TLS material lives under `~/snap/openshell/common/.local/state/openshell/tls`. Register it from the same user account: ```shell snap services openshell.user-gateway diff --git a/python/openshell/release_formula_test.py b/python/openshell/release_formula_test.py index d29d91ac08..3c34956d07 100644 --- a/python/openshell/release_formula_test.py +++ b/python/openshell/release_formula_test.py @@ -151,23 +151,12 @@ def test_snap_wrapper_uses_optional_gateway_config_without_generating_toml() -> ) assert "init-gateway-config.sh" not in wrapper - assert ( - 'CANONICAL_CONFIG_FILE="${OPENSHELL_SNAP_CONFIG_FILE:-${SNAP_COMMON}/gateway.toml}"' - in wrapper - ) - assert ( - 'export OPENSHELL_DB_URL="${OPENSHELL_DB_URL:-sqlite:${SNAP_COMMON}/gateway.db?mode=rwc}"' - in wrapper - ) + assert "CANONICAL_CONFIG_FILE" not in wrapper + assert '[ -z "${OPENSHELL_GATEWAY_CONFIG:-}" ]' in wrapper + assert 'export OPENSHELL_GATEWAY_CONFIG="$OPENSHELL_SNAP_CONFIG_FILE"' in wrapper + assert "export OPENSHELL_DB_URL=" not 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 - ) + assert 'export OPENSHELL_LOCAL_TLS_DIR="${XDG_STATE_HOME}/openshell/tls"' in wrapper assert 'exec "${SNAP}/bin/openshell-gateway" "$@"' in wrapper @@ -253,17 +242,8 @@ def test_schema_v2_debian_and_snap_preflight_wiring() -> None: assert "ExecStart=/usr/bin/openshell-gateway" in unit assert "$src_dir/openshell-gateway.service" in package_deb assert "$pkgroot/usr/lib/systemd/user/openshell-gateway.service" in package_deb - assert 'if [ -n "${OPENSHELL_GATEWAY_CONFIG:-}" ]; then' in wrapper - assert ( - 'elif [ -e "$CANONICAL_CONFIG_FILE" ] || [ -L "$CANONICAL_CONFIG_FILE" ]; then' - in wrapper - ) - assert wrapper.count('"${SNAP}/bin/openshell-gateway" config preflight') == 4 + assert '[ -z "${OPENSHELL_GATEWAY_CONFIG:-}" ]' in wrapper + assert 'export OPENSHELL_GATEWAY_CONFIG="$OPENSHELL_SNAP_CONFIG_FILE"' in wrapper + assert wrapper.count('"${SNAP}/bin/openshell-gateway" config preflight') == 1 assert 'config preflight -- "$@"' in wrapper - assert 'config preflight -- --config "$CANONICAL_CONFIG_FILE" "$@"' in wrapper - assert ( - 'exec "${SNAP}/bin/openshell-gateway" --config "$CANONICAL_CONFIG_FILE" "$@"' - in wrapper - ) - assert wrapper.count('exec "${SNAP}/bin/openshell-gateway" "$@"') == 3 - assert '[ -f "$CANONICAL_CONFIG_FILE" ]' not in wrapper + assert wrapper.count('exec "${SNAP}/bin/openshell-gateway" "$@"') == 1 diff --git a/snapcraft.yaml b/snapcraft.yaml index 19286b29bf..93c6f32bd7 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -127,7 +127,9 @@ apps: # OPENSHELL_GATEWAY_CONFIG takes precedence over gateway.toml. environment: XDG_DATA_HOME: "$SNAP_COMMON" - XDG_RUNTIME_DIR: "$SNAP_COMMON" + OPENSHELL_SNAP_CONFIG_FILE: "$SNAP_COMMON/gateway.toml" + OPENSHELL_DB_URL: "sqlite:$SNAP_COMMON/gateway.db?mode=rwc" + OPENSHELL_LOCAL_TLS_DIR: "$SNAP_COMMON/tls" plugs: - docker - log-observe @@ -140,15 +142,12 @@ apps: daemon-scope: user install-mode: disable refresh-mode: endure - # The shared wrapper uses these user-owned paths instead of its legacy - # $SNAP_COMMON defaults. The CLI uses the same XDG config and state roots. + # Use persistent user-owned XDG roots shared with the Snap CLI and TUI. + # Snapd provides the ephemeral XDG_RUNTIME_DIR automatically. environment: XDG_CONFIG_HOME: "$SNAP_USER_COMMON/.config" XDG_DATA_HOME: "$SNAP_USER_COMMON/.local/share" XDG_STATE_HOME: "$SNAP_USER_COMMON/.local/state" - OPENSHELL_SNAP_CONFIG_FILE: "$SNAP_USER_COMMON/.config/openshell/gateway.toml" - OPENSHELL_DB_URL: "sqlite:$SNAP_USER_COMMON/gateway.db?mode=rwc" - OPENSHELL_LOCAL_TLS_DIR: "$SNAP_USER_COMMON/.local/state/openshell/tls" plugs: - docker - log-observe diff --git a/tasks/scripts/snap-gateway-wrapper.sh b/tasks/scripts/snap-gateway-wrapper.sh index a5df1eb02a..38ac1f6a9c 100755 --- a/tasks/scripts/snap-gateway-wrapper.sh +++ b/tasks/scripts/snap-gateway-wrapper.sh @@ -2,69 +2,28 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Snap wrapper for openshell-gateway. Sets snap-specific defaults: -# - OPENSHELL_SNAP_CONFIG_FILE -> $SNAP_COMMON/gateway.toml (overridable) -# - 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. +# Snap wrapper for openshell-gateway. The system service supplies explicit +# compatibility paths; the user service uses OpenShell's XDG defaults. set -eu -CANONICAL_CONFIG_FILE="${OPENSHELL_SNAP_CONFIG_FILE:-${SNAP_COMMON}/gateway.toml}" -export OPENSHELL_DB_URL="${OPENSHELL_DB_URL:-sqlite:${SNAP_COMMON}/gateway.db?mode=rwc}" -export OPENSHELL_LOCAL_TLS_DIR="${OPENSHELL_LOCAL_TLS_DIR:-${SNAP_COMMON}/tls}" +if [ -z "${OPENSHELL_GATEWAY_CONFIG:-}" ] \ + && [ -n "${OPENSHELL_SNAP_CONFIG_FILE:-}" ] \ + && { [ -e "$OPENSHELL_SNAP_CONFIG_FILE" ] || [ -L "$OPENSHELL_SNAP_CONFIG_FILE" ]; } +then + export OPENSHELL_GATEWAY_CONFIG="$OPENSHELL_SNAP_CONFIG_FILE" +fi -# Mirror clap's CLI-over-environment precedence so preflight always inspects -# the same file the daemon will load. Reject ambiguous duplicate selectors -# before either command runs. -cli_config="" -config_seen=false -expect_config_path=false -options_done=false -for argument in "$@"; do - if [ "$options_done" = true ]; then - continue - fi - if [ "$expect_config_path" = true ]; then - case "$argument" in - -*) - echo "openshell-gateway: --config requires a nonempty path" >&2 - exit 2 - ;; - esac - if [ "$config_seen" = true ]; then - echo "openshell-gateway: duplicate --config option" >&2 - exit 2 - fi - cli_config=$argument - config_seen=true - expect_config_path=false - continue +if [ -z "${OPENSHELL_LOCAL_TLS_DIR:-}" ]; then + if [ -z "${XDG_STATE_HOME:-}" ]; then + echo "openshell-gateway: OPENSHELL_LOCAL_TLS_DIR or XDG_STATE_HOME is required" >&2 + exit 1 fi - case "$argument" in - --) - options_done=true - ;; - --config) - expect_config_path=true - ;; - --config=*) - if [ "$config_seen" = true ]; then - echo "openshell-gateway: duplicate --config option" >&2 - exit 2 - fi - cli_config=${argument#--config=} - config_seen=true - ;; - esac -done -if [ "$expect_config_path" = true ] || { [ "$config_seen" = true ] && [ -z "$cli_config" ]; }; then - echo "openshell-gateway: --config requires a nonempty path" >&2 - exit 2 + export OPENSHELL_LOCAL_TLS_DIR="${XDG_STATE_HOME}/openshell/tls" fi +"${SNAP}/bin/openshell-gateway" config preflight -- "$@" + # Generate the local TLS bundle and the JWT bundle used for launch-scoped # supervisor credentials; generate-certs is idempotent and preserves an # existing bundle. @@ -72,16 +31,4 @@ fi --output-dir "$OPENSHELL_LOCAL_TLS_DIR" \ --server-san host.openshell.internal -if [ "$config_seen" = true ]; then - "${SNAP}/bin/openshell-gateway" config preflight -- "$@" - exec "${SNAP}/bin/openshell-gateway" "$@" -elif [ -n "${OPENSHELL_GATEWAY_CONFIG:-}" ]; then - "${SNAP}/bin/openshell-gateway" config preflight -- "$@" - exec "${SNAP}/bin/openshell-gateway" "$@" -elif [ -e "$CANONICAL_CONFIG_FILE" ] || [ -L "$CANONICAL_CONFIG_FILE" ]; then - "${SNAP}/bin/openshell-gateway" config preflight -- --config "$CANONICAL_CONFIG_FILE" "$@" - exec "${SNAP}/bin/openshell-gateway" --config "$CANONICAL_CONFIG_FILE" "$@" -else - "${SNAP}/bin/openshell-gateway" config preflight -- "$@" - exec "${SNAP}/bin/openshell-gateway" "$@" -fi +exec "${SNAP}/bin/openshell-gateway" "$@" diff --git a/tasks/scripts/test-packaging-assets.sh b/tasks/scripts/test-packaging-assets.sh index 678480adb5..46bc5cfc3d 100755 --- a/tasks/scripts/test-packaging-assets.sh +++ b/tasks/scripts/test-packaging-assets.sh @@ -101,12 +101,11 @@ assert_file_exists "$package_deb" assert_contains "$service" "ExecStartPre=/usr/bin/openshell-gateway config preflight" assert_contains "$package_deb" "\$src_dir/openshell-gateway.service" assert_contains "$package_deb" "\$pkgroot/usr/lib/systemd/user/openshell-gateway.service" -assert_contains "$snap_wrapper" "if [ -n \"\${OPENSHELL_GATEWAY_CONFIG:-}\" ]; then" -assert_contains \ - "$snap_wrapper" \ - "elif [ -e \"\$CANONICAL_CONFIG_FILE\" ] || [ -L \"\$CANONICAL_CONFIG_FILE\" ]; then" -assert_contains "$snap_wrapper" "config preflight -- --config \"\$CANONICAL_CONFIG_FILE\" \"\$@\"" -assert_not_contains "$snap_wrapper" "[ -f \"\$CANONICAL_CONFIG_FILE\" ]" +assert_contains "$snap_wrapper" '[ -z "${OPENSHELL_GATEWAY_CONFIG:-}" ]' +assert_contains "$snap_wrapper" '[ -e "$OPENSHELL_SNAP_CONFIG_FILE" ] || [ -L "$OPENSHELL_SNAP_CONFIG_FILE" ]' +assert_contains "$snap_wrapper" 'export OPENSHELL_GATEWAY_CONFIG="$OPENSHELL_SNAP_CONFIG_FILE"' +assert_contains "$snap_wrapper" 'config preflight -- "$@"' +assert_not_contains "$snap_wrapper" "CANONICAL_CONFIG_FILE" bash "$ROOT/tasks/scripts/test-snap-gateway-wrapper.sh" "$snap_wrapper" # Store installs autoconnect all required interfaces and require snapd 2.76 for @@ -146,9 +145,13 @@ if grep -Eq '^ gateway:$' "$snapcraft"; then fi assert_contains "$snapcraft" ' system-gateway:' assert_contains "$snapcraft" ' user-gateway:' -assert_contains "$snapcraft" 'OPENSHELL_SNAP_CONFIG_FILE: "$SNAP_USER_COMMON/.config/openshell/gateway.toml"' -assert_contains "$snapcraft" 'OPENSHELL_DB_URL: "sqlite:$SNAP_USER_COMMON/gateway.db?mode=rwc"' -assert_contains "$snapcraft" 'OPENSHELL_LOCAL_TLS_DIR: "$SNAP_USER_COMMON/.local/state/openshell/tls"' +assert_contains "$snapcraft" 'OPENSHELL_SNAP_CONFIG_FILE: "$SNAP_COMMON/gateway.toml"' +assert_contains "$snapcraft" 'OPENSHELL_DB_URL: "sqlite:$SNAP_COMMON/gateway.db?mode=rwc"' +assert_contains "$snapcraft" 'OPENSHELL_LOCAL_TLS_DIR: "$SNAP_COMMON/tls"' +assert_not_contains "$snapcraft" 'XDG_RUNTIME_DIR:' +assert_not_contains "$snapcraft" 'OPENSHELL_SNAP_CONFIG_FILE: "$SNAP_USER_COMMON' +assert_not_contains "$snapcraft" 'OPENSHELL_DB_URL: "sqlite:$SNAP_USER_COMMON' +assert_not_contains "$snapcraft" 'OPENSHELL_LOCAL_TLS_DIR: "$SNAP_USER_COMMON' assert_contains "$snapcraft" 'refresh-mode: endure' if [[ ! -x "$snap_post_refresh_hook" ]]; then echo "FAIL: Snap post-refresh hook must be executable" >&2 diff --git a/tasks/scripts/test-snap-gateway-wrapper.sh b/tasks/scripts/test-snap-gateway-wrapper.sh index 38cff1ba95..c39c6e9bc8 100755 --- a/tasks/scripts/test-snap-gateway-wrapper.sh +++ b/tasks/scripts/test-snap-gateway-wrapper.sh @@ -4,9 +4,7 @@ set -euo pipefail -wrapper_input=${1:?Usage: test-snap-gateway-wrapper.sh } -wrapper_dir=$(cd "$(dirname "$wrapper_input")" && pwd) -wrapper="${wrapper_dir}/$(basename "$wrapper_input")" +wrapper=${1:?Usage: test-snap-gateway-wrapper.sh } work=$(mktemp -d "${TMPDIR:-/tmp}/openshell snap wrapper.XXXXXX") trap 'rm -rf "$work"' EXIT @@ -18,57 +16,30 @@ mkdir -p "$snap/bin" "$common" cat >"$snap/bin/openshell-gateway" <<'EOF' #!/bin/sh -printf '%s\n' "$*" >>"$FAKE_GATEWAY_LOG" -if [ "${1:-}" = generate-certs ]; then - exit 0 -fi -printf 'env:%s|%s|%s\n' \ +printf '%s|config=%s|db=%s|tls=%s\n' \ + "$*" \ "${OPENSHELL_GATEWAY_CONFIG:-}" \ "${OPENSHELL_DB_URL:-}" \ - "${OPENSHELL_DISABLE_TLS:-}" >>"$FAKE_GATEWAY_LOG" -if [ "${1:-}" = config ] && [ "${2:-}" = preflight ]; then - if [ "${FAKE_PREFLIGHT_FAIL:-}" = 1 ]; then - exit 42 - fi - if [ "${FAKE_REJECT_UNPAIRED_RATE:-}" = 1 ]; then - case " $* " in - *" --grpc-rate-limit-requests "*) - case " $* " in - *" --grpc-rate-limit-window-seconds "*) ;; - *) exit 43 ;; - esac - ;; - esac - fi + "${OPENSHELL_LOCAL_TLS_DIR:-}" >>"$FAKE_GATEWAY_LOG" +if [ "${1:-}:${2:-}" = config:preflight ] && [ "${FAKE_PREFLIGHT_FAIL:-}" = 1 ]; then + exit 42 fi EOF chmod +x "$snap/bin/openshell-gateway" -run_wrapper() { - local config=$1 - local fail=${2:-} - if [ "$config" = unset ]; then - env -u OPENSHELL_GATEWAY_CONFIG \ - SNAP="$snap" \ - SNAP_COMMON="$common" \ - FAKE_GATEWAY_LOG="$log" \ - FAKE_PREFLIGHT_FAIL="$fail" \ - "$wrapper" --trace - else - env \ - SNAP="$snap" \ - SNAP_COMMON="$common" \ - OPENSHELL_GATEWAY_CONFIG="$config" \ - FAKE_GATEWAY_LOG="$log" \ - FAKE_PREFLIGHT_FAIL="$fail" \ - "$wrapper" --trace - fi +run_system_wrapper() { + env -u OPENSHELL_GATEWAY_CONFIG \ + SNAP="$snap" \ + SNAP_COMMON="$common" \ + OPENSHELL_SNAP_CONFIG_FILE="$common/gateway.toml" \ + OPENSHELL_DB_URL="sqlite:$common/gateway.db?mode=rwc" \ + OPENSHELL_LOCAL_TLS_DIR="$common/tls" \ + FAKE_GATEWAY_LOG="$log" \ + "$wrapper" "$@" } assert_log() { - printf '%s\n' \ - "generate-certs --output-dir $common/tls --server-san host.openshell.internal" \ - "$1" >"$expected" + printf '%s\n' "$1" >"$expected" if ! cmp -s "$expected" "$log"; then echo "FAIL: unexpected call sequence" >&2 diff -u "$expected" "$log" >&2 @@ -76,175 +47,86 @@ assert_log() { fi } -override="$work/override.toml" -printf 'operator override\n' >"$override" -cp "$override" "$work/override-before" +# A missing compatibility config remains optional and OpenShell performs its +# normal config discovery. +: >"$log" +run_system_wrapper --trace +assert_log "config preflight -- --trace|config=|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls +generate-certs --output-dir $common/tls --server-san host.openshell.internal|config=|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls +--trace|config=|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls" + +# An existing compatibility config is exposed through the standard gateway +# config environment variable for both preflight and startup. +printf 'valid schema-v2\n' >"$common/gateway.toml" : >"$log" -run_wrapper "$override" -assert_log "config preflight -- --trace -env:$override|sqlite:$common/gateway.db?mode=rwc| ---trace -env:$override|sqlite:$common/gateway.db?mode=rwc|" -cmp -s "$work/override-before" "$override" +run_system_wrapper --trace +assert_log "config preflight -- --trace|config=$common/gateway.toml|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls +generate-certs --output-dir $common/tls --server-san host.openshell.internal|config=$common/gateway.toml|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls +--trace|config=$common/gateway.toml|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls" -cli_config="$work/cli.toml" -printf 'CLI override\n' >"$cli_config" -cp "$cli_config" "$work/cli-before" +# An operator-provided environment path takes precedence over the Snap +# compatibility path, while CLI arguments are replayed unchanged. +override="$work/override.toml" +printf 'operator override\n' >"$override" : >"$log" env \ SNAP="$snap" \ SNAP_COMMON="$common" \ + OPENSHELL_SNAP_CONFIG_FILE="$common/gateway.toml" \ OPENSHELL_GATEWAY_CONFIG="$override" \ + OPENSHELL_DB_URL="sqlite:$common/gateway.db?mode=rwc" \ + OPENSHELL_LOCAL_TLS_DIR="$common/tls" \ 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| ---trace --config $cli_config -env:$override|sqlite:$common/gateway.db?mode=rwc|" -cmp -s "$work/cli-before" "$cli_config" - + "$wrapper" --config "$work/cli.toml" --trace +assert_log "config preflight -- --config $work/cli.toml --trace|config=$override|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls +generate-certs --output-dir $common/tls --server-san host.openshell.internal|config=$override|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls +--config $work/cli.toml --trace|config=$override|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls" + +# Broken compatibility symlinks are selected so preflight fails closed rather +# than silently falling back to defaults. +rm "$common/gateway.toml" +ln -s "$work/missing.toml" "$common/gateway.toml" : >"$log" -if env \ - SNAP="$snap" \ - SNAP_COMMON="$common" \ - OPENSHELL_GATEWAY_CONFIG="$override" \ - FAKE_GATEWAY_LOG="$log" \ - FAKE_PREFLIGHT_FAIL=1 \ - "$wrapper" --config="$cli_config"; then - echo "FAIL: CLI-selected config preflight failure reached gateway start" >&2 +if FAKE_PREFLIGHT_FAIL=1 run_system_wrapper --trace; then + echo "FAIL: broken compatibility symlink reached certificate generation" >&2 exit 1 fi -assert_log "config preflight -- --config=$cli_config -env:$override|sqlite:$common/gateway.db?mode=rwc|" -cmp -s "$work/cli-before" "$cli_config" +assert_log "config preflight -- --trace|config=$common/gateway.toml|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls" +# Preflight failure prevents certificate generation and gateway startup. : >"$log" -if env \ - SNAP="$snap" \ - SNAP_COMMON="$common" \ - OPENSHELL_GATEWAY_CONFIG="$override" \ - FAKE_GATEWAY_LOG="$log" \ - FAKE_REJECT_UNPAIRED_RATE=1 \ - "$wrapper" --grpc-rate-limit-requests 10; then - echo "FAIL: invalid daemon overrides reached gateway start" >&2 +if FAKE_PREFLIGHT_FAIL=1 run_system_wrapper --config --; then + echo "FAIL: preflight failure reached certificate generation" >&2 exit 1 fi -assert_log "config preflight -- --grpc-rate-limit-requests 10 -env:$override|sqlite:$common/gateway.db?mode=rwc|" - -for invalid_selector in terminator nested-config; do - : >"$log" - if [ "$invalid_selector" = terminator ]; then - invalid_args=(--config --) - else - invalid_args=(--config "--config=$cli_config") - fi - if env \ - SNAP="$snap" \ - SNAP_COMMON="$common" \ - OPENSHELL_GATEWAY_CONFIG="$override" \ - FAKE_GATEWAY_LOG="$log" \ - "$wrapper" "${invalid_args[@]}"; then - echo "FAIL: invalid $invalid_selector selector reached gateway execution" >&2 - exit 1 - fi - if [ -s "$log" ]; then - echo "FAIL: invalid $invalid_selector selector reached preflight" >&2 - exit 1 - fi -done - -: >"$log" -env \ - SNAP="$snap" \ - SNAP_COMMON="$common" \ - OPENSHELL_GATEWAY_CONFIG="$override" \ - FAKE_GATEWAY_LOG="$log" \ - "$wrapper" --config=--dash-leading -assert_log "config preflight -- --config=--dash-leading -env:$override|sqlite:$common/gateway.db?mode=rwc| ---config=--dash-leading -env:$override|sqlite:$common/gateway.db?mode=rwc|" - -canonical="$common/gateway.toml" -printf 'valid schema-v2\n' >"$canonical" -cp "$canonical" "$work/canonical-before" -: >"$log" -run_wrapper unset -assert_log "config preflight -- --config $canonical --trace -env:|sqlite:$common/gateway.db?mode=rwc| ---config $canonical --trace -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| ---trace -env:|sqlite:$common/gateway.db?mode=rwc|" - -assert_preflight_failure() { - local name=$1 - : >"$log" - if run_wrapper unset 1; then - echo "FAIL: $name reached gateway start" >&2 - exit 1 - fi - assert_log "config preflight -- --config $canonical --trace -env:|sqlite:$common/gateway.db?mode=rwc|" -} - -printf 'legacy version = 1\n' >"$canonical" -cp "$canonical" "$work/legacy-before" -assert_preflight_failure legacy -cmp -s "$work/legacy-before" "$canonical" - -printf 'not valid TOML = [\n' >"$canonical" -cp "$canonical" "$work/malformed-before" -assert_preflight_failure malformed -cmp -s "$work/malformed-before" "$canonical" - -rm "$canonical" -ln -s "$work/missing-target" "$canonical" -readlink "$canonical" >"$work/link-before" -assert_preflight_failure broken-symlink -readlink "$canonical" >"$work/link-after" -cmp -s "$work/link-before" "$work/link-after" - -rm "$canonical" -mkdir "$canonical" -printf 'nonregular marker\n' >"$canonical/marker" -cp "$canonical/marker" "$work/marker-before" -assert_preflight_failure nonregular -cmp -s "$work/marker-before" "$canonical/marker" +assert_log "config preflight -- --config --|config=$common/gateway.toml|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls" +# User mode supplies only XDG roots. The wrapper derives TLS state, leaves the +# database unset for OpenShell's native default, and lets OpenShell discover +# the XDG config itself. user_common="$work/user-common" -user_config="$user_common/.config/openshell/gateway.toml" user_tls="$user_common/.local/state/openshell/tls" -user_db="sqlite:$user_common/gateway.db?mode=rwc" -mkdir -p "$(dirname "$user_config")" -printf 'user config\n' >"$user_config" : >"$log" env -u OPENSHELL_GATEWAY_CONFIG \ + -u OPENSHELL_SNAP_CONFIG_FILE \ + -u OPENSHELL_DB_URL \ + -u OPENSHELL_LOCAL_TLS_DIR \ SNAP="$snap" \ SNAP_COMMON="$common" \ - OPENSHELL_SNAP_CONFIG_FILE="$user_config" \ - OPENSHELL_DB_URL="$user_db" \ - OPENSHELL_LOCAL_TLS_DIR="$user_tls" \ + XDG_CONFIG_HOME="$user_common/.config" \ + XDG_STATE_HOME="$user_common/.local/state" \ FAKE_GATEWAY_LOG="$log" \ "$wrapper" --trace -printf '%s\n' \ - "generate-certs --output-dir $user_tls --server-san host.openshell.internal" \ - "config preflight -- --config $user_config --trace" \ - "env:|$user_db|" \ - "--config $user_config --trace" \ - "env:|$user_db|" >"$expected" -if ! cmp -s "$expected" "$log"; then - echo "FAIL: user gateway path overrides were not applied" >&2 - diff -u "$expected" "$log" >&2 +assert_log "config preflight -- --trace|config=|db=|tls=$user_tls +generate-certs --output-dir $user_tls --server-san host.openshell.internal|config=|db=|tls=$user_tls +--trace|config=|db=|tls=$user_tls" + +if env -u OPENSHELL_LOCAL_TLS_DIR -u XDG_STATE_HOME \ + SNAP="$snap" FAKE_GATEWAY_LOG="$log" "$wrapper" --trace \ + >"$work/out" 2>"$work/err"; then + echo "FAIL: wrapper accepted missing TLS roots" >&2 exit 1 fi +grep -Fq 'OPENSHELL_LOCAL_TLS_DIR or XDG_STATE_HOME is required' "$work/err" echo "Snap gateway wrapper tests passed"