diff --git a/.github/workflows/on-pull-request.yml b/.github/workflows/on-pull-request.yml index e0bbf0e..02017ec 100644 --- a/.github/workflows/on-pull-request.yml +++ b/.github/workflows/on-pull-request.yml @@ -145,6 +145,7 @@ jobs: DEFAULT_HOST_PROVIDER: docker # The sandbox sends its HTTPS through a proxy on the Docker bridge; the API test only checks delivery. SECRETS_PROXY_URL: http://172.17.0.1:8880 + SECRETS_PROXY_CA_FILE: /secrets-proxy-ca.pem DOCKER_DEFAULT_IMAGE: drukbox/sandbox:ci UVICORN_HOST: 127.0.0.1 @@ -163,12 +164,14 @@ jobs: docker run --name drukbox-api-test --detach \ --network host \ --mount type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock \ + --mount type=bind,src=$PWD/env/test-proxy-ca.pem,dst=/secrets-proxy-ca.pem,readonly \ --group-add "$(stat -c '%g' /var/run/docker.sock)" \ --env DATABASE_URL \ --env SECRETS_KEY \ --env SERVICE_TOKENS \ --env DEFAULT_HOST_PROVIDER \ --env SECRETS_PROXY_URL \ + --env SECRETS_PROXY_CA_FILE \ --env DOCKER_DEFAULT_IMAGE \ --env UVICORN_HOST \ drukbox:api-test \ diff --git a/docs/add-a-provider.md b/docs/add-a-provider.md index b59716e..69f10f7 100644 --- a/docs/add-a-provider.md +++ b/docs/add-a-provider.md @@ -82,12 +82,15 @@ installs `--all-extras`, so the shipped image carries every provider. ## 6. Optional capabilities -Every provider gets the proxy in `secrets` by default. The sandbox gets a placeholder and the proxy address in `env`, like every -other variable, and the provider has one job: put `env` where every session -can read it. On Linux that is `/etc/environment`, which PAM reads at login. -`providers.environment.persist` writes it. The cloud providers get this from -`environment.cloud_init`. A provider with a secret store of its own, such as -docker-sbx, implements `SecretInjectionCapability` itself. +Every provider gets the proxy in `secrets` by default. The sandbox gets a +placeholder, the proxy address, and the proxy's CA in `env`, like every other +variable. The provider has two jobs: put `env` where every session can read it, +and install the CA. On Linux the first is `/etc/environment`, which PAM reads +at login, and `providers.environment.get_persist` writes it. +`providers.environment.get_install_ca` writes the second, with `sudo` when the script +runs as a user. The cloud providers get both from `environment.get_cloud_init`. A +provider with a secret store of its own, such as docker-sbx, implements +`SecretInjectionCapability` itself. Do not add provider-specific fields to the host schema. Add a capability instead. diff --git a/docs/architecture.md b/docs/architecture.md index 1c9cad9..107ed4f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -126,7 +126,8 @@ and the service, `drk...`. The entry keeps only a fingerprint of the random part. The sandbox receives the auth variable with the placeholder in its boot environment, next to the caller's `env`. On every provider but docker-sbx it also receives `HTTPS_PROXY`, `https_proxy`, and -`NO_PROXY`, so it sends its HTTPS through the proxy at `SECRETS_PROXY_URL`. +`NO_PROXY`, so it sends its HTTPS through the proxy at `SECRETS_PROXY_URL`, +and the proxy's CA in `SECRETS_PROXY_CA`, which it installs at boot. The proxy is the official mitmproxy image with the addon in `deploy/proxy`. It terminates TLS only for the hosts the exchange lists at `/upstreams`, the hosts with a registered secret, and tunnels every other host blind. For a diff --git a/docs/deploy.md b/docs/deploy.md index 0f842f1..1855df9 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -313,8 +313,25 @@ volumes: ``` The exchange binds `0.0.0.0` inside the compose network and publishes no -port, so only the proxy reaches it. The public certificate of the CA in the -volume is what a sandbox must trust for the hosts with a secret. Keep `flow_detail` at `1` or below. A +port, so only the proxy reaches it. The API reads the public certificate of +the CA from the same volume, at `SECRETS_PROXY_CA_FILE`, and hands it to +every sandbox with secrets: + +```yaml + api: + image: ghcr.io/czpython/drukbox:latest + env_file: drukbox.env + environment: + SECRETS_PROXY_URL: http://proxy.example:8880 + SECRETS_PROXY_CA_FILE: /secrets-proxy-ca/mitmproxy-ca-cert.pem + volumes: + - secrets-proxy-ca:/secrets-proxy-ca:ro +``` + +A sandbox with secrets gets the certificate in `SECRETS_PROXY_CA`, base64, +and installs it at boot with `update-ca-certificates`. `SSL_CERT_FILE`, +`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `NODE_EXTRA_CA_CERTS` point curl, +Python, and Node at it. Keep `flow_detail` at `1` or below. A higher level prints request headers, and after the swap those carry the real credential. Real credentials exist in three places only. They are encrypted in Postgres, they pass through the exchange process for one @@ -409,6 +426,7 @@ Secrets exchange: | Variable | Default | Purpose | | --- | --- | --- | | `SECRETS_PROXY_URL` | — | Proxy a sandbox sends its HTTPS through. Required to create a host with secrets on every provider but docker-sbx. | +| `SECRETS_PROXY_CA_FILE` | — | Path of the proxy's public CA certificate, from the proxy's volume. Required with `SECRETS_PROXY_URL`. | | `SECRETS_EXCHANGE_BIND_HOST` | `127.0.0.1` | Interface the exchange process binds. Bind it where only the proxy can reach it. | | `SECRETS_EXCHANGE_PORT` | `8781` | Port the exchange process listens on. | diff --git a/docs/security.md b/docs/security.md index 9c6d72f..4a33e71 100644 --- a/docs/security.md +++ b/docs/security.md @@ -113,9 +113,9 @@ header on the host the exchange approved. The proxy refuses a destination that resolves to a loopback, private, link-local, or metadata address, so a sandbox cannot reach the exchange or the API through it. Bind the exchange process where only the proxy can reach it, because its answer is the real -credential. The sandbox trusts the proxy's CA for the hosts with a secret, so -whoever holds the CA key can impersonate those hosts to the sandbox. The key -lives in the proxy's volume. +credential. A sandbox with secrets installs the proxy's CA at boot, so whoever +holds the CA key can impersonate any host to that sandbox. The key lives in +the proxy's volume. The API reads the public certificate only. Two pieces of material reach the VM through its provider's user-data / setup-script mechanism, and that channel is the relevant exposure: diff --git a/env/test-proxy-ca.pem b/env/test-proxy-ca.pem new file mode 100644 index 0000000..ad2297e --- /dev/null +++ b/env/test-proxy-ca.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBlDCCATugAwIBAgIUb9KZNOdLcC8Znj76t4PPmXdb/p4wCgYIKoZIzj0EAwIw +IDEeMBwGA1UEAwwVZHJ1a2JveCB0ZXN0IHByb3h5IENBMB4XDTI2MDkwNjE0MjQ1 +NFoXDTM2MDkwMzE0MjQ1NFowIDEeMBwGA1UEAwwVZHJ1a2JveCB0ZXN0IHByb3h5 +IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWW4OH6QzHfZC7kGytzZaLtK4 +TK8bwFaSGy3GO8K/1uMeIQrguXXeUrqHw61nybKeKAsR5j78hkapTZI6wL4mw6NT +MFEwHQYDVR0OBBYEFHvLb1C5RRD7Ruvyz/cH2JfHdBQjMB8GA1UdIwQYMBaAFHvL +b1C5RRD7Ruvyz/cH2JfHdBQjMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwID +RwAwRAIgWK/1s71gqFNXOh+jnhn769pnesgK7EnFL/5R0grBGrgCIAaQ5HCntmqG +fFZMJLq5HZtCnw/ri3nvlY1fweTudaRI +-----END CERTIFICATE----- diff --git a/env/test.env b/env/test.env index 3a905fb..584d8b0 100644 --- a/env/test.env +++ b/env/test.env @@ -9,3 +9,4 @@ TAILSCALE_OAUTH_CLIENT_ID=tailscale-client-id TAILSCALE_OAUTH_CLIENT_SECRET=tailscale-client-secret TAILSCALE_TAILNET=example.ts.net SECRETS_PROXY_URL=http://proxy.test:8880 +SECRETS_PROXY_CA_FILE=env/test-proxy-ca.pem diff --git a/images/local/entrypoint.sh b/images/local/entrypoint.sh index 546d544..972612c 100755 --- a/images/local/entrypoint.sh +++ b/images/local/entrypoint.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash -# First-boot entrypoint for the drukbox local sandbox container. Seeds the -# per-host public key, persists caller env for SSH sessions, then runs sshd. +# First boot: the host key, the caller env, then sshd. set -euo pipefail : "${DRUKBOX_AUTHORIZED_KEY:?DRUKBOX_AUTHORIZED_KEY is required}" @@ -9,12 +8,17 @@ install -d -m 700 /root/.ssh printf '%s\n' "$DRUKBOX_AUTHORIZED_KEY" > /root/.ssh/authorized_keys chmod 600 /root/.ssh/authorized_keys -# Persist the caller-supplied env vars so interactive SSH sessions see them: -# pam_env reads /etc/environment. Each name is also a real container env var. +# pam_env reads /etc/environment. for name in ${DRUKBOX_ENV_KEYS:-}; do printf '%s=%s\n' "$name" "${!name-}" >> /etc/environment done +# A box with secrets gets the proxy's CA in SECRETS_PROXY_CA. Install it first. +if [ -n "${SECRETS_PROXY_CA:-}" ]; then + printf '%s' "$SECRETS_PROXY_CA" | base64 -d > /usr/local/share/ca-certificates/drukbox.crt + update-ca-certificates >/dev/null +fi + # Generate host keys if the image doesn't ship any. ssh-keygen -A diff --git a/src/core/settings.py b/src/core/settings.py index 94c6fa7..001ebe3 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -56,6 +56,14 @@ class Settings(BaseSettings): "The proxy swaps the sandbox's placeholder for the real credential." ), ) + secrets_proxy_ca_file: str = Field( + default="", + validation_alias="SECRETS_PROXY_CA_FILE", + description=( + "Path of the proxy's public CA certificate, from the proxy's volume. " + "A sandbox with secrets installs it at boot." + ), + ) service_label: str = Field( default="drukbox", diff --git a/src/host_secrets/tests/test_api.py b/src/host_secrets/tests/test_api.py index 29c4f90..afd5408 100644 --- a/src/host_secrets/tests/test_api.py +++ b/src/host_secrets/tests/test_api.py @@ -94,8 +94,11 @@ async def test_a_secret_the_exchange_cannot_serve_is_refused( assert "one" not in response.text.replace("exactly one", "") -async def test_secrets_on_a_proxy_provider_need_the_proxy_address(client, monkeypatch) -> None: - monkeypatch.setattr(get_settings(), "secrets_proxy_url", "") +@pytest.mark.parametrize("setting", ["secrets_proxy_url", "secrets_proxy_ca_file"]) +async def test_secrets_on_a_proxy_provider_need_the_proxy_and_its_certificate( + client, monkeypatch, setting: str +) -> None: + monkeypatch.setattr(get_settings(), setting, "") response = await client.post( "/hosts", headers=AUTH_HEADERS, json={"secrets": {"anthropic": {"value": "sk-ant-real"}}} diff --git a/src/hosts/schemas.py b/src/hosts/schemas.py index 9b1396b..f242359 100644 --- a/src/hosts/schemas.py +++ b/src/hosts/schemas.py @@ -89,7 +89,7 @@ def reject_reserved_env_keys(cls, env: dict[str, str]) -> dict[str, str]: raise ValueError(f"reserved env keys are not allowed: {', '.join(reserved_keys)}") # Every provider persists env through /etc/environment. persist raises # for any entry pam_env would change or drop. - environment.persist(env) + environment.get_persist(env) return env _validate_expires_at = field_validator("expires_at")(_expires_at_must_be_future_and_tz_aware) diff --git a/src/hosts/service.py b/src/hosts/service.py index 46eff84..30602f0 100644 --- a/src/hosts/service.py +++ b/src/hosts/service.py @@ -28,6 +28,7 @@ Tailscale, ) from providers.base import VMProvider +from providers.environment import get_persist from providers.exceptions import ( ProviderCommandError, ProviderError, @@ -233,9 +234,11 @@ async def create_host( raise UnsupportedSizingError( f"provider {vm.name!r} does not support a per-request disk_gb" ) - if secrets and not vm.secrets.needs_value and not self.settings.secrets_proxy_url: + proxy = self.settings.secrets_proxy_url and self.settings.secrets_proxy_ca_file + if secrets and not vm.secrets.needs_value and not proxy: raise SecretsProxyNotConfiguredError( - "SECRETS_PROXY_URL must name the proxy that sandboxes dial" + "SECRETS_PROXY_URL and SECRETS_PROXY_CA_FILE must name the proxy that " + "sandboxes dial and its certificate" ) if template and not image: image = await self._resolve_template_image(template_id=template, provider=vm.name) @@ -506,7 +509,8 @@ async def provision(self, host_id: str) -> None: try: environment.update(await self.put_secrets(host, vm)) - except (IssuerError, ProviderError) as exc: + get_persist(environment) + except (IssuerError, ProviderError, ValueError) as exc: await self.mark_failed(host, exc) return diff --git a/src/hosts/tests/test_secrets.py b/src/hosts/tests/test_secrets.py index 3a7fc8f..b1fd7ce 100644 --- a/src/hosts/tests/test_secrets.py +++ b/src/hosts/tests/test_secrets.py @@ -52,6 +52,7 @@ async def delete_secret(self, *, vm: str, placeholder: Placeholder) -> None: def settings(monkeypatch: pytest.MonkeyPatch) -> Generator[Settings, None, None]: monkeypatch.setenv("TAILSCALE_ENABLED", "false") monkeypatch.setenv("SECRETS_PROXY_URL", "http://proxy.test:8880") + monkeypatch.setenv("SECRETS_PROXY_CA_FILE", "env/test-proxy-ca.pem") get_settings.cache_clear() yield get_settings() get_settings.cache_clear() @@ -126,6 +127,8 @@ async def test_a_proxy_box_boots_with_its_placeholders_and_the_proxy_and_no_valu assert environment["KEEP"] == "me" assert environment["HTTPS_PROXY"] == environment["https_proxy"] == "http://proxy.test:8880" assert environment["NO_PROXY"].startswith("localhost,") + assert environment["SECRETS_PROXY_CA"] + assert environment["NODE_EXTRA_CA_CERTS"] == "/usr/local/share/ca-certificates/drukbox.crt" for name, variable in (("anthropic", "ANTHROPIC_AUTH_TOKEN"), ("github", "GH_TOKEN")): placeholder = Placeholder.read(environment[variable]) assert (placeholder.host_id, placeholder.service) == (host.id, name) @@ -155,6 +158,29 @@ async def test_a_provider_that_holds_the_value_gets_it_at_boot( assert "HTTPS_PROXY" not in environment +class OversizedInjection(RecordingInjection): + """Hands the box a value that pam_env cannot read.""" + + async def put_secret( + self, *, vm: str, service: Service, placeholder: Placeholder, value: str + ) -> dict[str, str]: + return {service["auth_variable"]: "a" * 9000} + + +async def test_a_boot_environment_pam_cannot_read_fails_provisioning_before_the_vm( + settings: Settings, create_vm: AsyncMock, stub_provider: StubVMProvider +) -> None: + stub_provider.secrets = OversizedInjection() + + async with async_session_factory() as session: + with pytest.raises(ProvisioningFailedError, match="longer than 8191 bytes"): + await HostService(session, settings=settings).create_host( + env={}, secrets={"anthropic": {"value": "sk-ant-real"}}, image=None, provider="stub" + ) + + create_vm.assert_not_awaited() + + @respx.mock async def test_an_issuer_that_gives_no_value_at_boot_fails_provisioning( settings: Settings, create_vm: AsyncMock, stub_provider: StubVMProvider diff --git a/src/providers/aws/provider.py b/src/providers/aws/provider.py index 25a0eb2..74e0530 100644 --- a/src/providers/aws/provider.py +++ b/src/providers/aws/provider.py @@ -107,7 +107,7 @@ async def create_vm( await self.api.delete_key_pair(key_name) raise ProviderTransportError(str(exc)) from exc - user_data = environment.cloud_init(setup_script or "", env) + user_data = environment.get_cloud_init(setup_script or "", env) try: instance_id = await self.api.run_instance( client_token=name, diff --git a/src/providers/capabilities.py b/src/providers/capabilities.py index ab80c21..4ea864f 100644 --- a/src/providers/capabilities.py +++ b/src/providers/capabilities.py @@ -1,15 +1,23 @@ import abc +import base64 +import pathlib from typing import ClassVar, TypeVar +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding + from core.settings import get_settings from host_secrets.catalog import Service from host_secrets.placeholder import Placeholder -from providers.exceptions import CapabilityUnsupportedError +from providers import environment +from providers.exceptions import CapabilityUnsupportedError, ProviderCommandError CapabilityT = TypeVar("CapabilityT") # The box itself and the cloud metadata address. NO_PROXY = "localhost,127.0.0.1,::1,169.254.169.254" +# update-ca-certificates writes the system bundle here. +SYSTEM_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt" def resolve_capability(provider, capability: type[CapabilityT]) -> CapabilityT: @@ -42,7 +50,8 @@ async def delete_secret(self, *, vm: str, placeholder: Placeholder) -> None: ... class ProxyInjection(SecretInjectionCapability): - """The box sends its HTTPS through the proxy, which swaps the placeholder.""" + """The box sends its HTTPS through the proxy, which swaps the placeholder. + It gets the proxy's CA and the trust variables.""" needs_value = False @@ -54,17 +63,39 @@ async def put_secret( placeholder: Placeholder, value: str, ) -> dict[str, str]: - proxy = get_settings().secrets_proxy_url + settings = get_settings() return { service["auth_variable"]: str(placeholder), - "HTTPS_PROXY": proxy, - "https_proxy": proxy, + "HTTPS_PROXY": settings.secrets_proxy_url, + "https_proxy": settings.secrets_proxy_url, "NO_PROXY": NO_PROXY, + environment.PROXY_CA: base64.b64encode(self.get_public_certificate()).decode(), + "SSL_CERT_FILE": SYSTEM_CA_BUNDLE, + "REQUESTS_CA_BUNDLE": SYSTEM_CA_BUNDLE, + "CURL_CA_BUNDLE": SYSTEM_CA_BUNDLE, + "NODE_EXTRA_CA_CERTS": environment.PROXY_CA_PATH, } async def delete_secret(self, *, vm: str, placeholder: Placeholder) -> None: return + def get_public_certificate(self) -> bytes: + """mitmproxy writes the key and the certificate into one file and the + certificate alone into another. Only the second may reach a box.""" + path = get_settings().secrets_proxy_ca_file + try: + pem = pathlib.Path(path).read_bytes() + certificate = x509.load_pem_x509_certificate(pem) + except (OSError, ValueError) as exc: + raise ProviderCommandError( + f"SECRETS_PROXY_CA_FILE does not name a readable certificate: {exc}" + ) from exc + if b"PRIVATE KEY" in pem: + raise ProviderCommandError( + "SECRETS_PROXY_CA_FILE holds a private key. Name the public certificate only." + ) + return certificate.public_bytes(Encoding.PEM) + class TemplateCapability(abc.ABC): """An ABC, not a Protocol, so ``resolve_capability`` can test inheritance.""" diff --git a/src/providers/docker_sbx/provider.py b/src/providers/docker_sbx/provider.py index 7137ff4..b426f4e 100644 --- a/src/providers/docker_sbx/provider.py +++ b/src/providers/docker_sbx/provider.py @@ -34,7 +34,7 @@ def _bootstrap_script(*, public_key: str, env: dict[str, str], ssh_username: str f"chown {owner}:{owner} {home}/.ssh/authorized_keys", ] # The runtime takes no environment at create time. pam_env reads this file. - return "\n".join([*lines, *environment.persist(env)]) + "\n" + return "\n".join([*lines, *environment.get_persist(env)]) + "\n" class DockerSbxProvider(VMProvider, TemplateCapability): @@ -94,7 +94,7 @@ async def create_vm( caller_env = env or {} try: - environment.persist(caller_env) + environment.get_persist(caller_env) except ValueError as exc: raise ProviderCommandError(str(exc)) from exc diff --git a/src/providers/environment.py b/src/providers/environment.py index b792638..db3a827 100644 --- a/src/providers/environment.py +++ b/src/providers/environment.py @@ -15,9 +15,12 @@ # pam_env reads a line into a buffer of 8192 bytes. A `KEY=VALUE\n` line that # fills it ends the read, and every entry after it is lost too. LINE_LIMIT = 8191 +# The proxy's CA certificate, base64. The box installs it. +PROXY_CA = "SECRETS_PROXY_CA" +PROXY_CA_PATH = "/usr/local/share/ca-certificates/drukbox.crt" -def export(env: dict[str, str]) -> list[str]: +def get_export(env: dict[str, str]) -> list[str]: """Shell ``export`` lines, one per variable, for the running script.""" exports: list[str] = [] for key, value in env.items(): @@ -27,7 +30,7 @@ def export(env: dict[str, str]) -> list[str]: return exports -def persist(env: dict[str, str]) -> list[str]: +def get_persist(env: dict[str, str]) -> list[str]: """Shell lines that write ``env`` to /etc/environment, so every later session gets it from PAM. @@ -49,22 +52,41 @@ def persist(env: dict[str, str]) -> list[str]: return lines -def bashrc(env: dict[str, str]) -> str: +def get_bashrc(env: dict[str, str]) -> str: """Shell that puts the exports at the top of ~/.bashrc. Every bash session reads them.""" return "\n".join( [ "cat > ~/.bashrc.new <<'DRUKBOX_ENV'", - *export(env), + *get_export(env), "DRUKBOX_ENV", "cat ~/.bashrc >> ~/.bashrc.new && mv ~/.bashrc.new ~/.bashrc", ] ) -def cloud_init(setup_script: str, env: dict[str, str] | None) -> str: - """The user-data for a cloud VM: a shebang, ``env`` for the setup script - and for every later session, then the setup script.""" +def get_install_ca(env: dict[str, str], *, sudo: bool = False) -> list[str]: + """Lines after ``export``. A failed install ends the script: a box must not + come up without trust in the proxy.""" + privileged = "sudo -n " if sudo else "" + if PROXY_CA in env: + return [ + f"printf '%s' \"${PROXY_CA}\" | base64 -d | {privileged}tee {PROXY_CA_PATH} >/dev/null" + " || exit 1", + f"{privileged}update-ca-certificates >/dev/null || exit 1", + ] + return [] + + +def get_cloud_init(setup_script: str, env: dict[str, str] | None) -> str: + """A shebang, ``env`` for the script and every session, the proxy's CA, + then the script.""" script = setup_script if setup_script.startswith("#!") else f"#!/bin/sh\n{setup_script}" shebang, _, body = script.partition("\n") - lines = [shebang, *export(env or {}), *persist(env or {}), body] + lines = [ + shebang, + *get_export(env or {}), + *get_persist(env or {}), + *get_install_ca(env or {}), + body, + ] return "\n".join(line for line in lines if line) diff --git a/src/providers/exe/api.py b/src/providers/exe/api.py index 8358446..c43d537 100644 --- a/src/providers/exe/api.py +++ b/src/providers/exe/api.py @@ -21,6 +21,8 @@ # default 30s budget. Creation alone gets this read floor; a configured timeout # above it wins. _CREATE_VM_READ_TIMEOUT_FLOOR = 90.0 +# exe.dev takes a setup script of at most this many bytes. +SETUP_SCRIPT_LIMIT = 10 * 1024 def _encode_setup_script(script: str) -> str: @@ -93,10 +95,22 @@ async def create_vm( if env: # exe puts --env in /etc/profile.d. Only a login shell reads it. shebang, _, body = (setup_script or "#!/bin/bash").partition("\n") - parts = [shebang, *environment.export(env), environment.bashrc(env), body] + parts = [ + shebang, + *environment.get_export(env), + environment.get_bashrc(env), + *environment.get_install_ca(env, sudo=True), + body, + ] setup_script = "\n".join(part for part in parts if part) if setup_script: - command_parts.append(f"--setup-script={_encode_setup_script(setup_script)}") + encoded = _encode_setup_script(setup_script) + if len(encoded) > SETUP_SCRIPT_LIMIT: + raise ExeCommandError( + f"the setup script is {len(encoded)} bytes, over the exe.dev limit of " + f"{SETUP_SCRIPT_LIMIT}" + ) + command_parts.append(f"--setup-script={encoded}") if tags: for tag in tags: diff --git a/src/providers/exe/tests/test_api.py b/src/providers/exe/tests/test_api.py index 1b2c5fc..e26fd07 100644 --- a/src/providers/exe/tests/test_api.py +++ b/src/providers/exe/tests/test_api.py @@ -117,6 +117,34 @@ async def test_create_vm_sends_a_setup_script_for_env_alone(respx_mock): ] +@pytest.mark.asyncio +@respx.mock(base_url="https://exe.dev") +async def test_create_vm_installs_the_proxy_ca_with_sudo(respx_mock): + # The setup script runs as a user with passwordless sudo, not as root. + route = respx_mock.post("/exec").mock( + return_value=httpx.Response(200, content=b'{"vm_name": "sb-1", "ssh_port": 22}'), + ) + + await _api().create_vm(name="sb-1", image="ubuntu:22.04", env={"SECRETS_PROXY_CA": "Q0VSVA=="}) + + assert _setup_script(route.calls.last.request.content.decode())[-2:] == [ + "printf '%s' \"$SECRETS_PROXY_CA\" | base64 -d | sudo -n tee " + "/usr/local/share/ca-certificates/drukbox.crt >/dev/null || exit 1", + "sudo -n update-ca-certificates >/dev/null || exit 1", + ] + + +@pytest.mark.asyncio +@respx.mock(base_url="https://exe.dev", assert_all_called=False) +async def test_create_vm_refuses_a_setup_script_over_the_exe_limit(respx_mock): + route = respx_mock.post("/exec") + + with pytest.raises(ExeCommandError, match=r"over the exe\.dev limit"): + await _api().create_vm(name="sb-1", image="ubuntu:22.04", env={"BIG": "a" * 6000}) + + assert route.call_count == 0 + + def _setup_script(body: str) -> list[str]: """The setup script exe receives, as lines.""" quoted = body.split("--setup-script=", 1)[1].split(" --", 1)[0] @@ -125,7 +153,7 @@ def _setup_script(body: str) -> list[str]: @pytest.mark.asyncio async def test_create_vm_rejects_invalid_setup_env_name(): - # environment.export raises for a key that is not a shell identifier. + # environment.get_export raises for a key that is not a shell identifier. with pytest.raises(ValueError, match="invalid VM environment variable name"): await _api().create_vm( name="sb-1", diff --git a/src/providers/exoscale/provider.py b/src/providers/exoscale/provider.py index 05952cf..050775b 100644 --- a/src/providers/exoscale/provider.py +++ b/src/providers/exoscale/provider.py @@ -64,7 +64,7 @@ async def create_vm( except ExoscaleProviderError as exc: raise ProviderTransportError(str(exc)) from exc - user_data = environment.cloud_init(setup_script or "", env) + user_data = environment.get_cloud_init(setup_script or "", env) try: instance_id = await self.api.create_instance( name=name, diff --git a/src/providers/hetzner/provider.py b/src/providers/hetzner/provider.py index b5c5362..32962a1 100644 --- a/src/providers/hetzner/provider.py +++ b/src/providers/hetzner/provider.py @@ -70,7 +70,7 @@ async def create_vm( except HetznerProviderError as exc: raise ProviderTransportError(str(exc)) from exc - user_data = environment.cloud_init(setup_script or "", env) + user_data = environment.get_cloud_init(setup_script or "", env) try: server_id = await self.api.create_server( name=name, diff --git a/src/providers/tests/test_capabilities.py b/src/providers/tests/test_capabilities.py index 1bcf930..810f713 100644 --- a/src/providers/tests/test_capabilities.py +++ b/src/providers/tests/test_capabilities.py @@ -1,4 +1,6 @@ +import base64 import uuid +from pathlib import Path import pytest @@ -10,11 +12,14 @@ from providers.capabilities import ProxyInjection, TemplateCapability, resolve_capability from providers.docker.provider import DockerProvider from providers.docker_sbx.provider import DockerSbxProvider -from providers.exceptions import CapabilityUnsupportedError +from providers.environment import get_persist +from providers.exceptions import CapabilityUnsupportedError, ProviderCommandError from providers.exe.provider import ExeProvider from providers.exoscale.provider import ExoscaleProvider from providers.hetzner.provider import HetznerProvider +TEST_CERTIFICATE = Path(__file__).parents[3] / "env" / "test-proxy-ca.pem" + class StubProvider(VMProvider): """The smallest provider.""" @@ -75,10 +80,13 @@ def test_resolve_capability_refuses_provider_without_capability() -> None: resolve_capability(StubProvider(), TemplateCapability) -async def test_the_proxy_injection_hands_the_box_its_placeholder_and_the_proxy( - monkeypatch: pytest.MonkeyPatch, +async def test_the_proxy_injection_hands_the_box_its_placeholder_the_proxy_and_the_ca( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + certificate = tmp_path / "ca.pem" + certificate.write_bytes(TEST_CERTIFICATE.read_bytes()) monkeypatch.setattr(get_settings(), "secrets_proxy_url", "http://proxy.test:8880") + monkeypatch.setattr(get_settings(), "secrets_proxy_ca_file", str(certificate)) placeholder = Placeholder.mint(uuid.uuid4(), "anthropic") environment = await ProxyInjection().put_secret( @@ -90,7 +98,46 @@ async def test_the_proxy_injection_hands_the_box_its_placeholder_and_the_proxy( "HTTPS_PROXY": "http://proxy.test:8880", "https_proxy": "http://proxy.test:8880", "NO_PROXY": "localhost,127.0.0.1,::1,169.254.169.254", + "SECRETS_PROXY_CA": base64.b64encode(certificate.read_bytes()).decode(), + "SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt", + "REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt", + "CURL_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt", + "NODE_EXTRA_CA_CERTS": "/usr/local/share/ca-certificates/drukbox.crt", } + # Every value must survive pam_env, the base64 certificate included. + get_persist(environment) + + +@pytest.mark.parametrize( + ("content", "reason"), + [ + (b"", "does not name a readable certificate"), + (b"not a certificate", "does not name a readable certificate"), + ( + b"-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIA==\n-----END PRIVATE KEY-----\n", + "does not name a readable certificate", + ), + ( + b"-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIA==\n-----END PRIVATE KEY-----\n" + + TEST_CERTIFICATE.read_bytes(), + "holds a private key", + ), + ], +) +async def test_the_proxy_injection_hands_out_a_public_certificate_and_nothing_else( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, content: bytes, reason: str +) -> None: + path = tmp_path / "ca.pem" + path.write_bytes(content) + monkeypatch.setattr(get_settings(), "secrets_proxy_ca_file", str(path)) + + with pytest.raises(ProviderCommandError, match=reason): + await ProxyInjection().put_secret( + vm="sb-one", + service=CATALOG["anthropic"], + placeholder=Placeholder.mint(uuid.uuid4(), "anthropic"), + value="", + ) async def test_the_proxy_injection_holds_no_value_and_has_nothing_to_delete() -> None: diff --git a/src/providers/tests/test_environment.py b/src/providers/tests/test_environment.py index 80cee53..916525b 100644 --- a/src/providers/tests/test_environment.py +++ b/src/providers/tests/test_environment.py @@ -6,7 +6,7 @@ def test_export_quotes_each_value_for_the_shell(): - assert environment.export({"FOO": "bar", "QUOTE": "a b c", "EMPTY": ""}) == [ + assert environment.get_export({"FOO": "bar", "QUOTE": "a b c", "EMPTY": ""}) == [ "export FOO=bar", "export QUOTE='a b c'", "export EMPTY=''", @@ -15,7 +15,7 @@ def test_export_quotes_each_value_for_the_shell(): def test_export_rejects_invalid_env_names(): with pytest.raises(ValueError, match="invalid VM environment variable name"): - environment.export({"BAD-NAME": "v"}) + environment.get_export({"BAD-NAME": "v"}) @pytest.mark.parametrize( @@ -30,7 +30,7 @@ def test_export_rejects_invalid_env_names(): ], ) def test_persist_accepts_values_pam_reads_back_unchanged(value): - assert environment.persist({"KEY": value}) == [ + assert environment.get_persist({"KEY": value}) == [ f"printf '%s\\n' {shlex.quote(f'KEY={value}')} >> /etc/environment" ] @@ -40,20 +40,20 @@ def test_persist_accepts_values_pam_reads_back_unchanged(value): ) def test_persist_rejects_values_pam_would_change(value): with pytest.raises(ValueError, match=r"must be printable ASCII .*: KEY"): - environment.persist({"KEY": value}) + environment.get_persist({"KEY": value}) def test_persist_accepts_a_line_of_8191_bytes_and_rejects_8192(): longest = "a" * (8191 - len("KEY=\n")) - assert environment.persist({"KEY": longest}) == [ + assert environment.get_persist({"KEY": longest}) == [ f"printf '%s\\n' KEY={longest} >> /etc/environment" ] with pytest.raises(ValueError, match="longer than 8191 bytes: KEY"): - environment.persist({"KEY": longest + "a"}) + environment.get_persist({"KEY": longest + "a"}) def test_cloud_init_adds_a_shebang_exports_and_persists_the_env(): - out = environment.cloud_init("", env={"FOO": "bar", "URL": "http://x/y"}) + out = environment.get_cloud_init("", env={"FOO": "bar", "URL": "http://x/y"}) assert out.splitlines() == [ "#!/bin/sh", "export FOO=bar", @@ -64,10 +64,46 @@ def test_cloud_init_adds_a_shebang_exports_and_persists_the_env(): def test_cloud_init_keeps_the_setup_script_shebang_first(): - out = environment.cloud_init("#!/usr/bin/env bash\nset -e\necho ready\n", env={"FOO": "bar"}) + out = environment.get_cloud_init( + "#!/usr/bin/env bash\nset -e\necho ready\n", env={"FOO": "bar"} + ) assert out.startswith("#!/usr/bin/env bash\nexport FOO=bar\nprintf") assert out.endswith("/etc/environment\nset -e\necho ready\n") def test_cloud_init_without_env_is_the_setup_script(): - assert environment.cloud_init("#!/bin/sh\necho hi\n", env=None) == "#!/bin/sh\necho hi\n" + assert environment.get_cloud_init("#!/bin/sh\necho hi\n", env=None) == "#!/bin/sh\necho hi\n" + + +def test_trust_installs_the_proxy_ca_from_the_exported_variable_and_stops_on_failure(): + assert environment.get_install_ca({"SECRETS_PROXY_CA": "Q0VSVA==", "FOO": "bar"}) == [ + "printf '%s' \"$SECRETS_PROXY_CA\" | base64 -d | tee " + "/usr/local/share/ca-certificates/drukbox.crt >/dev/null || exit 1", + "update-ca-certificates >/dev/null || exit 1", + ] + + +def test_trust_uses_sudo_for_a_script_that_runs_as_a_user(): + assert environment.get_install_ca({"SECRETS_PROXY_CA": "Q0VSVA=="}, sudo=True) == [ + "printf '%s' \"$SECRETS_PROXY_CA\" | base64 -d | sudo -n tee " + "/usr/local/share/ca-certificates/drukbox.crt >/dev/null || exit 1", + "sudo -n update-ca-certificates >/dev/null || exit 1", + ] + + +def test_a_box_without_secrets_installs_nothing(): + assert environment.get_install_ca({"FOO": "bar"}) == [] + assert "ca-certificates" not in environment.get_cloud_init("echo hi", env={"FOO": "bar"}) + + +def test_cloud_init_installs_the_ca_after_the_env_and_before_the_script(): + out = environment.get_cloud_init("echo hi", env={"SECRETS_PROXY_CA": "Q0VSVA=="}) + assert out.splitlines() == [ + "#!/bin/sh", + "export SECRETS_PROXY_CA=Q0VSVA==", + "printf '%s\\n' SECRETS_PROXY_CA=Q0VSVA== >> /etc/environment", + "printf '%s' \"$SECRETS_PROXY_CA\" | base64 -d | tee " + "/usr/local/share/ca-certificates/drukbox.crt >/dev/null || exit 1", + "update-ca-certificates >/dev/null || exit 1", + "echo hi", + ] diff --git a/src/providers/tests/test_environment_pam.py b/src/providers/tests/test_environment_pam.py index 8a6cfae..04a0ca2 100644 --- a/src/providers/tests/test_environment_pam.py +++ b/src/providers/tests/test_environment_pam.py @@ -25,7 +25,7 @@ @pytest.mark.parametrize("user", ["root", "nobody"]) def test_a_session_sees_each_value_unchanged(user): read_back = " ; ".join(f"printenv {key}" for key in ENV) - script = "\n".join([*environment.persist(ENV), f"su -m {user} -s /bin/sh -c '{read_back}'"]) + script = "\n".join([*environment.get_persist(ENV), f"su -m {user} -s /bin/sh -c '{read_back}'"]) result = subprocess.run( ["docker", "run", "--rm", "debian:bookworm-slim", "sh", "-c", script], capture_output=True,