Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/on-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions images/local/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 9 additions & 3 deletions images/local/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions images/local/goss.yaml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 9 additions & 7 deletions src/providers/docker/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -77,21 +78,22 @@ 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: "
f"{', '.join(sorted(reserved))}"
)

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,
}
Expand Down
5 changes: 4 additions & 1 deletion src/providers/docker/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
20 changes: 14 additions & 6 deletions src/providers/docker/tests/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()


Expand Down
Loading