Merge pull request 'Check the guard can read its own key, and say so when it cannot' (#10) from key-readable into main

Reviewed-on: #10
Reviewed-by: branderson <brad@branderson.io>
This commit was merged in pull request #10.
This commit is contained in:
2026-08-28 20:48:13 -07:00
4 changed files with 121 additions and 3 deletions
+9 -2
View File
@@ -20,8 +20,15 @@ RUN apt-get update \
# A uid of its own. It needs no privilege: it opens outbound connections and
# writes one directory. Running as root would be granting root to a program
# holding an SSH key to another machine, for no reason at all.
RUN useradd --system --create-home --home-dir /var/lib/dejima-guard \
--shell /usr/sbin/nologin --uid 10001 guard
#
# 10001 IS PART OF THE INTERFACE, not an implementation detail, and the gid is
# pinned rather than left to whatever useradd picks. Compose bind-mounts a file
# secret with the host's ownership and mode, so the key has to be chown'd to
# this uid on the host - a number that moved between builds would break every
# deployment that had done so.
RUN groupadd --system --gid 10001 guard \
&& useradd --system --create-home --home-dir /var/lib/dejima-guard \
--shell /usr/sbin/nologin --uid 10001 --gid 10001 guard
COPY bin/dejima-guard bin/dejima-guard-loop bin/dejima-guard-install-key /usr/local/bin/
RUN chmod 0755 /usr/local/bin/dejima-guard \
+13
View File
@@ -144,8 +144,21 @@ compose will refuse to start with a secret it cannot find.
```sh
chmod 600 secrets/guard_key secrets/config
sudo chown 10001:10001 secrets/guard_key secrets/config
```
**The `chown` is not optional, and it is the first thing that goes wrong
without it.** The image runs as uid 10001 rather than root, and compose mounts
a file secret with the host's ownership and mode — so a key that is `0600` and
yours is a key the container cannot open. ssh reports that as
`Permission denied (publickey,password)`, which looks exactly like the devbox
rejecting the key, and sends you to the far end to debug a file on this one.
`dejima-guard` checks the key itself before connecting and says so plainly, but
the `chown` is what fixes it.
10001 is fixed in the `Dockerfile` and is part of the interface, so it is safe
to write down here.
**5. Start it, then prove the alert path works.**
```sh
+33
View File
@@ -250,6 +250,39 @@ require_config() {
case "$h" in
CHANGE-ME*) die "host is still the example placeholder ($h) - edit your config" ;;
esac
# The key is checked here, where the answer is certain, rather than left to
# ssh - which reports a key it could not open as
# "Permission denied (publickey,password)", indistinguishable from a devbox
# that rejected it. That sends an operator to the far end to debug a file on
# this one.
#
# Containers make it the expected first failure rather than an unlikely one.
# This image runs as its own uid, compose bind-mounts a file secret with the
# host's ownership and mode, and a private key is 0600 to somebody else - so
# a correctly built deployment cannot read its own key until the ownership
# is made to match.
local k
k=$(cfg ssh_key)
if [ -n "$k" ]; then
[ -e "$k" ] || die "ssh_key $k does not exist"
if [ ! -r "$k" ]; then
say "$PROG: cannot read ssh_key $k"
say " this runs as uid $(id -u); the file is uid $(stat -c %u "$k" 2>/dev/null || echo '?')," \
"mode $(stat -c %a "$k" 2>/dev/null || echo '?')"
say ""
# Phrased around the uid this is actually running as, which is the
# right answer whether that is a container's own account or an
# operator's. Claiming "the image runs as" would be false half the
# time, in a message whose only job is to be believed.
say " The key has to be readable by the account this runs as. In a"
say " container that is not yours: compose mounts the secret with"
say " the host's ownership. On the host, beside your compose file:"
say ""
say " sudo chown $(id -u):$(id -g) secrets/guard_key secrets/config"
exit 2
fi
fi
local u
for u in ntfy_url heartbeat_url; do
[ -n "$(cfg "$u")" ] && check_url "$u" "$(cfg "$u")"
+66 -1
View File
@@ -321,7 +321,12 @@ printf '%s\n' "$*" >> "$DEJIMA_GUARD_STATE/ssh.log"
case "$*" in *"drift --state"*) echo "FILE aaa /etc/one" ;; esac
EOF
chmod +x "$STUB/ssh"
DEJIMA_GUARD_SSH_KEY=/var/lib/dejima-guard/id_ed25519 run_watch check
# A real file, because the key is checked for existence and readability
# before anything connects - an unreadable one is the expected first
# failure of a container deployment and is reported here rather than left
# to look like the devbox rejecting it.
: > "$BATS_TEST_TMPDIR/key"; chmod 600 "$BATS_TEST_TMPDIR/key"
DEJIMA_GUARD_SSH_KEY="$BATS_TEST_TMPDIR/key" run_watch check
grep -q 'IdentitiesOnly=yes' "$DEJIMA_GUARD_STATE/ssh.log"
}
@@ -569,3 +574,63 @@ keyline() { awk '{print $1, $2}' "$BATS_TEST_TMPDIR/hk_$1.pub"; }
[ "$status" -ne 0 ]
[ ! -s "$DEJIMA_GUARD_SSH_KNOWN_HOSTS" ]
}
# --- the key this machine holds ----------------------------------------------
#
# Containers make an unreadable key the expected first failure rather than an
# unlikely one: the image runs as its own uid, compose mounts a file secret with
# the host's ownership, and a private key is 0600 to somebody else. So a
# correctly built deployment cannot read its own key until the ownership is
# made to match, and the whole setup looks like the devbox refusing it.
@test "an unreadable key is reported here, not left to look like a rejection" {
# ssh calls it "Permission denied (publickey,password)", which is what a
# devbox that rejected the key says too - so the operator debugs the far
# end over a file on this one.
key="$BATS_TEST_TMPDIR/unreadable_key"
: > "$key"; chmod 000 "$key"
export DEJIMA_GUARD_SSH_KEY="$key"
stub_ssh "FILE /etc/passwd aaaa" 0
run_watch check
[ "$status" -ne 0 ]
[[ "$output" == *"cannot read ssh_key"* ]]
}
@test "an unreadable key names the chown that fixes it" {
key="$BATS_TEST_TMPDIR/unreadable_key"
: > "$key"; chmod 000 "$key"
export DEJIMA_GUARD_SSH_KEY="$key"
stub_ssh "" 0
run_watch check
[[ "$output" == *"chown"* ]]
}
@test "a key that does not exist is named, rather than reported as a devbox fault" {
export DEJIMA_GUARD_SSH_KEY="$BATS_TEST_TMPDIR/no-such-key"
stub_ssh "" 0
run_watch check
[ "$status" -ne 0 ]
[[ "$output" == *"does not exist"* ]]
}
@test "a bad key stops the cycle before it can alert about the devbox" {
# A file on this machine is not a finding about that one. Reporting it as
# one sends the operator to the wrong host, and the silence that follows is
# what the dead-man's switch is for.
key="$BATS_TEST_TMPDIR/unreadable_key"
: > "$key"; chmod 000 "$key"
export DEJIMA_GUARD_SSH_KEY="$key"
stub_ssh "" 0
run_watch check
[ "$(alerts)" -eq 0 ]
[ "$(heartbeats)" -eq 0 ]
}
@test "the uid the README tells you to chown to is the one the image runs as" {
# Two files, one number. A uid that moved between them would produce
# exactly the failure this documents, with the documentation insisting it
# had been dealt with.
uid=$(grep -oE '\-\-uid [0-9]+' "$REPO/Dockerfile" | awk '{print $2}')
[ -n "$uid" ]
grep -q "chown $uid:$uid" "$REPO/README.md"
}