bin/deploy.sh does almost everything. Two things it deliberately will not
do, because both can lock you out of the machine: create the account, and
edit sshd_config. Those are below. Everything after them is one command.
cp canary.conf.example canary.conf
$EDITOR canary.confTwo choices worth thinking about before you continue:
The account name is part of the bait. It has to appear in whatever
~/.ssh/config or wrapper script leads an intruder to it, so it must fit the
cover story of the key you plan to plant — svc-backup, deploy,
gitlab-runner. Naming it canary gives the game away.
It must be an account nothing else ever uses. The watcher treats every
successful login as this account as a hit, and that is what makes it reliable.
Check the name is free, including distro system accounts — on Debian/Ubuntu,
backup is already taken:
getent passwd svc-backup; getent group svc-backup # both must be emptyOn the canary host, as root:
adduser --system --group --home /home/svc-backup --shell /bin/bash svc-backupThe shell must be a real shell.
/usr/sbin/nologinbreaks the canary completely. sshd runs a forcedcommand=through the user's login shell ($SHELL -c "/opt/canary/trigger.sh <label>"), so withnologinthe trigger never runs: nothing is logged, no mail is sent, and the client gets backThis account is currently not available.— which both loses the alert and tells the intruder something is odd.
/bin/bash sounds like a step backwards; it is not, because the protection
nologin was supposed to provide comes from elsewhere. Verify all three:
passwd -S svc-backup # L (locked) — no usable password
sshd -T | grep -i '^passwordauthentication' # no
sudo -l -U svc-backup # not allowed to run sudoadduser --system already leaves the password locked. With those three in
place the only way into the account is the key — and every key carries
restrict,command=. See design.md for the full argument.
The home directory and .ssh are taken over by root during deploy, so you do
not need to set permissions here.
restrict in authorized_keys already blocks port, agent and X11 forwarding.
This is a second, independent layer for the case where that file is ever
wrong. Append to the end of /etc/ssh/sshd_config:
Match User svc-backup
DisableForwarding yes
PermitTTY no
PermitTTY no means an interactive ssh <alias> prints
PTY allocation request failed on the client before it hangs. That is
intended, not a defect: it is what every restricted service account looks
like — deploy keys, git-shell, rsync-only accounts — so it fits the cover
story rather than breaking it. See design.md.
Three things about the rest:
DisableForwarding, not a list. It covers X11, agent, TCP, StreamLocal and tun in one directive, and keeps covering whatever OpenSSH adds later. Spelling the options out individually is how you end up withAllowStreamLocalForwardingleft at its defaultyes.- End of the main file, not
sshd_config.d/. AMatchblock runs until the nextMatchor end of file. SinceIncludesits near the top ofsshd_config, a block placed in an included file swallows every global directive that follows it. - No
ForceCommandhere. It looks like the obvious belt-and-braces move, butForceCommandinsshd_configoverrides the per-keycommand=including its argument, so every key would collapse to the same unlabeled trigger and you would lose the one thing the alert is for.
Then, keeping your current session open:
sshd -t && systemctl reload ssh
sshd -T -C user=svc-backup,host=localhost,addr=127.0.0.1 | grep -i forwardingdisableforwarding yes is the line that matters. The individual
allowtcpforwarding etc. still print yes — DisableForwarding overrides
them at runtime rather than rewriting the dumped config.
bin/new-key.sh testkey # a key you will not plant, for testing
bin/deploy.sh
bin/selftest.sh testkeyThat is it. deploy.sh is idempotent; re-run it after editing any script,
generating a key, or changing canary.conf.
For reference — you should not need to do any of this by hand.
| Path | Owner / mode | Contents |
|---|---|---|
/opt/canary/ |
root:root |
trigger.sh (755), canary_alert.py, canary_authwatch.py (644) |
/etc/canary/canary.env |
root:<account> 640 |
runtime config generated from canary.conf |
/var/lib/canary/ |
<account> 700 |
rate-limit stamps — the only path the account can write |
~<account>/, ~<account>/.ssh/ |
root:root 755 |
so the account cannot replace its own authorized_keys |
~<account>/.ssh/authorized_keys |
root:root 644 |
one restrict,command= line per key |
/etc/systemd/system/canary-authwatch.service |
root:root 644 |
the watcher |
crontab for <account> |
— | monthly alert-path selftest |
It then verifies: the account can load the alert path and read its config; it
cannot write trigger.sh, its own authorized_keys, or its own .ssh;
sshd -t passes; the watcher is running; the cron exists. Any failure stops
the deploy.
# every hit, including ones whose mail was rate-limited
ssh <host> 'journalctl -t canary-ssh -n 50 --no-pager'
# is the watcher alive?
ssh <host> 'systemctl status canary-authwatch'
# add a key for another plant location
bin/new-key.sh backup-share && bin/deploy.sh
# prove the whole chain still works
bin/selftest.shPut bin/selftest.sh on a calendar reminder. The monthly cron on the host
only proves that mail still goes out; it cannot notice a detector that has
stopped detecting.
ssh <host> 'systemctl disable --now canary-authwatch
rm -f /etc/systemd/system/canary-authwatch.service
systemctl daemon-reload
rm -rf /opt/canary /etc/canary /var/lib/canary
crontab -u svc-backup -r
userdel -r svc-backup'Then remove the Match User block from sshd_config, sshd -t, and reload.
Collect the planted keys, or leave them — without the account they are inert.