From ceefca461ccf181ed181a5b09db94a8f54df4751 Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 22 Sep 2026 23:50:19 +0200 Subject: [PATCH] Seed the SSH user drukbox names in the local sandbox entrypoint --- .github/workflows/on-pull-request.yml | 19 +++++++++++++++++++ docs/deploy.md | 2 +- images/local/Dockerfile | 5 +++-- images/local/entrypoint.sh | 12 +++++++++--- images/local/goss.yaml | 16 ++++++++++++++++ src/providers/docker/provider.py | 16 +++++++++------- src/providers/docker/settings.py | 5 ++++- src/providers/docker/tests/test_provider.py | 20 ++++++++++++++------ 8 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 images/local/goss.yaml diff --git a/.github/workflows/on-pull-request.yml b/.github/workflows/on-pull-request.yml index a25431b..82e4e1b 100644 --- a/.github/workflows/on-pull-request.yml +++ b/.github/workflows/on-pull-request.yml @@ -136,6 +136,25 @@ jobs: - name: Build proxy image run: docker build -t drukbox-proxy:validate deploy/proxy + - name: Build sandbox image + run: docker build -t drukbox/sandbox:validate images/local/ + + # dgoss boots the image the way the docker provider does and checks + # images/local/goss.yaml inside it. + - name: Validate the sandbox image + env: + GOSS_VERSION: 0.4.10 + GOSS_FILES_PATH: images/local + GOSS_OPTS: -r 30s -s 1s --format documentation + run: | + curl -fsSL "https://github.com/goss-org/goss/releases/download/v$GOSS_VERSION/goss_${GOSS_VERSION}_linux_x86_64.tar.gz" \ + | tar -xz -C "$RUNNER_TEMP" goss + curl -fsSL -o "$RUNNER_TEMP/dgoss" "https://github.com/goss-org/goss/releases/download/v$GOSS_VERSION/dgoss" + chmod +x "$RUNNER_TEMP/dgoss" + GOSS_PATH="$RUNNER_TEMP/goss" "$RUNNER_TEMP/dgoss" run \ + -e DRUKBOX_SSH_USER=ubuntu -e 'DRUKBOX_AUTHORIZED_KEY=ssh-ed25519 AAAA goss' \ + drukbox/sandbox:validate + api-tests: name: Run API Tests (docker provider) runs-on: ubuntu-latest diff --git a/docs/deploy.md b/docs/deploy.md index 5b4fccd..a89a11f 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -568,7 +568,7 @@ Docker provider: | --- | --- | --- | | `DOCKER_DEFAULT_IMAGE` | `ghcr.io/czpython/drukbox/sandbox:latest` | Sandbox image with sshd, git, and gh; auto-pulled. Build `images/local/Dockerfile` to customize. | | `DOCKER_SSH_HOST` | `127.0.0.1` | Daemon host address where Docker publishes sshd and callers dial it. | -| `DOCKER_SSH_USERNAME` | `root` | In-container user callers SSH as. | +| `DOCKER_SSH_USERNAME` | `root` | In-container user callers SSH as. The entrypoint seeds its `authorized_keys`; a derived image adds the user. | | `DOCKER_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `30.0` | ssh-keyscan retry budget for a fresh container. | The published image includes the Docker CLI. Mount the local daemon socket diff --git a/images/local/Dockerfile b/images/local/Dockerfile index 559147c..0f5a959 100644 --- a/images/local/Dockerfile +++ b/images/local/Dockerfile @@ -3,8 +3,9 @@ # # docker build -t drukbox/sandbox:latest images/local/ # -# The entrypoint seeds authorized_keys from $DRUKBOX_AUTHORIZED_KEY (injected by -# drukbox per host) and runs sshd in the foreground. Key-only auth; no password. +# The entrypoint seeds authorized_keys for $DRUKBOX_SSH_USER (root by default) +# from $DRUKBOX_AUTHORIZED_KEY, both injected by drukbox per host, and runs sshd +# in the foreground. Key-only auth; no password. FROM ubuntu:24.04 # gh comes from GitHub's own apt repository, as its install guide says. diff --git a/images/local/entrypoint.sh b/images/local/entrypoint.sh index 385565a..f10215e 100755 --- a/images/local/entrypoint.sh +++ b/images/local/entrypoint.sh @@ -4,9 +4,15 @@ set -euo pipefail : "${DRUKBOX_AUTHORIZED_KEY:?DRUKBOX_AUTHORIZED_KEY is required}" -install -d -m 700 /root/.ssh -printf '%s\n' "$DRUKBOX_AUTHORIZED_KEY" > /root/.ssh/authorized_keys -chmod 600 /root/.ssh/authorized_keys +# drukbox names the user callers SSH as. The stock image has root only; a +# derived image adds its own user and sets DOCKER_SSH_USERNAME to it. +user="${DRUKBOX_SSH_USER:-root}" +home="$(getent passwd "$user" | cut -d: -f6)" \ + || { echo "DRUKBOX_SSH_USER names no user in the image: $user" >&2; exit 1; } +install -d -m 700 "$home/.ssh" +printf '%s\n' "$DRUKBOX_AUTHORIZED_KEY" > "$home/.ssh/authorized_keys" +chmod 600 "$home/.ssh/authorized_keys" +chown -R "$user:" "$home/.ssh" # pam_env reads /etc/environment. for name in ${DRUKBOX_ENV_KEYS:-}; do diff --git a/images/local/goss.yaml b/images/local/goss.yaml new file mode 100644 index 0000000..3f2fb03 --- /dev/null +++ b/images/local/goss.yaml @@ -0,0 +1,16 @@ +# The boot contract of the image, checked by dgoss in CI with +# DRUKBOX_SSH_USER=ubuntu and DRUKBOX_AUTHORIZED_KEY="ssh-ed25519 AAAA goss". +file: + /home/ubuntu/.ssh/authorized_keys: + exists: true + mode: "0600" + owner: ubuntu + group: ubuntu + contents: + - ssh-ed25519 AAAA goss +process: + sshd: + running: true +port: + tcp:22: + listening: true diff --git a/src/providers/docker/provider.py b/src/providers/docker/provider.py index 3ae7903..498c406 100644 --- a/src/providers/docker/provider.py +++ b/src/providers/docker/provider.py @@ -17,8 +17,9 @@ from .settings import DockerSettings _AUTHORIZED_KEY_ENV = "DRUKBOX_AUTHORIZED_KEY" +_SSH_USER_ENV = "DRUKBOX_SSH_USER" _ENV_KEYS_ENV = "DRUKBOX_ENV_KEYS" -_RESERVED_ENV_KEYS = frozenset({_AUTHORIZED_KEY_ENV, _ENV_KEYS_ENV}) +_RESERVED_ENV_KEYS = frozenset({_AUTHORIZED_KEY_ENV, _SSH_USER_ENV, _ENV_KEYS_ENV}) class DockerProvider(VMProvider, TemplateCapability): @@ -77,10 +78,10 @@ async def create_vm( ) caller_env = env or {} - # These names carry the per-VM public key and the env-key manifest the - # entrypoint reads; a caller-supplied value would clobber the generated - # key (locking the caller out) or rewrite the manifest. Reject rather - # than let `**caller_env` silently win. + # These names carry the per-VM public key, the SSH user, and the env-key + # manifest the entrypoint reads; a caller-supplied value would clobber + # the generated key (locking the caller out), seed another user, or + # rewrite the manifest. Reject rather than let `**caller_env` silently win. if reserved := _RESERVED_ENV_KEYS.intersection(caller_env): raise ProviderCommandError( f"env keys reserved by the docker provider are not allowed: " @@ -88,10 +89,11 @@ async def create_vm( ) private_key, public_key = generate_ed25519_keypair() - # The sandbox entrypoint seeds authorized_keys from the public key and - # persists the named caller vars into the container's session env. + # The sandbox entrypoint seeds the SSH user's authorized_keys from the + # public key and persists the named caller vars into the session env. container_env = { _AUTHORIZED_KEY_ENV: public_key, + _SSH_USER_ENV: self.settings.ssh_username, _ENV_KEYS_ENV: " ".join(caller_env), **caller_env, } diff --git a/src/providers/docker/settings.py b/src/providers/docker/settings.py index 35d6ce2..0813426 100644 --- a/src/providers/docker/settings.py +++ b/src/providers/docker/settings.py @@ -20,7 +20,10 @@ class DockerSettings(BaseSettings): ) ssh_username: str = Field( default="root", - description="In-container user callers SSH as. The sandbox image runs sshd for root.", + description=( + "In-container user callers SSH as. The entrypoint seeds its authorized_keys; " + "the stock image has root only." + ), ) ssh_host: IPv4Address | IPv6Address = Field( default=IPv4Address("127.0.0.1"), diff --git a/src/providers/docker/tests/test_provider.py b/src/providers/docker/tests/test_provider.py index 4bfa159..97d9e70 100644 --- a/src/providers/docker/tests/test_provider.py +++ b/src/providers/docker/tests/test_provider.py @@ -74,6 +74,17 @@ async def test_create_vm_passes_caller_env_and_names_it_for_the_entrypoint(): assert container_env["DRUKBOX_ENV_KEYS"] == "FOO" +@pytest.mark.asyncio +async def test_create_vm_names_the_ssh_user_for_the_entrypoint(): + api = _api_mock() + provider = DockerProvider(api, _settings(ssh_username="druks")) + + result = await provider.create_vm(name="sb-test", image="img", env={}) + + assert api.run_container.await_args.kwargs["env"]["DRUKBOX_SSH_USER"] == "druks" + assert result.ssh_username == "druks" + + @pytest.mark.asyncio async def test_create_vm_rejects_setup_script_because_tailscale_is_unsupported(): api = _api_mock() @@ -84,17 +95,14 @@ async def test_create_vm_rejects_setup_script_because_tailscale_is_unsupported() api.run_container.assert_not_called() +@pytest.mark.parametrize("key", ["DRUKBOX_AUTHORIZED_KEY", "DRUKBOX_SSH_USER", "DRUKBOX_ENV_KEYS"]) @pytest.mark.asyncio -async def test_create_vm_rejects_caller_env_that_collides_with_reserved_keys(): +async def test_create_vm_rejects_caller_env_that_collides_with_reserved_keys(key: str): api = _api_mock() provider = DockerProvider(api, _settings()) with pytest.raises(ProviderCommandError, match="reserved"): - await provider.create_vm( - name="sb-test", - image="img", - env={"DRUKBOX_AUTHORIZED_KEY": "ssh-ed25519 attacker"}, - ) + await provider.create_vm(name="sb-test", image="img", env={key: "attacker"}) api.run_container.assert_not_called()