CaRMen's deploys start in GitHub Actions and end on a server, and something has to cross that gap. Here it's an SSH key, stored as a secret in the repository, that the deploy job uses to connect to the server and run the deploy. A key like that is exactly the kind of secret that leaks: it sits in a CI system, it's used automatically, and nobody watches each use. So the key can't open a shell. It can run two commands, with strictly checked arguments, and nothing else.
The forced command
OpenSSH has this built in. In authorized_keys, a key can carry a command="..." option, and the sshd manual describes what it does: the command is executed whenever this key is used for authentication, and the command supplied by the client is ignored. Whatever the client asked to run is passed to the forced command in the SSH_ORIGINAL_COMMAND environment variable, so the forced command can decide what to do with it.
For CaRMen, the forced command is a small script, installed as /usr/local/sbin/carmen-ci-deploy. Its header says what the key can do:
deploy <crm|demo> <40-char commit SHA on origin/main>rollback <crm|demo>
Checking every argument
The script reads the requested command into four variables, and the fourth one exists only to catch extras:
read -r action instance sha extra <<< "${SSH_ORIGINAL_COMMAND:-}"
Then it refuses, with exit status 2 and a short reason, anything that isn't exactly right:
- more than three words
- an instance that isn't literally
crmordemo, checked with an anchored regular expression - for
deploy: a commit that isn't a full 40-character hexadecimal SHA, or one that isn't onmain - for
rollback: any third argument at all - any action other than those two
The "on main" check fetches main and runs git merge-base --is-ancestor, which exits 0 only if the given commit is an ancestor of the branch tip. So even with the key, you can't deploy a commit from some other branch or a commit that doesn't exist; it has to be something that went through the main branch, where the tests run before images are built.
Only after all of that does it check out the commit and hand over to the real deploy script with exec, so the forced command's process becomes the deploy rather than wrapping it.
Installed outside what it updates
One detail is easy to miss. The forced command is a copy of deploy/ci-deploy.sh from the repository, but it isn't run from the repository. It lives in /usr/local/sbin, outside the git checkout, because a deploy checks that checkout out at a different commit. The script that does the checking shouldn't be one of the files the checkout replaces. The flip side is a manual step: the deploy README says that after changing ci-deploy.sh, it has to be copied to /usr/local/sbin/ again.
The workflow side pins the server's host key too, from a second secret, so the job connects with strict host key checking and can't be pointed at a different machine without failing.
What a forced command doesn't cover
The same sshd manual entry has a warning worth reading twice: even with a forced command, the client may still ask for TCP or X11 forwarding unless that's explicitly prohibited, for example with the restrict option. restrict disables port, agent and X11 forwarding, PTY allocation and ~/.ssh/rc in one word, and the manual says future restrictions will be added to it too. The repository describes the forced command but not the full authorized_keys line, so whether the key also carries restrict is something to check on the server, not something I can show here. If you set up a key like this yourself, put restrict in front of command= from the start.
The shape to copy
A CI key that can only say "deploy this commit from main to this instance" or "roll back this instance", with forwarding switched off as well, turns a leaked secret from "someone has a shell on the server" into "someone can redeploy code we already shipped". Both commands go through the same deploy script, with its backups and health checks, as a deploy started by hand. That's the point of the design: the key adds a way to start the deploy, not a second, less careful way to do one.
Leave a comment
Comments are reviewed before they appear.