diff --git a/deploy/production/README.md b/deploy/production/README.md index a068da4..59b797f 100644 --- a/deploy/production/README.md +++ b/deploy/production/README.md @@ -4,16 +4,19 @@ This directory is intentionally separate from the development/toolchain Compose files. It starts only the Linux server and nginx TLS terminator; Windows clients connect through nginx at `/v1/agent`. -Before the first start, create `server.toml` from the authoritative example: +Before the first start, create `server.toml` from the Compose-specific example: ```sh -cp ../../docs/examples/server.toml server.toml -chmod 0640 server.toml +cp server.toml.example server.toml +chmod 0644 server.toml ``` Set `RVBOX_SERVER_IMAGE` to an immutable image reference, plus absolute paths for `RVBOX_TLS_CERT` and `RVBOX_TLS_KEY`. The TLS key must be readable by Docker -but should remain inaccessible to ordinary host users. Validate before start: +but should remain inaccessible to ordinary host users. `server.toml` can be +kept outside this directory by setting `RVBOX_SERVER_CONFIG` to its absolute +path; this is useful for a controlled smoke run without changing deployment +files. Validate before start: ```sh docker compose -f compose.yaml config @@ -25,3 +28,24 @@ ownership required by the non-root server. `server-data` is the sole persistent data volume and must be backed up as a whole while the server is stopped; `server-run` contains only the ephemeral local control socket. Do not publish, proxy, or enable JSON-RPC except for intentional loopback debugging. + +`server.toml` contains configuration rather than credentials and must be +world-readable on the host (`0644`): a bind mount preserves host file ownership, +while the server intentionally runs as the fixed unprivileged container UID +`65532`. Keep TLS private keys outside `server.toml` and restrict the key file +separately. + +The provided Compose-specific example binds the private agent and observability +listeners to `0.0.0.0` *inside the Compose network*. This is required for nginx +to proxy them. It does not publish those ports to the host. + +Set `RVBOX_HTTPS_BIND` when a deployment must bind a particular host interface; +it defaults to `0.0.0.0`. The repeatable test-only smoke lane binds only +loopback, uses material beneath `.test-runs`, and can be run after building a +local runtime image: + +```sh +docker build -f deploy/Dockerfile.runtime -t rvbox-server:test . +scripts/test-production-compose run --run-id production-smoke +scripts/test-production-compose clean --run-id production-smoke --purge --yes +``` diff --git a/deploy/production/compose.yaml b/deploy/production/compose.yaml index 4e94a6e..70f0e00 100644 --- a/deploy/production/compose.yaml +++ b/deploy/production/compose.yaml @@ -10,10 +10,10 @@ services: image: "${RVBOX_SERVER_IMAGE:?set RVBOX_SERVER_IMAGE to a pinned rvbox-server image}" user: "0:0" entrypoint: ["/bin/sh", "-ec"] - command: >- - mkdir -p /var/lib/rvbox-server /run/rvbox && - chown 65532:65532 /var/lib/rvbox-server /run/rvbox && - chmod 0700 /var/lib/rvbox-server /run/rvbox + # Compose does not turn a scalar `command` into one shell script argument. + # Preserve this whole program as $0 for /bin/sh -c rather than passing + # `mkdir` followed by its words as separate shell positional arguments. + command: ["mkdir -p /var/lib/rvbox-server /run/rvbox && chown 65532:65532 /var/lib/rvbox-server /run/rvbox && chmod 0700 /var/lib/rvbox-server /run/rvbox"] read_only: true tmpfs: - /tmp:mode=1777,size=8m @@ -34,7 +34,7 @@ services: - /tmp:mode=1777,size=32m volumes: - type: bind - source: ./server.toml + source: ${RVBOX_SERVER_CONFIG:-./server.toml} target: /etc/rvbox/server.toml read_only: true - type: volume @@ -67,7 +67,7 @@ services: server: condition: service_healthy ports: - - "${RVBOX_HTTPS_PORT:-443}:443" + - "${RVBOX_HTTPS_BIND:-0.0.0.0}:${RVBOX_HTTPS_PORT:-443}:443" tmpfs: - /var/cache/nginx:uid=101,gid=101,mode=0755,size=16m - /var/run:uid=101,gid=101,mode=0755,size=4m @@ -87,7 +87,12 @@ services: security_opt: - no-new-privileges:true cap_drop: ["ALL"] - cap_add: ["NET_BIND_SERVICE"] + # The nginx master creates worker-owned temporary directories beneath its + # explicitly mounted tmpfs paths, then drops workers to UID/GID 101. These + # are the exact bootstrap capabilities required for that lifecycle, + # including Docker user-namespace-remapping hosts; it retains no network, + # process, mount, or broad administration capability. + cap_add: ["NET_BIND_SERVICE", "DAC_OVERRIDE", "CHOWN", "SETUID", "SETGID"] volumes: server-data: diff --git a/deploy/production/server.toml.example b/deploy/production/server.toml.example new file mode 100644 index 0000000..c694eca --- /dev/null +++ b/deploy/production/server.toml.example @@ -0,0 +1,21 @@ +# RVBox Linux-server Docker Compose configuration. +# Copy this file to server.toml before starting the production stack. +# +# The server is private to the Compose network. These two listeners deliberately +# bind all container interfaces so nginx can reach them; Compose publishes only +# nginx's TLS port to the host. + +[server] +data_dir = "/var/lib/rvbox-server" +agent_listen = "0.0.0.0:6899" +agent_path = "/v1/agent" +control_socket = "/run/rvbox/server.sock" + +[json_rpc] +# The unauthenticated compatibility adapter is intentionally disabled in the +# production stack. Use a separately scoped loopback debugging setup if needed. +enabled = false + +[observability] +# nginx exposes only /livez and /readyz, not the metrics listener itself. +listen = "0.0.0.0:6901" diff --git a/docs/implementation-plan.v1.md b/docs/implementation-plan.v1.md index 8206e75..cad15ad 100644 --- a/docs/implementation-plan.v1.md +++ b/docs/implementation-plan.v1.md @@ -189,6 +189,7 @@ scripts/test-integration [--suite NAME|all] [--run-id ID] [--resume] scripts/test-e2e [--scenario NAME|all] [--run-id ID] [--resume] scripts/test-env doctor|coverage|status|logs|collect|recover|reuse|stop|reset|purge|gc ... scripts/build build|verify|doctor|recover|clean (pinned toolchain; host-safe) +scripts/test-production-compose run|clean --run-id ID (loopback production-stack smoke) scripts/windows/build-test-bundle --run-id ID --config FILE [--ca FILE] scripts/windows/test-host status|prepare|stage|install|run|collect|stop|reset|recover ``` diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md index df165c0..2c1c0ad 100644 --- a/docs/operations-runbook.md +++ b/docs/operations-runbook.md @@ -6,7 +6,8 @@ describe a Unix-like client, which is outside v1. ## Deploy and verify Use the checked-in Compose deployment as the sole Linux-server runtime. Start -from the annotated [`server.toml`](examples/server.toml) and retain +from its Compose-specific +[`server.toml.example`](../deploy/production/server.toml.example) and retain `json_rpc.enabled = false` unless performing loopback-only debugging. For Compose, follow the setup in diff --git a/docs/testing.md b/docs/testing.md index 7daff71..85a03b9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -193,6 +193,18 @@ Interactive browser access is a deliberately temporary recovery path only. See self-signed HTTPS Guacamole lifecycle; it must be started only after the native fixture controller has prepared and leased the VM, and stopped before reset. +The Linux production Compose asset has a separate, loopback-only smoke lane. It +uses a disposable self-signed key only under the ignored `.test-runs` tree, +starts the real non-root server and nginx services, probes TLS liveness and +readiness, and invokes `rvc` through the private socket. It leaves compact logs +and output for inspection, while removing its exact Compose project by default: + +```sh +docker build -f deploy/Dockerfile.runtime -t rvbox-server:test . +scripts/test-production-compose run --run-id production-smoke +scripts/test-production-compose clean --run-id production-smoke --purge --yes +``` + The canonical headless VirtualBox/Guest Control adapter is `scripts/windows/test-host`. It is a POSIX controller script because the fixture's VirtualBox host is Arch Linux and has no PowerShell runtime. The diff --git a/scripts/test-production-compose b/scripts/test-production-compose new file mode 100755 index 0000000..6b7fa4d --- /dev/null +++ b/scripts/test-production-compose @@ -0,0 +1,137 @@ +#!/bin/sh +# Disposable production-Compose smoke test. All generated data stays beneath +# the ignored .test-runs//production-compose directory. +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +compose_file=$repo_root/deploy/production/compose.yaml + +usage() { + cat <<'EOF' +usage: scripts/test-production-compose run|clean --run-id ID [options] + +run options: + --port PORT loopback HTTPS port (default: 18443) + --keep retain the exact Compose project for inspection + +clean options: + --purge --yes also remove only .test-runs//production-compose + +The run requires a locally available RVBox runtime image. By default it uses +rvbox-server:test; set RVBOX_PRODUCTION_SMOKE_IMAGE to override it. It creates a +test-only self-signed localhost certificate, verifies TLS /livez and /readyz, +and invokes rvc through the server's private control socket. It never writes to +/tmp or to deployment configuration files. +EOF +} + +fail() { printf '%s\n' "test-production-compose: $*" >&2; exit 2; } + +safe_id() { + case $1 in [a-z0-9]* ) ;; *) fail "run ID must start with lowercase alphanumeric" ;; esac + case $1 in ''|*[!a-z0-9-]*|????????????????????????????????????????????????????????????????*) fail "run ID must match [a-z0-9][a-z0-9-]{0,63}" ;; esac +} + +action=${1-} +[ -n "$action" ] || { usage >&2; exit 2; } +shift +case $action in run|clean|--help|-h) ;; *) usage >&2; fail "unknown action $action" ;; esac +[ "$action" != --help ] && [ "$action" != -h ] || { usage; exit 0; } + +run_id= +port=${RVBOX_PRODUCTION_SMOKE_PORT:-18443} +keep=no +purge=no +yes=no +while [ "$#" -gt 0 ]; do + case $1 in + --run-id) [ "$#" -ge 2 ] || fail "--run-id needs a value"; run_id=$2; shift 2 ;; + --port) [ "$#" -ge 2 ] || fail "--port needs a value"; port=$2; shift 2 ;; + --keep) keep=yes; shift ;; + --purge) purge=yes; shift ;; + --yes) yes=yes; shift ;; + --help|-h) usage; exit 0 ;; + *) fail "unknown argument $1" ;; + esac +done +[ -n "$run_id" ] || fail "$action requires --run-id" +safe_id "$run_id" +case $port in ''|*[!0-9]*) fail "--port must be an integer" ;; esac +[ "$port" -ge 1024 ] && [ "$port" -le 65535 ] || fail "--port must be 1024..65535" +[ "$purge" = no ] || [ "$action" = clean ] || fail "--purge is only valid with clean" +[ "$yes" = no ] || [ "$action" = clean ] || fail "--yes is only valid with clean" +[ "$purge" = no ] || [ "$yes" = yes ] || fail "--purge requires --yes" + +image=${RVBOX_PRODUCTION_SMOKE_IMAGE:-rvbox-server:test} +project=rvbox-production-smoke-$run_id +run_root=$repo_root/.test-runs/$run_id/production-compose +config=$run_root/server.toml +cert=$run_root/server.pem +key=$run_root/server-key.pem + +compose() { + RVBOX_SERVER_IMAGE=$image RVBOX_SERVER_CONFIG=$config RVBOX_TLS_CERT=$cert \ + RVBOX_TLS_KEY=$key RVBOX_HTTPS_BIND=127.0.0.1 RVBOX_HTTPS_PORT=$port \ + docker compose -p "$project" -f "$compose_file" "$@" +} + +collect() { + [ -d "$run_root" ] || return 0 + compose logs --no-color --tail=500 >"$run_root/compose.log" 2>&1 || true +} + +down() { + compose down --volumes --remove-orphans || true +} + +clean() { + collect + down + if [ "$purge" = yes ]; then + [ -L "$run_root" ] && fail "refusing symlink run root $run_root" + rm -rf "$run_root" + # Remove the per-run parent only when production-compose was its only + # child; another harness lane may legitimately share the same run ID. + rmdir "$repo_root/.test-runs/$run_id" 2>/dev/null || true + fi +} + +case $action in + clean) + clean + printf 'cleaned run_id=%s\n' "$run_id" + ;; + run) + docker version >/dev/null 2>&1 || fail "Docker is unavailable" + docker compose version >/dev/null 2>&1 || fail "Docker Compose is unavailable" + command -v openssl >/dev/null 2>&1 || fail "openssl is required for the test-only certificate" + command -v curl >/dev/null 2>&1 || fail "curl is required for TLS probes" + docker image inspect "$image" >/dev/null 2>&1 || fail "runtime image $image is absent; build or set RVBOX_PRODUCTION_SMOKE_IMAGE" + [ ! -e "$run_root" ] || fail "refusing to overwrite existing run root $run_root; use clean --purge --yes" + umask 077 + mkdir -p "$run_root" + cp "$repo_root/deploy/production/server.toml.example" "$config" + # Docker user-namespace remapping may prevent a container from reading + # a host file with mode 0600. This is a disposable test-only key under + # .test-runs, never a production certificate. + chmod 0644 "$config" + openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 1 -subj /CN=localhost \ + -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' -keyout "$key" -out "$cert" >/dev/null 2>&1 + chmod 0644 "$key" "$cert" + trap 'status=$?; collect; if [ "$keep" = no ]; then down; fi; exit "$status"' EXIT INT TERM + compose up -d + attempt=0 + while [ "$attempt" -lt 40 ]; do + if curl --fail --silent --show-error --max-time 3 --cacert "$cert" "https://127.0.0.1:$port/livez" >/dev/null && \ + curl --fail --silent --show-error --max-time 3 --cacert "$cert" "https://127.0.0.1:$port/readyz" >/dev/null; then + break + fi + attempt=$((attempt + 1)) + sleep 1 + done + curl --fail --silent --show-error --max-time 3 --cacert "$cert" "https://127.0.0.1:$port/livez" >/dev/null + curl --fail --silent --show-error --max-time 3 --cacert "$cert" "https://127.0.0.1:$port/readyz" >/dev/null + compose exec -T server /usr/local/bin/rvc --socket /run/rvbox/server.sock stat >"$run_root/rvc-stat.txt" + printf 'production Compose smoke passed: run_id=%s artifacts=%s\n' "$run_id" "$run_root" + ;; +esac diff --git a/test/coverage.toml b/test/coverage.toml index 8861ceb..ca11153 100644 --- a/test/coverage.toml +++ b/test/coverage.toml @@ -599,6 +599,12 @@ layer = "unit" status = "implemented" tests = ["test/windowsnative/native_fixture_test.go:TestNativeFixtureAssets_HP_HARNESS_20"] +[[requirements]] +id = "HP-OPS-01" +layer = "unit" +status = "implemented" +tests = ["test/windowsnative/native_fixture_test.go:TestProductionComposeAssets_HP_OPS_01"] + [[requirements]] id = "HP-STORE-01" layer = "integration" diff --git a/test/windowsnative/native_fixture_test.go b/test/windowsnative/native_fixture_test.go index b8b0c9f..ba9bfd9 100644 --- a/test/windowsnative/native_fixture_test.go +++ b/test/windowsnative/native_fixture_test.go @@ -54,3 +54,43 @@ func TestNativeFixtureAssets_HP_HARNESS_20(t *testing.T) { t.Fatal("fixture-only context faults are not separated from release builds") } } + +// TestProductionComposeAssets_HP_OPS_01 guards the deployment properties that +// a syntax-only Compose check cannot prove: private server reachability through +// nginx, non-root state initialization, and a repository-local smoke cleanup. +func TestProductionComposeAssets_HP_OPS_01(t *testing.T) { + t.Parallel() + root := filepath.Clean(filepath.Join("..", "..")) + read := func(relative string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, relative)) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + return string(data) + } + compose := read("deploy/production/compose.yaml") + for _, required := range []string{ + "RVBOX_SERVER_CONFIG:-./server.toml", + "RVBOX_HTTPS_BIND:-0.0.0.0", + "condition: service_completed_successfully", + "condition: service_healthy", + "NET_BIND_SERVICE", "DAC_OVERRIDE", "CHOWN", "SETUID", "SETGID", + } { + if !strings.Contains(compose, required) { + t.Fatalf("production Compose is missing %q", required) + } + } + config := read("deploy/production/server.toml.example") + for _, required := range []string{"agent_listen = \"0.0.0.0:6899\"", "listen = \"0.0.0.0:6901\"", "enabled = false"} { + if !strings.Contains(config, required) { + t.Fatalf("production server example is missing %q", required) + } + } + runner := read("scripts/test-production-compose") + for _, required := range []string{".test-runs/$run_id/production-compose", "RVBOX_HTTPS_BIND=127.0.0.1", "curl --fail", "compose exec -T server", "clean --purge --yes"} { + if !strings.Contains(runner, required) { + t.Fatalf("production smoke runner is missing %q", required) + } + } +}