From 3a69124161e5acb83ae266d915a8aa69253d64ff Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 13 Nov 2026 09:22:31 +0800 Subject: [PATCH 1/3] feat(dev-box): serve sshd on port 22 The box listened on 1022 while the dstack 0.5.x dev image used 22, so the connection recipe differed from the one users already have. Port 22 also matches what the gateway maps for `-22.`, making gateway access the plain case again. Reflected in the README connect section; no other 1022 references were in this example. --- dev-box/README.md | 4 ++-- dev-box/sshd_config | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-box/README.md b/dev-box/README.md index 1c2ee1f..5678136 100644 --- a/dev-box/README.md +++ b/dev-box/README.md @@ -47,12 +47,12 @@ On a plain dstack deployment, set `SSH_AUTHORIZED_KEYS` as an encrypted environm ## Connect -`sshd` listens on port `1022`. Reach it through the dstack-gateway by adding this to `~/.ssh/config`: +`sshd` listens on port `22`, the same as the 0.5.x dev image. Reach it through the dstack-gateway by adding this to `~/.ssh/config`: ``` Host my-app User root - ProxyCommand openssl s_client -quiet -connect -1022.:443 -servername -1022. -verify_hostname -1022. -verify_return_error + ProxyCommand openssl s_client -quiet -connect -22.:443 -servername -22. -verify_hostname -22. -verify_return_error ``` Replace `` with your app ID and `` with the gateway base domain of the node the CVM runs on, for example `dstack-pha-prod15.phala.network`. Change `443` if your gateway uses a different port. diff --git a/dev-box/sshd_config b/dev-box/sshd_config index c3fcc55..decbbeb 100644 --- a/dev-box/sshd_config +++ b/dev-box/sshd_config @@ -1,4 +1,4 @@ -Port 1022 +Port 22 HostKey /etc/ssh/host-keys/ssh_host_ed25519_key AllowUsers root PermitRootLogin prohibit-password From 3c0ddd0270c3e4830f2f06b06c893b6a2b94eca5 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 13 Nov 2026 09:22:36 +0800 Subject: [PATCH 2/3] feat(dev-box): merge user_config SSH keys with the env keys Phala Cloud pushes workspace SSH keys into the guest's user_config, but the box only read SSH_AUTHORIZED_KEYS, so those keys never worked and the platform channel was dead weight. The entrypoint now merges both channels and de-duplicates: the env key stays valid even after a workspace key update, and a key present in both is listed once. The user_config read goes through /host/dstack/user_config, already reachable via the `/:host:rslave` mount; dstack exposes it as a relative symlink into .host-shared, so it resolves inside /host. Tolerance is deliberate: a missing file, malformed JSON, a missing field, a null, a non-string, or an empty value all mean "this channel has no keys", because an unreadable platform file must not keep an operator out. The non-zero exit stays reserved for losing both channels. jq streams, so `{"ssh_authorized_keys":"k"} garbage` prints the key and then fails; clearing the whole assignment on failure discards that partial key instead of trusting a config we just rejected. --- dev-box/docker-compose.yaml | 2 ++ dev-box/entrypoint.sh | 44 ++++++++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/dev-box/docker-compose.yaml b/dev-box/docker-compose.yaml index 6fe8ad3..dec1ae8 100644 --- a/dev-box/docker-compose.yaml +++ b/dev-box/docker-compose.yaml @@ -15,6 +15,8 @@ services: uts: host environment: # One or more public keys, newline-separated (authorized_keys format). + # Merged with the platform-managed keys in /host/dstack/user_config; + # drop this line to rely on those alone. - SSH_AUTHORIZED_KEYS=${SSH_AUTHORIZED_KEYS} volumes: - /:/host:rslave diff --git a/dev-box/entrypoint.sh b/dev-box/entrypoint.sh index f33061f..89ad589 100644 --- a/dev-box/entrypoint.sh +++ b/dev-box/entrypoint.sh @@ -1,12 +1,50 @@ #!/bin/sh +# Authorized keys arrive on two channels with different purposes: +# +# SSH_AUTHORIZED_KEYS key pasted at deploy time, one per line +# user_config workspace keys the platform pushes to the CVM before +# it boots; the host root is mounted at /host, so the +# guest's /dstack/user_config is /host/dstack/user_config +# +# They are merged, not overridden: a key set in either channel works, and a +# platform-side key update never drops the deploy-time key. Duplicates are +# removed. Each channel is optional; only losing both is fatal. set -eu -if [ -z "${SSH_AUTHORIZED_KEYS:-}" ]; then - echo "SSH_AUTHORIZED_KEYS is not set" >&2 + +# Overridable to exercise the merge logic outside a CVM. +USER_CONFIG=${USER_CONFIG:-/host/dstack/user_config} + +env_keys=${SSH_AUTHORIZED_KEYS:-} + +# A missing file or malformed JSON makes jq fail; `.ssh_authorized_keys // empty` +# turns a missing field and a null into no output, select() drops a non-string +# value, and an empty string comes out as an empty line that the blank-line +# filter below removes. Every path ends with this channel contributing no key. +# +# The assignment, not the jq call, is what swallows the failure: jq streams, so +# `{"ssh_authorized_keys":"k"} garbage` prints the key and then exits non-zero. +# Dropping output only inside the substitution would keep that partial key from +# a config we just decided not to trust, so a non-zero exit discards it all. +config_keys=$( + jq -r '.ssh_authorized_keys // empty | select(type == "string")' \ + "$USER_CONFIG" 2>/dev/null +) || config_keys= + +# Blank and whitespace-only lines would become empty authorized_keys entries. +keys=$( + printf '%s\n%s\n' "$env_keys" "$config_keys" | + sed -e 's/[[:space:]]*$//' -e '/^$/d' | sort -u +) + +if [ -z "$keys" ]; then + echo "no SSH public keys in SSH_AUTHORIZED_KEYS or ${USER_CONFIG}" >&2 exit 1 fi + install -d -m 700 /root/.ssh -printf '%s\n' "$SSH_AUTHORIZED_KEYS" > /root/.ssh/authorized_keys +printf '%s\n' "$keys" > /root/.ssh/authorized_keys chmod 600 /root/.ssh/authorized_keys +echo "authorized_keys: $(printf '%s\n' "$keys" | wc -l) key(s)" >&2 key=/etc/ssh/host-keys/ssh_host_ed25519_key [ -f "$key" ] || ssh-keygen -q -t ed25519 -N '' -f "$key" install -d -m 755 /run/sshd From 3ed62fb9ef347b871b7b4fd8f53501cdf59c1480 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 13 Nov 2026 09:22:43 +0800 Subject: [PATCH 3/3] docs(dev-box): document both SSH key channels and their trust boundary The README only described SSH_AUTHORIZED_KEYS, so a reader could not tell where workspace keys come from, that the two sources merge instead of override, or that a workspace key change only lands on the next VM start. Also states the consequence plainly: either channel is a path to CVM root, the box trusts every key the platform delivers, and there is no switch to ignore user_config today. Which roles may change those keys is platform policy, not something this example enforces. --- dev-box/README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/dev-box/README.md b/dev-box/README.md index 5678136..03d7d03 100644 --- a/dev-box/README.md +++ b/dev-box/README.md @@ -35,7 +35,18 @@ volumes: home: ``` -Deploy with your public key or keys in `SSH_AUTHORIZED_KEYS`, in `authorized_keys` format with one key per line. The container exits with an error if the variable is empty. +## SSH keys + +Two channels feed `/root/.ssh/authorized_keys`. They are merged and de-duplicated when the container starts, so a key in either channel gets you in, a key in both is listed once, and a key the platform manages never displaces the one you deployed with. + +| Channel | Source | Use it for | +|---------|--------|------------| +| `SSH_AUTHORIZED_KEYS` | Environment variable in the compose file | A key you paste at deploy time | +| `user_config` | The `ssh_authorized_keys` field of the `user_config` Phala Cloud pushes into the guest; the host root is mounted at `/host`, so it reads `/host/dstack/user_config` | Workspace keys the platform keeps in sync | + +Both channels grant root. Setting `SSH_AUTHORIZED_KEYS` and changing this app's workspace SSH keys both hand over the CVM, and the dev box trusts every key the platform delivers through `user_config`; there is no switch to ignore that channel today. Which workspace members and roles may change an app's SSH keys is decided by Phala Cloud's key management, not by this example. + +Deploy with your public key or keys in `SSH_AUTHORIZED_KEYS`, in `authorized_keys` format with one key per line: ```bash phala deploy -n my-app -c docker-compose.yaml \ @@ -43,7 +54,11 @@ phala deploy -n my-app -c docker-compose.yaml \ -e SSH_AUTHORIZED_KEYS="$(cat ~/.ssh/id_ed25519.pub)" ``` -On a plain dstack deployment, set `SSH_AUTHORIZED_KEYS` as an encrypted environment variable. +On a plain dstack deployment, set `SSH_AUTHORIZED_KEYS` as an encrypted environment variable. If your keys live in the Phala Cloud workspace instead, drop the `environment:` entry from the service: the dev box still picks them up from `user_config`. + +`user_config` is optional and read defensively: a missing file, invalid JSON, a missing field, or an empty value all mean "this channel has no keys". The container exits with an error only when both channels turn up empty. + +Keys added to your Phala Cloud workspace reach the guest's `user_config` only at CVM boot, so a workspace key change works on the next start of the VM, not immediately. ## Connect