Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c61a28e86 | ||
|
|
ed2a843406 | ||
|
|
fe9db52f68 | ||
|
|
50f0f6980f | ||
|
|
674d77d788 | ||
|
|
aa4d7c9207 | ||
|
|
4b700a24cc | ||
|
|
051c096c0d | ||
|
|
f86a6b5c75 | ||
|
|
df590bf8c9 | ||
|
|
21ebd4709e | ||
|
|
aec0100ce6 | ||
|
|
e41cc06ecc | ||
|
|
551e814f81 | ||
|
|
74761e0376 | ||
|
|
a837dd7255 | ||
|
|
26d29e92f2 | ||
|
|
1012abe6fa | ||
|
|
cb84f51944 | ||
|
|
44d9a88fd1 | ||
|
|
c5a04ea89b | ||
|
|
b9221c4dba | ||
|
|
b2e578c7e1 | ||
|
|
40ebbecda4 | ||
|
|
03e5617d24 | ||
|
|
aed2a2b2fa | ||
|
|
d30a5b8d8e | ||
|
|
5f0e853eae | ||
|
|
9d7d82c6d2 | ||
|
|
d87e8d5e6d | ||
|
|
a21be2e4b4 | ||
|
|
5c3f9c7c2e | ||
|
|
55fd8d2562 | ||
|
|
9ea1fbaa85 | ||
|
|
179498ff0e | ||
|
|
95efc90497 | ||
|
|
f92ef81250 | ||
|
|
4ea3827188 | ||
|
|
01be9885ef | ||
|
|
969d50e373 |
+9
-2
@@ -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 \
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
<!-- Fixed, not preference-aware, and that is the decision rather than an
|
||||
oversight. `prefers-color-scheme` reports the browser's setting, never the
|
||||
site's, and this is read on a forge that renders dark whichever way the
|
||||
browser is set - so anything conditional shows dark ink on a dark page to
|
||||
a reader whose browser prefers light, and does it silently.
|
||||
The suffix names the mode a file is FOR, not the colour of its ink:
|
||||
-darkmode is the pale wordmark that belongs on a dark page. GitHub does
|
||||
follow its own theme setting through <picture>, so the GitHub mirror
|
||||
replaces the tag below with one that falls back to
|
||||
dejima-wordmark-lightmode.png; see dejima's mirror/github-mirror.sh.
|
||||
Every here is spacing, and it has to be a character rather than CSS
|
||||
or a presentation attribute. Gitea's sanitizer strips `style` from the tag
|
||||
and from any wrapper, `hspace`/`vspace` survive sanitizing only to be
|
||||
ignored by browsers that dropped HTML4 presentation attributes, and a bare
|
||||
<br> ends a line nothing follows, which browsers collapse to no height.
|
||||
survives as a real U+00A0: on its own line it fills a paragraph
|
||||
that would otherwise be empty, and immediately before the tag it sits
|
||||
inline and indents the image by one space. -->
|
||||
|
||||
|
||||
<img src="assets/dejima-wordmark-darkmode.png" alt="dejima" height="72">
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
# dejima-guard
|
||||
|
||||
Notices when a devbox changes, and tells you — from a machine that is not the
|
||||
Notices when a devbox changes, and tells you, from a machine that is not the
|
||||
devbox.
|
||||
|
||||
A devbox has no upper bound on how long a compromise lasts. Its sister project,
|
||||
@@ -14,7 +40,7 @@ made the box report what changed. This is the part that reads those reports and
|
||||
wakes you up.
|
||||
|
||||
**It does not run on the devbox.** That is the entire design, not a deployment
|
||||
detail — see [Why it pulls](#why-it-pulls).
|
||||
detail: see [Why it pulls](#why-it-pulls).
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
@@ -22,6 +48,8 @@ detail — see [Why it pulls](#why-it-pulls).
|
||||
| [Why it pulls](#why-it-pulls) | The three things the inversion buys |
|
||||
| [What it does not do](#what-it-does-not-do) | Read this before relying on it |
|
||||
|
||||
[](https://gitbox.branderson.io/dejima/dejima)
|
||||
|
||||
---
|
||||
|
||||
## Before you use it
|
||||
@@ -32,7 +60,9 @@ the person who wrote it. Every security property this describes is a claim
|
||||
awaiting scrutiny, not one that has survived it.
|
||||
|
||||
**One maintainer, no support.** Nothing about this is promised: not a reply, not
|
||||
a fix, not a release.
|
||||
a fix, not a release. [SECURITY.md](SECURITY.md) has what this claims, what it
|
||||
holds, and where it is weaker than dejima. It is also where to report a
|
||||
vulnerability rather than in a public issue.
|
||||
|
||||
**It is a second opinion, not a boundary.** Nothing here prevents anything. It
|
||||
watches, and everything it can tell you is something that has already happened.
|
||||
@@ -40,48 +70,84 @@ watches, and everything it can tell you is something that has already happened.
|
||||
## Setting it up
|
||||
|
||||
It needs a machine that is always on and is not the devbox. A NAS, a home
|
||||
server, a Pi, an existing VM — anything already running. **Not a new VM**: this
|
||||
server, a Pi, an existing VM: anything already running. **Not a new VM**: this
|
||||
is not a service, and a machine of its own would be a poor trade for something
|
||||
whose whole job is to notice.
|
||||
|
||||
### On the host, with a systemd timer
|
||||
|
||||
```sh
|
||||
sudo install -m 0755 bin/dejima-guard /usr/local/sbin/dejima-guard
|
||||
sudo install -d -m 0750 /etc/dejima-guard /var/lib/dejima-guard
|
||||
sudo install -m 0640 etc/config.example /etc/dejima-guard/config
|
||||
sudo useradd --system --home-dir /var/lib/dejima-guard \
|
||||
--shell /usr/sbin/nologin dejima-guard
|
||||
sudo install -m 0755 bin/dejima-guard bin/dejima-guard-install-key /usr/local/sbin/
|
||||
sudo install -d -m 0750 -o dejima-guard -g dejima-guard \
|
||||
/etc/dejima-guard /var/lib/dejima-guard
|
||||
sudo install -m 0640 -o root -g dejima-guard etc/config.example /etc/dejima-guard/config
|
||||
sudoedit /etc/dejima-guard/config
|
||||
```
|
||||
|
||||
Then a key of its own, restricted at the devbox end so it can run the two
|
||||
read-only subcommands and nothing else:
|
||||
**An account of its own, because the unit runs as it.** This holds an SSH key to
|
||||
another machine, and a bug in it should yield that account rather than root on
|
||||
whatever host you put it on. The config stays root-owned and group-readable: it
|
||||
names a command the guard runs, so write access to it is code execution as the
|
||||
guard, and `dejima-guard` refuses to start if it is writable by group or others.
|
||||
|
||||
A key of its own, put on the devbox restricted so it can run the two read-only
|
||||
subcommands and nothing else:
|
||||
|
||||
```sh
|
||||
sudo ssh-keygen -t ed25519 -N '' -f /var/lib/dejima-guard/id_ed25519
|
||||
sudo cat /var/lib/dejima-guard/id_ed25519.pub
|
||||
sudo -u dejima-guard ssh-keygen -t ed25519 -N '' -f /var/lib/dejima-guard/id_ed25519
|
||||
sudo -u dejima-guard dejima-guard-install-key
|
||||
```
|
||||
|
||||
`etc/config.example` has the `authorized_keys` line to pair it with. Then:
|
||||
It authenticates to the devbox the ordinary way, so expect a password prompt:
|
||||
the guard's key is what it installs, not what it uses. It then tries four
|
||||
commands with the new key and reports whether the restriction holds. It needs
|
||||
`dejima-guard-shell` and a sudoers rule on the devbox, both from
|
||||
["Let the guard in"](https://gitbox.branderson.io/dejima/dejima-devbox-hardener/src/branch/main/docs/building-a-devbox.md#guard-access);
|
||||
if either is missing it says which.
|
||||
|
||||
Then the devbox's host key, which nothing else records and every other command
|
||||
needs:
|
||||
|
||||
```sh
|
||||
sudo dejima-guard check # first run records the baseline, alerts nothing
|
||||
sudo -u dejima-guard dejima-guard trust-host
|
||||
sudo -u dejima-guard dejima-guard accept # the baseline, taken deliberately
|
||||
sudo install -m 0644 systemd/dejima-guard.{service,timer} /etc/systemd/system/
|
||||
sudo sed -i 's|@@BIN_DIR@@|/usr/local/sbin|' /etc/systemd/system/dejima-guard.service
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now dejima-guard.timer
|
||||
```
|
||||
|
||||
**Take the first baseline on a box you believe is clean.** One taken on a
|
||||
compromised machine records the compromise as normal and reports clean forever.
|
||||
**Take the baseline on a box you believe is clean.** One taken on a compromised
|
||||
machine records the compromise as normal and reports clean forever. `check` will
|
||||
not take one for you: under the timer that would happen five minutes after boot,
|
||||
unattended, and everything the guard says afterwards rests on that file. Until
|
||||
you run `accept` it reports as down, which is what a guard that has not been
|
||||
told what normal looks like is.
|
||||
|
||||
`trust-host` prints the fingerprint before writing it: compare it against
|
||||
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the devbox, or pass
|
||||
`--fingerprint SHA256:...` and have it refuse anything else. Skipping it is not
|
||||
a quiet failure: host key checking is left on, batch mode turns its prompt into
|
||||
an error, and every cycle then reports the devbox unreachable at max priority.
|
||||
|
||||
### Or as a container
|
||||
|
||||
**1. Copy the example and edit it.** Every variable in it is commented with
|
||||
what goes in it.
|
||||
**1. Copy the example and edit it.** Every variable is commented with what goes
|
||||
in it. Your copy is gitignored, and the example will not start as shipped: its
|
||||
host is a placeholder the guard refuses, so it cannot become a live config by
|
||||
being copied and forgotten.
|
||||
|
||||
```sh
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
$EDITOR docker-compose.yml
|
||||
```
|
||||
|
||||
**2. Make a key for the guard and put it on the devbox.**
|
||||
**A copy, not an override.** `docker compose up` merges
|
||||
`docker-compose.override.yml` with the file it overrides rather than replacing
|
||||
it, so an override would leave the example's values in play.
|
||||
|
||||
**2. Make a key and put it on the devbox**, as described above.
|
||||
|
||||
```sh
|
||||
mkdir -p secrets
|
||||
@@ -89,95 +155,74 @@ ssh-keygen -t ed25519 -N '' -f secrets/guard_key -C dejima-guard
|
||||
bin/dejima-guard-install-key --pub secrets/guard_key.pub you@devbox
|
||||
```
|
||||
|
||||
That connects **as you**, with your own credentials — the guard's key is what
|
||||
it installs, not what it uses. It then tries four commands with the new key and
|
||||
tells you whether the restriction holds.
|
||||
**Not `ssh-copy-id`.** It copies the bare key with no options, and a bare key
|
||||
here is a shell on the devbox for whoever eventually takes *this* machine:
|
||||
the one holding the evidence about that one. The line needs a forced command,
|
||||
which `ssh-copy-id` cannot attach, and doing it by hand means copy-then-edit
|
||||
with an unrestricted key live in between.
|
||||
|
||||
**Not `ssh-copy-id`.** That copies the bare key with no options, and a bare key
|
||||
here is a shell on the devbox for whoever eventually takes *this* machine —
|
||||
which is the machine holding the evidence about that one. The line needs a
|
||||
forced command, and `ssh-copy-id` has no way to attach one. Doing it by hand
|
||||
instead means copy-then-edit, with a window in between where an unrestricted
|
||||
key is live; this writes the restricted line in a single append.
|
||||
|
||||
It needs `dejima-guard-shell` on the devbox and a sudoers rule permitting the
|
||||
two audit commands — both from
|
||||
[dejima-devbox-hardener §7](https://gitbox.branderson.io/dejima/dejima-devbox-hardener/src/branch/main/docs/building-a-devbox.md).
|
||||
If either is missing the check above says which.
|
||||
|
||||
**3. Write the secret config.** This is the one file that is not a copy of
|
||||
anything: create `secrets/config` and put in it whatever must not be in the
|
||||
environment — on a self-hosted ntfy with access control, that is the token.
|
||||
**3. Write the secret config.** The one file that is not a copy of anything:
|
||||
whatever must not sit in the environment, which on a self-hosted ntfy is the
|
||||
token.
|
||||
|
||||
```
|
||||
ntfy_token = <the token, from your ntfy server>
|
||||
heartbeat_url = <the Push URL Uptime Kuma gave you>
|
||||
```
|
||||
|
||||
If your ntfy needs no token, the file can be empty, but it still has to exist:
|
||||
compose will refuse to start with a secret it cannot find.
|
||||
**Both of these are credentials, which is why neither is in the compose file.**
|
||||
The token is obvious. The push URL is less so: it carries its token in the path,
|
||||
and anyone holding it can tell Kuma this guard is healthy, which suppresses the
|
||||
one signal that reports a guard that has died. Everything in a compose
|
||||
`environment:` block shows up in `docker inspect` and to anything that can read
|
||||
`/proc`.
|
||||
|
||||
An ntfy needing no token can leave that line out, but the file must exist:
|
||||
compose will not start with a secret it cannot find.
|
||||
|
||||
```sh
|
||||
chmod 600 secrets/guard_key secrets/config
|
||||
sudo chown 10001:10001 secrets/guard_key secrets/config
|
||||
```
|
||||
|
||||
**4. Start it, then prove the alert path works.**
|
||||
**The `chown` is not optional.** The image runs as uid 10001, and compose mounts
|
||||
a secret with the host's ownership, so a key that is `0600` and yours is one
|
||||
the container cannot open. 10001 is fixed in the `Dockerfile` and is part of the
|
||||
interface. Bind-mounting a host directory in place of the named volume needs the
|
||||
same treatment.
|
||||
|
||||
**4. Record the host key and start it.**
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
docker compose run --build --rm dejima-guard trust-host
|
||||
docker compose run --build --rm dejima-guard accept # the baseline
|
||||
docker compose up --build -d
|
||||
docker compose exec dejima-guard dejima-guard test-alert
|
||||
```
|
||||
|
||||
**`--build`, every time.** This builds from the `Dockerfile` rather than pulling
|
||||
from a registry, and `up -d` reuses whatever image already exists, so after a
|
||||
`git pull` it would start the old code and report success. Rebuilding is how an
|
||||
update takes effect here.
|
||||
|
||||
**Go and check that alert arrived on your phone.** Nothing else exercises that
|
||||
path: a clean cycle never publishes, so a wrong token or a wrong topic sits
|
||||
undiscovered until the first real finding — and then takes it with it. The
|
||||
guard suppresses its heartbeat when a send fails, so a broken channel
|
||||
eventually shows up as a dead guard, but the first time you find that out
|
||||
should be now and not then.
|
||||
|
||||
**A copy, not an override.** `docker compose up` reads `docker-compose.yml` and
|
||||
`docker-compose.override.yml` together and merges them, so overriding the
|
||||
example leaves its values in play rather than replacing them. Your copy is
|
||||
gitignored, which is also where your host and your URLs belong.
|
||||
|
||||
The example will not start as shipped: its host is a placeholder the guard
|
||||
refuses, so it cannot become a live config by being copied and forgotten.
|
||||
|
||||
The public half of that key goes in the devbox's `authorized_keys`, restricted
|
||||
as `etc/config.example` shows.
|
||||
|
||||
**It never publishes a port, and it never should.** What makes a container an
|
||||
acceptable home for a program holding an SSH key to the devbox is that nothing
|
||||
can connect to it. The compose file drops every capability, mounts the root
|
||||
filesystem read-only, and sets `no-new-privileges`.
|
||||
path, and a broken one takes the first real finding with it.
|
||||
|
||||
**The volume is not a cache.** It holds the baseline, the last report and
|
||||
`known_hosts`. Losing it is not a clean slate: a fresh baseline compares clean
|
||||
against everything for the rest of its life, and a fresh `known_hosts` fails
|
||||
host-key verification on every connection — which `BatchMode` turns into
|
||||
"could not reach the devbox", at max priority, forever. A false alarm that
|
||||
never stops is the kind that gets muted.
|
||||
against everything for the rest of its life, and a fresh `known_hosts` reports
|
||||
the devbox unreachable forever.
|
||||
|
||||
**Config comes from the environment first, then the file.** Compose is where
|
||||
everything else about a deployment lives, so the environment is the ergonomic
|
||||
place for a hostname. It is the wrong place for a token: `docker inspect` shows
|
||||
it, and so does anything that can read `/proc`. Put secrets in the mounted
|
||||
config file, which is what `DEJIMA_GUARD_CONF` points at.
|
||||
|
||||
**If you bind-mount a host directory instead of the named volume**, chown it to
|
||||
uid `10001` first. The image creates that user and the named volume inherits
|
||||
its ownership; a bind mount does not, and the guard cannot write its baseline
|
||||
to a directory owned by root.
|
||||
|
||||
The image is Debian rather than Alpine deliberately: the diff that produces the
|
||||
change list uses `--unchanged-line-format`, which is GNU diffutils and not
|
||||
busybox. Alpine works once diffutils and coreutils are added back, at which
|
||||
point the smaller base has been patched towards this one a package at a time.
|
||||
**It never publishes a port, and never should.** What makes a container an
|
||||
acceptable home for a program holding an SSH key to the devbox is that nothing
|
||||
can connect to it. The compose file also drops every capability, mounts the root
|
||||
filesystem read-only, and sets `no-new-privileges`.
|
||||
|
||||
### What goes in each setting
|
||||
|
||||
Every one can come from the config file or from the environment as
|
||||
`DEJIMA_GUARD_` plus the key in upper case. The environment wins, which is
|
||||
what makes a compose file workable — but a token belongs in the file, since
|
||||
what makes a compose file workable, but a token belongs in the file, since
|
||||
`docker inspect` shows the environment and so does anything reading `/proc`.
|
||||
|
||||
**The machine being watched.** Everything here describes the *devbox*, at the
|
||||
@@ -186,19 +231,22 @@ other end of the connection, not the machine the guard runs on.
|
||||
| Setting | What goes in it |
|
||||
| --- | --- |
|
||||
| `devbox_host` | The devbox's hostname or address, as this machine reaches it |
|
||||
| `devbox_user` | The account **on the devbox** to log in as. Not root, and no default — the devbox runbook builds a machine with no root password, so the guard connects as an ordinary account and elevates for two commands |
|
||||
| `devbox_user` | The account **on the devbox** to log in as. Not root, and no default: the devbox runbook builds a machine with no root password, so the guard connects as an ordinary account and elevates for two commands |
|
||||
| `ssh_key` | The private key **on this machine**. Its own, not a general-purpose administrative one |
|
||||
| `ssh_known_hosts` | Where the devbox's host key is remembered. Must persist — in a container, on the state volume |
|
||||
| `ssh_known_hosts` | Where the devbox's host key is remembered. Must persist: in a container, on the state volume. Populated by `dejima-guard trust-host`, never automatically |
|
||||
| `ssh_timeout` | Seconds to wait. The line between a slow devbox and a gone one |
|
||||
| `critical_paths` | Paths whose change is its own max-priority alert, one per line. Defaults to the audit binary, the forced command, the sudoers rule, `sshd_config` and `authorized_keys` |
|
||||
| `ignore_paths` | Paths whose change is reported but never alerted on, space-separated substrings. None by default. See [Changes not worth waking for](#changes-not-worth-waking-for) |
|
||||
|
||||
**Where alerts go.**
|
||||
|
||||
| Setting | What goes in it |
|
||||
| --- | --- |
|
||||
| `ntfy_url` | The topic to publish to. **Its own**, not the one dejima pushes to — that URL lives on the devbox, so root there could drown it or forge an all-clear |
|
||||
| `ntfy_url` | The topic to publish to. **Its own**, not the one dejima pushes to: that URL lives on the devbox, so root there could drown it or forge an all-clear |
|
||||
| `ntfy_token` | Write access to that topic and nothing else. The reason the config file is `0640` |
|
||||
| `heartbeat_url` | GET after a cycle that completed clean. An Uptime Kuma push monitor |
|
||||
| `heartbeat_url` | GET after a cycle that completed clean. An Uptime Kuma push monitor. **A credential**: the token is in the path, so it belongs in the config file rather than the environment |
|
||||
| `alert_command` | An alternative to ntfy: gets the title and body on stdin |
|
||||
| `guard_name` | This machine's name, for alerts to say where to run `dejima-guard report`. Optional; inside a container the hostname is a container ID, so it is never guessed |
|
||||
|
||||
`etc/config.example` carries the same reference with the reasoning attached.
|
||||
|
||||
@@ -207,17 +255,115 @@ other end of the connection, not the machine the guard runs on.
|
||||
Both self-hosted, and if you run dejima's remote access you already have the
|
||||
first.
|
||||
|
||||
**ntfy** carries the alerts. Give this its **own topic**, separate from the one
|
||||
dejima pushes to, and give the devbox no credential for it — `ntfy_url` lives in
|
||||
`~/.config/dejima/config` *on the devbox*, so root there can publish to that
|
||||
topic, drown it, or forge an all-clear. Deny the devbox read on the alert topic
|
||||
too: an intruder who can read the alerts about themselves learns what the
|
||||
monitoring noticed and what it missed.
|
||||
#### ntfy: its own topic, and a token that can only write to it
|
||||
|
||||
**Uptime Kuma** is the dead-man's switch. A push monitor expects a ping on a
|
||||
cadence and alerts when one does not arrive; this pings only on a cycle that
|
||||
completed and found nothing wrong. Without it, a watcher that dies looks exactly
|
||||
like a watcher with nothing to report.
|
||||
Give this its **own topic**, separate from the one dejima pushes to. `ntfy_url`
|
||||
lives in `~/.config/dejima/config` *on the devbox*, so root there can publish to
|
||||
dejima's topic, drown it, or forge an all-clear on it. A shared topic hands the
|
||||
machine under suspicion a voice in the channel reporting on it.
|
||||
|
||||
On a public ntfy that is as far as you can go: anyone who learns the topic name
|
||||
can post to it. Closing it properly needs a server you configure.
|
||||
|
||||
```yaml
|
||||
# server.yml
|
||||
auth-file: /var/lib/ntfy/user.db
|
||||
auth-default-access: deny-all
|
||||
```
|
||||
|
||||
> ⚠️ **This is a server-wide switch, and on its own it will break notifications
|
||||
> that have nothing to do with dejima.** It closes *every* topic on that server.
|
||||
> Worse, the apps subscribe to all of a server's topics over one connection, and
|
||||
> ntfy authorises it by looping the list, returning 403 for the whole request
|
||||
> on the first topic the account cannot read. Nothing says which one, so
|
||||
> one missing grant reads as the server being broken. Grant everything back
|
||||
> before you turn it on.
|
||||
|
||||
```sh
|
||||
ntfy user add guard # role user, not admin
|
||||
ntfy user add phone # you, subscribing
|
||||
ntfy access guard dejima-alerts write-only
|
||||
ntfy access phone "*" read-only # EVERY topic you subscribe to
|
||||
ntfy access everyone dejima-notify read-write # dejima's topic, as it was
|
||||
ntfy access everyone "home-*" read-write # anything else that published
|
||||
ntfy token add guard # prints tk_..., for secrets/config
|
||||
ntfy access # read it back
|
||||
```
|
||||
|
||||
Four rules decide whether that is what you think it is:
|
||||
|
||||
- **`deny-all` is server-wide.** There is no per-topic default, so everything
|
||||
publishing anonymously needs granting back by name. `dejima-notify-watch` has
|
||||
no token to offer and does not need one: nothing about dejima changes, and the
|
||||
devbox still ends up with no access to `dejima-alerts` at all.
|
||||
- **Neither account may be an `admin`.** An admin bypasses the ACL entirely, so
|
||||
an admin token is a key to the alert topic that no entry can take away.
|
||||
- **`write-only` for the guard**, not `read-write`. It never reads the topic and
|
||||
this machine can be taken too; read access would hand whoever took it every
|
||||
alert about the devbox, and the gaps between them are as informative as the
|
||||
contents. The devbox gets no read either, since an intruder reading the alerts
|
||||
about themselves learns what the monitoring missed.
|
||||
- **An `everyone` grant does cover a logged-in user**, on topics where that user
|
||||
has no entry of their own. Logging in to read the alert topic does not cost
|
||||
you the anonymous grant on dejima's.
|
||||
|
||||
The `"*"` for the reading account is doing real work. Name topics individually
|
||||
if you prefer, but name *all* of them, everywhere that account subscribes: one
|
||||
left out denies the rest with it.
|
||||
|
||||
**Then log the subscribers in** (web app: Settings → Users; Android: per
|
||||
server). A denied subscribe fails the WebSocket upgrade, which the apps report
|
||||
as `WebSocketNotSupportedException` or a 403 on the upgrade, so it reads as a
|
||||
broken reverse proxy. Publishing keeps working throughout, because that path
|
||||
carries a token, so a test alert arriving proves nothing about whether anyone
|
||||
can read it. To tell the ACL from the proxy without WebSockets in the way, and
|
||||
to find a topic that is denying the rest:
|
||||
|
||||
```sh
|
||||
for t in dejima-alerts dejima-notify whatever-else; do
|
||||
printf '%s ' "$t"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -u phone:PASS \
|
||||
"https://ntfy.example.invalid/$t/json?poll=1"
|
||||
done
|
||||
```
|
||||
|
||||
Anonymous should give 403 and the credentialled call 200. Anything else is the
|
||||
proxy. A hand-rolled browser client needs `?auth=` instead of a header, since a
|
||||
browser cannot set one on a WebSocket: its value is base64 of the string
|
||||
`Basic ` plus base64 of `user:pass`.
|
||||
|
||||
**On a server you cannot reconfigure**, one topic cannot be closed properly.
|
||||
Access falls back to `auth-default-access` whenever no entry matches, so denying
|
||||
the alert topic means an entry for `everyone` *and* one per account, and the
|
||||
next account added inherits publish rights to the channel reporting on the
|
||||
devbox. Existing admins keep access regardless. What is left is an unguessable
|
||||
topic name, which is not access control and is worth calling that; a second
|
||||
server for the one topic, which carries a few hundred bytes a day; or
|
||||
`alert_command`, which leaves ntfy entirely.
|
||||
|
||||
Either way, a stolen guard token can still publish: a forged all-clear, or noise
|
||||
to bury a real alert. Nothing in ntfy prevents that, which is part of why the
|
||||
heartbeat is a separate channel on a separate server.
|
||||
|
||||
#### Uptime Kuma: a Push monitor, and the interval matters
|
||||
|
||||
This is the dead-man's switch. dejima-guard pings only on a cycle that completed
|
||||
and found nothing wrong, so silence is the signal, and without it, a guard that
|
||||
died looks exactly like one with nothing to report.
|
||||
|
||||
**Add New Monitor → Monitor Type: `Push`.** Kuma then shows a **Push URL** ending
|
||||
in a token. That whole URL is `heartbeat_url`.
|
||||
|
||||
| Field | Set it to | Why |
|
||||
| --- | --- | --- |
|
||||
| Monitor Type | `Push` | Kuma waits to be called. Every other type calls out, and there is nothing here to call |
|
||||
| Heartbeat Interval | **Longer than dejima-guard's**: 1200s against the default 900s | Kuma counts from the last ping, so equal intervals put every ping right on the deadline. The gap is never exactly 900s either: the systemd timer adds up to 60s of deliberate jitter, and the cycle itself takes as long as two SSH round trips |
|
||||
| Retries | `0` | A missed ping *is* the finding. Retries make Kuma wait for more of them before saying so, which delays the one alert that reports a guard that has stopped |
|
||||
|
||||
dejima-guard's own interval is `DEJIMA_GUARD_INTERVAL` (900s) in a container, or
|
||||
`OnUnitActiveSec` in `systemd/dejima-guard.timer` (15min) on a host. Change
|
||||
either and Kuma has to follow, or it starts alerting on a schedule nobody
|
||||
changed.
|
||||
|
||||
Anything else works through `alert_command` and a `heartbeat_url` of the same
|
||||
shape. Only the one path is documented, because a list of alternatives is a list
|
||||
@@ -225,9 +371,9 @@ that goes stale.
|
||||
|
||||
## Why it pulls
|
||||
|
||||
The watcher reaches in; the devbox never reaches out. That falls out of the
|
||||
firewall the devbox already has — inbound SSH from the admin subnet is
|
||||
permitted, so **no new firewall rule is needed** — and it buys three things:
|
||||
dejima-guard reaches in; the devbox never reaches out. That falls out of the
|
||||
firewall the devbox already has: inbound SSH from the admin subnet is
|
||||
permitted, so **no new firewall rule is needed**, and it buys three things:
|
||||
|
||||
**The baseline lives here.** Root on the devbox can lie about the current state.
|
||||
It cannot alter what this machine recorded last week.
|
||||
@@ -236,7 +382,7 @@ It cannot alter what this machine recorded last week.
|
||||
quiet, or drown it by shouting.
|
||||
|
||||
**Silence becomes a signal.** This is the one that matters. A reporter running
|
||||
*on* the devbox, switched off by whoever took the box, causes nothing to happen —
|
||||
*on* the devbox, switched off by whoever took the box, causes nothing to happen:
|
||||
forever, with nothing to see. A collection that stops arriving is itself the
|
||||
alarm, and it is the only mechanism here that a compromised machine cannot
|
||||
defeat by doing nothing.
|
||||
@@ -249,7 +395,7 @@ minutes to notice the monitoring and scrub, and the resulting gap is evidence.
|
||||
- **It does not prevent anything.** Everything it reports has already happened.
|
||||
- **It does not detect an intruder who anticipated it.** The devbox-side audit
|
||||
can be lied to by root on the devbox. What this catches is a payload that did
|
||||
not think to look — most of them, since the adversary in this threat model did
|
||||
not think to look: most of them, since the adversary in this threat model did
|
||||
not choose your devbox.
|
||||
- **It does not watch CI runners.** Their scheduled rollback bounds dwell time,
|
||||
so the need is weaker. The design is not tier-specific and extending it would
|
||||
@@ -259,27 +405,106 @@ minutes to notice the monitoring and scrub, and the resulting gap is evidence.
|
||||
- **It cannot tell you its alerts are arriving.** Sent is not received. A
|
||||
clean cycle never publishes, so a broken token or topic waits until the
|
||||
first real finding. What it does do is suppress the heartbeat when a send
|
||||
fails, so a broken channel surfaces as a dead guard rather than as silence —
|
||||
fails, so a broken channel surfaces as a dead guard rather than as silence:
|
||||
and `test-alert` exists so you can settle it deliberately rather than find
|
||||
out.
|
||||
- **It sends no detail.** Alert bodies carry counts and categories, never a
|
||||
filename or a unit name. A compromised devbox controls those strings, and
|
||||
interpolating one into a push hands it a channel to your phone. The detail is
|
||||
in `dejima-guard report`, on this machine.
|
||||
- **It sends no paths.** Alert bodies say what kind of change and under which
|
||||
watched area ("1 file(s) modified in ~/.ssh/known_hosts"), worded by the
|
||||
guard from a fixed list, never a filename or a unit name. A compromised devbox
|
||||
controls those strings, and interpolating one into a push hands it a channel
|
||||
to your phone. The paths are in `dejima-guard report`, on this machine.
|
||||
|
||||
## Use
|
||||
|
||||
```sh
|
||||
dejima-guard check # what the timer runs
|
||||
dejima-guard report # the last cycle's detail
|
||||
dejima-guard accept # record the devbox's current state as the baseline
|
||||
dejima-guard status # baseline, last check, any expected-changes window
|
||||
dejima-guard report # the last cycle's detail: each path added, modified or removed
|
||||
dejima-guard accept # record the devbox's current state as the baseline, then check
|
||||
dejima-guard expect 30m # updating the devbox: hold alerts, then accept at the end
|
||||
dejima-guard test-alert # publish a test alert, then go and look
|
||||
dejima-guard trust-host # record the devbox's host key; run once, before check
|
||||
|
||||
dejima-guard-install-key # put the guard's key on a devbox, restricted
|
||||
```
|
||||
|
||||
`accept` is a decision, which is why a check never makes it. After an `apt
|
||||
upgrade` on the devbox, read the report, and if the changes are yours, accept.
|
||||
`accept` is a decision, which is why a check never makes it. After an
|
||||
unannounced change, read the report, and if the changes are yours, accept. The
|
||||
check it runs afterwards gets the heartbeat going again.
|
||||
|
||||
### Updating the devbox without being paged
|
||||
|
||||
Say so first, on the guard:
|
||||
|
||||
```sh
|
||||
dejima-guard expect 30m # or 2h, 90s; 24h at most
|
||||
```
|
||||
|
||||
Until then, changes and the devbox being unreachable (a reboot) are held
|
||||
rather than sent. When the window ends the guard accepts the devbox's state and
|
||||
sends a low-priority note saying what it accepted; `report --finding` has the
|
||||
paths. Finished early? `dejima-guard accept`. Called it off? `dejima-guard
|
||||
expect --cancel`.
|
||||
|
||||
**Critical paths and containment still alert**, and a window never accepts a
|
||||
critical path: those need an `accept` by hand. **It is opened on the guard and
|
||||
only there.** A devbox that could announce its own changes could silence its
|
||||
own guard.
|
||||
|
||||
### Changes not worth waking for
|
||||
|
||||
`ignore_paths` in the config, space-separated, each matched as a substring of
|
||||
the path like `critical_paths`:
|
||||
|
||||
```
|
||||
ignore_paths = /.ssh/known_hosts
|
||||
```
|
||||
|
||||
`~/.ssh/known_hosts` is the usual candidate. It changes whenever the devbox
|
||||
first connects to an SSH host, such as cloning from a new git server. Ignored
|
||||
changes still show in `report`, under their own heading, and a critical path
|
||||
cannot be ignored.
|
||||
|
||||
### In a container, every one of those needs a prefix
|
||||
|
||||
There is no `dejima-guard` on the host: it lives in the image, with the config
|
||||
and the state volume the commands need. Running the bare name on a container
|
||||
host either finds nothing or, if you also installed the host path, finds a
|
||||
second deployment with its own baseline.
|
||||
|
||||
**While it is running**, which is the usual case:
|
||||
|
||||
```sh
|
||||
docker compose exec dejima-guard dejima-guard report
|
||||
docker compose exec dejima-guard dejima-guard accept
|
||||
```
|
||||
|
||||
**While it is not** (during setup, or after `docker compose down`):
|
||||
|
||||
```sh
|
||||
docker compose run --rm dejima-guard accept
|
||||
```
|
||||
|
||||
`exec` runs inside the container that is already there. `run` starts another one
|
||||
from the same image, with the same volume and secrets, and removes it after.
|
||||
Either works; `exec` is cheaper and is what to reach for once the thing is up.
|
||||
|
||||
**The command is not repeated after `run`.** The image's entrypoint is already
|
||||
the guard, so `run --rm dejima-guard accept` is the whole of it. Naming the
|
||||
binary again passes it as an argument to itself.
|
||||
|
||||
**Or skip the prefix.** `bin/dejima-guard-docker` runs the guard in its
|
||||
container by name, from any directory. Install it on the container host as
|
||||
`dejima-guard` and every command above works as written:
|
||||
|
||||
```sh
|
||||
sudo install -m 0755 bin/dejima-guard-docker /usr/local/bin/dejima-guard
|
||||
dejima-guard status
|
||||
```
|
||||
|
||||
It assumes the `container_name` the example compose file sets; set
|
||||
`DEJIMA_GUARD_CONTAINER` if yours differs. Do not install it beside a host
|
||||
install of the guard, which would be a second deployment with its own baseline.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -288,11 +513,16 @@ bats test/
|
||||
```
|
||||
|
||||
They stub `ssh` and `curl` on `PATH` rather than replacing functions, because
|
||||
what is under test includes whether this program calls them at all — and the
|
||||
what is under test includes whether this program calls them at all, and the
|
||||
worst failures here are silent: alerting on nothing until the topic is muted,
|
||||
staying quiet when the box stops answering, and sending the heartbeat on a cycle
|
||||
that failed.
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0-or-later.
|
||||
GPL-3.0-or-later. See [LICENSE](LICENSE).
|
||||
|
||||
The dejima wordmark and logo are not code and are not covered by that licence.
|
||||
They are trademarks; who holds them and what may be done with them is stated
|
||||
once for the organisation, at
|
||||
[dejima's ATTRIBUTION.md](https://gitbox.branderson.io/dejima/dejima/src/branch/main/ATTRIBUTION.md).
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# Security policy
|
||||
|
||||
## What dejima-guard claims
|
||||
|
||||
It notices when a devbox changes, from a machine that is not the devbox, and it
|
||||
tells you. It is a second opinion, not a boundary.
|
||||
|
||||
**It prevents nothing.** Everything it reports has already happened. The
|
||||
controls that bound a compromise are
|
||||
[dejima](https://gitbox.branderson.io/dejima/dejima)'s and
|
||||
[dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener)'s;
|
||||
this only reports on the machine those two are protecting.
|
||||
|
||||
What it does claim is that its answer is hard to suppress from the devbox: the
|
||||
baseline lives here rather than there, the alert leaves from here, and it pulls
|
||||
rather than being pushed to, so a collection that stops arriving is itself the
|
||||
alarm. Root on the devbox can lie about the current state. It cannot alter what
|
||||
this machine recorded last week, and it cannot make this machine stay quiet
|
||||
without that silence being the signal.
|
||||
|
||||
## What it holds, which is the new risk it introduces
|
||||
|
||||
Watching a machine means holding credentials to it, so this is a concentration
|
||||
of privilege that did not exist before you ran it.
|
||||
|
||||
| It holds | Taking it gets | It does not get |
|
||||
| --- | --- | --- |
|
||||
| An SSH key to the devbox | Two read-only audit commands, through a forced command | A shell, a file, a tunnel, or `drift --accept` |
|
||||
| An ntfy token | Publishing to the alert topic: a forged all-clear, or noise to bury a real one | Reading past alerts, if it is `write-only` as documented |
|
||||
| The baseline | Knowing exactly what an intruder would have to preserve to stay unnoticed | Anything on the devbox |
|
||||
|
||||
The key is restricted at the devbox end rather than trusted here, which is the
|
||||
half that matters: see
|
||||
["Let the guard in"](https://gitbox.branderson.io/dejima/dejima-devbox-hardener/src/branch/main/docs/building-a-devbox.md#guard-access).
|
||||
`dejima-guard-install-key` proves the restriction rather than assuming it.
|
||||
|
||||
## What actually bounds this
|
||||
|
||||
Two things characterise the exposure, and both matter more than any comparison
|
||||
with the projects around it.
|
||||
|
||||
**It runs on whatever you run it on.** The guard holds a key to the watched box
|
||||
and parses that box's output, and its exposure is bounded by the hardening of a
|
||||
machine this project does not describe and asks nothing of. A NAS, a home
|
||||
server, an existing VM: whatever it is, it is not hardened by anything here. The
|
||||
container path answers this for itself, running unprivileged with every
|
||||
capability dropped and the root filesystem read-only. The host path is a systemd
|
||||
unit, and what that unit asks for is the whole of the answer: its own account,
|
||||
an empty capability bounding set, and memory and task limits.
|
||||
|
||||
What the guard does to protect that host: it never runs as root, it caps how
|
||||
much the watched machine may send it, it names the ssh settings a local
|
||||
`ssh_config` could otherwise widen, and it refuses a config file that is
|
||||
writable by group or others, because `alert_command` makes that file
|
||||
executable-equivalent.
|
||||
|
||||
**The collector runs on the watched host.** `dejima-devbox-audit` executes on
|
||||
the devbox, so root there can return whatever it likes, including last week's
|
||||
state verbatim. This is the detection ceiling and no amount of care on this side
|
||||
raises it. The heartbeat proves the guard ran, not that it saw the truth. What
|
||||
the design does buy is that lying has to be deliberate and continuous: the
|
||||
baseline is here, out of reach, so a payload that did not think to look is
|
||||
caught, and one that stops the collection entirely is caught by its silence.
|
||||
|
||||
## Where it sits next to the other projects
|
||||
|
||||
Not a fourth enforcement layer. It enforces nothing, and it is the only one of
|
||||
the four that adds a machine holding credentials rather than taking some away.
|
||||
|
||||
Three of dejima's controls have no equivalent here, and are worth knowing rather
|
||||
than discovering:
|
||||
|
||||
- **No egress bound.** dejima's deny-by-default allowlist covers DNS as well as
|
||||
IP. This has none: the container reaches whatever the host can. Its needs are
|
||||
three destinations, so an allowlist is cheap, but it belongs to your host or
|
||||
your container network. Doing it inside the container would mean granting
|
||||
`NET_ADMIN` to something that currently drops every capability, which is a
|
||||
worse trade than the gap.
|
||||
- **The credential is in the container.** dejima keeps the model credential out
|
||||
entirely, behind a proxy. Here the private key is mounted. An `SSH_AUTH_SOCK`
|
||||
from the host would give the same use-without-possession and is not documented
|
||||
yet.
|
||||
- **No request logging.** dejima logs egress verdicts. This logs findings and
|
||||
failures, not connections.
|
||||
|
||||
## What it deliberately does not do
|
||||
|
||||
- **It does not catch an intruder who anticipated it.** The devbox-side audit
|
||||
can be lied to by root on the devbox. This catches a payload that did not
|
||||
think to look, which is most of them.
|
||||
- **It sends no paths off this machine.** Alert bodies carry counts and the
|
||||
watched area a change falls under, printed from a fixed list in the guard,
|
||||
never a filename or a unit name, because the devbox chooses those strings.
|
||||
The detail stays in `dejima-guard report`.
|
||||
- **An expected-changes window is opened on the guard, never by the devbox.**
|
||||
One the devbox could open would let it silence its own guard. A window never
|
||||
holds or accepts a change to a critical path, or a containment failure.
|
||||
- **It treats what the devbox says as untrusted input**, including in that
|
||||
report: control characters are replaced as the text is collected, since a
|
||||
filename can carry an escape sequence and the report is read on the machine
|
||||
holding the key to the box that named the file.
|
||||
- **It cannot tell you its alerts are arriving.** Sent is not received.
|
||||
`test-alert` exists so that is a thing you settle deliberately.
|
||||
|
||||
## This is not proven software
|
||||
|
||||
Early development. Nothing here has been through outside review, an external
|
||||
audit, or adversarial testing by anyone other than the person who wrote it.
|
||||
Every security property described above is a claim awaiting scrutiny rather than
|
||||
one that has survived it. Do not put it anywhere that being wrong about it would
|
||||
matter.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Report it privately rather than in a public issue, to the address on the
|
||||
maintainer's forge profile. There is one maintainer and no promised response
|
||||
time; you will get an acknowledgement when it is seen, and credit if you want
|
||||
it.
|
||||
|
||||
The organisation-wide policy, including what the sister projects claim, is
|
||||
[dejima's SECURITY.md](https://gitbox.branderson.io/dejima/dejima/src/branch/main/SECURITY.md).
|
||||
|
||||
## No warranty
|
||||
|
||||
GPL-3.0-or-later, sections 15 and 16. This is provided as-is.
|
||||
@@ -0,0 +1,19 @@
|
||||
# assets
|
||||
|
||||
The dejima wordmark, in the two versions the README needs: `-darkmode` for a
|
||||
page that renders dark, `-lightmode` for one that renders light. The suffix
|
||||
names the mode a file is for, not the colour of its ink, so `-darkmode` is the
|
||||
pale one.
|
||||
|
||||
The README points at `-darkmode` unconditionally; the comment above the tag
|
||||
says why it is not conditional. The GitHub mirror rewrites that tag to pick
|
||||
between the two by the reader's theme.
|
||||
|
||||
**These are trademarks and are not covered by this repository's GPL licence.**
|
||||
Who holds them, and what may be done with them, is stated once for the whole
|
||||
organisation:
|
||||
|
||||
https://gitbox.branderson.io/dejima/dejima/src/branch/main/ATTRIBUTION.md
|
||||
|
||||
The link is absolute so that a fork resolves it to the canonical terms rather
|
||||
than to its own copy.
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="122" height="20" role="img"
|
||||
aria-label="Developed in dejima">
|
||||
<title>Developed in dejima</title>
|
||||
<linearGradient id="g" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="122" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="79" height="20" fill="#282828"/>
|
||||
<rect x="79" width="43" height="20" fill="#8ec07c"/>
|
||||
<rect width="122" height="20" fill="url(#g)"/>
|
||||
</g>
|
||||
<g font-family="Verdana,DejaVu Sans,Geneva,sans-serif" font-size="11"
|
||||
text-anchor="middle">
|
||||
<text x="39.5" y="15" fill="#010101" fill-opacity=".3">Developed in</text>
|
||||
<text x="39.5" y="14" fill="#ebdbb2">Developed in</text>
|
||||
<text x="100.5" y="15" fill="#010101" fill-opacity=".3">dejima</text>
|
||||
<text x="100.5" y="14" fill="#282828">dejima</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 969 B |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
+809
-62
File diff suppressed because it is too large
Load Diff
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Brad Anderson
|
||||
#
|
||||
# dejima-guard, run inside its container, from any directory.
|
||||
#
|
||||
# Install on the container host as `dejima-guard`, so the commands read the same
|
||||
# there as everywhere else:
|
||||
#
|
||||
# sudo install -m 0755 bin/dejima-guard-docker /usr/local/bin/dejima-guard
|
||||
#
|
||||
# `docker exec` by container name rather than `docker compose exec`, which only
|
||||
# works from the directory holding the compose file. The name is the
|
||||
# container_name the example compose file sets.
|
||||
|
||||
container=${DEJIMA_GUARD_CONTAINER:-dejima-guard}
|
||||
|
||||
if [ -t 0 ] && [ -t 1 ]; then tty=-it; else tty=-i; fi
|
||||
exec docker exec "$tty" "$container" dejima-guard "$@"
|
||||
@@ -35,6 +35,21 @@ esac
|
||||
# after however much of the sleep is left.
|
||||
trap 'exit 0' TERM INT
|
||||
|
||||
# ARGUMENTS MEAN "RUN THIS ONCE", because this is the image's entrypoint.
|
||||
#
|
||||
# `docker compose run SERVICE CMD` replaces the command and keeps the
|
||||
# entrypoint, so `run --rm dejima-guard dejima-guard trust-host` arrived here as
|
||||
# two arguments this script ignored, and it started looping instead. The
|
||||
# operator watching for a fingerprint got a check every fifteen minutes,
|
||||
# forever, in a container they had to interrupt. That was the documented setup.
|
||||
#
|
||||
# So: no arguments is the timer, arguments are one command. It also shortens the
|
||||
# form to `docker compose run --rm dejima-guard trust-host`, since the entrypoint
|
||||
# is already the guard.
|
||||
if [ "$#" -gt 0 ]; then
|
||||
exec "$GUARD" "$@"
|
||||
fi
|
||||
|
||||
echo "dejima-guard-loop: every ${INTERVAL}s" >&2
|
||||
while :; do
|
||||
"$GUARD" check || true
|
||||
|
||||
@@ -13,9 +13,17 @@
|
||||
# forgotten.
|
||||
services:
|
||||
dejima-guard:
|
||||
# Built here, not pulled: there is no registry for this. Which makes
|
||||
# `--build` part of every command that starts it - `docker compose up -d`
|
||||
# alone reuses the image it already has, so after a `git pull` it would run
|
||||
# the old code and look like it had worked.
|
||||
build: .
|
||||
# image: gitbox.example.invalid/dejima/dejima-guard:latest
|
||||
container_name: dejima-guard
|
||||
# bin/dejima-guard-docker finds the container by this name. And an init,
|
||||
# because `dejima-guard expect` leaves a process waiting for the window's
|
||||
# end, which needs reaping once it is done.
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
@@ -51,16 +59,24 @@ services:
|
||||
|
||||
# --- the dead-man's switch ------------------------------------------
|
||||
#
|
||||
# GET after a cycle that completed clean - an Uptime Kuma push monitor
|
||||
# is the documented shape. Without it, a guard that dies looks exactly
|
||||
# like a guard with nothing to report.
|
||||
DEJIMA_GUARD_HEARTBEAT_URL: https://kuma.example.invalid/api/push/abcdef
|
||||
# NOT HERE. heartbeat_url goes in the mounted config beside ntfy_token,
|
||||
# because an Uptime Kuma push URL carries its token in the path: the URL
|
||||
# is the credential rather than the destination. `docker inspect` shows
|
||||
# everything in this block, and so does anything that can read /proc.
|
||||
#
|
||||
# What it grants is worth being plain about. Anyone holding that URL can
|
||||
# tell Kuma this guard is healthy, which suppresses the one signal that
|
||||
# reports a guard that has died - the single channel a compromised devbox
|
||||
# cannot reach.
|
||||
|
||||
# --- this container --------------------------------------------------
|
||||
#
|
||||
# Seconds between collections. Sets the dead-man's switch granularity
|
||||
# and how long an intruder has to notice the monitoring and scrub.
|
||||
DEJIMA_GUARD_INTERVAL: "900"
|
||||
# The name of the machine this container runs on, so an alert says
|
||||
# where to go and run `dejima-guard report`. Optional.
|
||||
# DEJIMA_GUARD_GUARD_NAME: your-docker-host
|
||||
# Anything secret goes in the config file rather than here. Everything
|
||||
# above is visible in `docker inspect` and to anything that can read
|
||||
# /proc; a mounted secret is not. The guard reads the environment first
|
||||
|
||||
+57
-1
@@ -49,8 +49,35 @@ ssh_key = /var/lib/dejima-guard/id_ed25519
|
||||
# volume rather than a home directory that is new every run - without it,
|
||||
# BatchMode turns the first connection into a failure and every collection
|
||||
# reports the devbox unreachable.
|
||||
#
|
||||
# `dejima-guard trust-host` writes it, and is the only thing that does. Run it
|
||||
# once before the first check. An empty file here is not a fresh start, it is a
|
||||
# guard that cannot connect at all.
|
||||
ssh_known_hosts = /var/lib/dejima-guard/known_hosts
|
||||
|
||||
# Paths whose change is a different kind of event from a config edit.
|
||||
#
|
||||
# The audit's own binary is the sharp one: if it changed, everything this guard
|
||||
# has been told since is suspect, including the report saying so. It used to
|
||||
# arrive as "1 file(s) changed", indistinguishable from a logrotate tweak among
|
||||
# a hundred others. The forced command, the sudoers rule and authorized_keys are
|
||||
# the same argument one layer out - they are what makes this access read-only,
|
||||
# so a change to them changes what a stolen key is worth.
|
||||
#
|
||||
# One per line, matched as a substring of the path. The default covers the
|
||||
# machinery this design rests on; add anything else you would want woken for.
|
||||
# critical_paths = /usr/local/sbin/dejima-devbox-audit
|
||||
# /etc/sudoers.d/dejima-guard
|
||||
|
||||
# Paths whose change is reported but not alerted on, space-separated, each
|
||||
# matched as a substring of the path. None by default.
|
||||
#
|
||||
# ~/.ssh/known_hosts is the usual one: it changes whenever the devbox first
|
||||
# connects to an SSH host, such as cloning from a new git server. Ignored
|
||||
# changes still appear in `dejima-guard report`, and a critical path above is
|
||||
# never ignored, whatever this says.
|
||||
# ignore_paths = /.ssh/known_hosts
|
||||
|
||||
# Seconds to wait for the connection. A devbox that is slow is not a devbox
|
||||
# that is gone, and this is the line between those two readings.
|
||||
ssh_timeout = 10
|
||||
@@ -77,6 +104,16 @@ ntfy_url = https://ntfy.example.invalid/devbox-alerts
|
||||
|
||||
# A token with write access to that topic and nothing else. Optional on a
|
||||
# server without access control, and the reason this file is 0640.
|
||||
#
|
||||
# WRITE-ONLY, not read-write. This program never reads the topic, and this
|
||||
# machine could itself be taken - a token that can read would hand whoever took
|
||||
# it every alert ever sent about the devbox, and the gaps between them.
|
||||
#
|
||||
# ntfy access guard <topic> write-only
|
||||
#
|
||||
# The README has the rest, including what to do on a server you cannot
|
||||
# reconfigure: auth-default-access is server-wide, so closing one topic is not
|
||||
# something a shared ntfy will let you do.
|
||||
# ntfy_token = tk_...
|
||||
|
||||
# ===========================================================================
|
||||
@@ -85,6 +122,12 @@ ntfy_url = https://ntfy.example.invalid/devbox-alerts
|
||||
|
||||
# A URL to GET after a cycle that completed and found nothing wrong.
|
||||
#
|
||||
# A CREDENTIAL, not just a destination: an Uptime Kuma push URL carries its
|
||||
# token in the path. Anyone holding it can tell Kuma this guard is healthy,
|
||||
# which suppresses the one signal that reports a guard that has died. Keep it
|
||||
# here rather than in a compose environment block, which `docker inspect`
|
||||
# prints.
|
||||
#
|
||||
# An Uptime Kuma push monitor is the documented shape: it expects a ping on a
|
||||
# cadence and alerts when one does not arrive. That is exactly what is wanted
|
||||
# here, because this program is pinged only on a clean cycle - so silence from
|
||||
@@ -95,7 +138,12 @@ ntfy_url = https://ntfy.example.invalid/devbox-alerts
|
||||
# itself, which is why the line is here rather than left to be discovered.
|
||||
#
|
||||
# Anything speaking the same "GET this URL to say you are alive" shape works.
|
||||
heartbeat_url = https://kuma.example.invalid/api/push/abcdef
|
||||
#
|
||||
# Copied whole, including the query Uptime Kuma appends. An example trimmed to
|
||||
# the path is shorter and is not what you will paste, and the shorter one hid a
|
||||
# bug: the URL check refused "&", so the real thing was rejected while this
|
||||
# example passed.
|
||||
heartbeat_url = https://kuma.example.invalid/api/push/abcdef?status=up&msg=OK&ping=
|
||||
|
||||
# ===========================================================================
|
||||
# IF YOU ALERT THROUGH SOMETHING ELSE
|
||||
@@ -105,3 +153,11 @@ heartbeat_url = https://kuma.example.invalid/api/push/abcdef
|
||||
# Set it and ntfy_url is ignored.
|
||||
#
|
||||
# alert_command = /usr/local/bin/my-alerter
|
||||
|
||||
# ===========================================================================
|
||||
# THIS MACHINE
|
||||
# ===========================================================================
|
||||
|
||||
# This machine's name, so an alert can say where to run `dejima-guard report`.
|
||||
# Optional. Never guessed: inside a container the hostname is a container ID.
|
||||
# guard_name = nas
|
||||
|
||||
@@ -8,13 +8,45 @@ Wants=network-online.target
|
||||
Type=oneshot
|
||||
ExecStart=@@BIN_DIR@@/dejima-guard check
|
||||
|
||||
# NOT ROOT, and this is the setting that matters most here.
|
||||
#
|
||||
# The container path has always run as its own uid, on the argument that running
|
||||
# as root grants root to a program holding an SSH key to another machine, for no
|
||||
# reason at all. That argument does not stop being true on a host, and this unit
|
||||
# used to disagree with it: no User=, so a bug in the guard yielded root on a
|
||||
# machine this project describes no hardening for, rather than an unprivileged
|
||||
# account in a capability-dropped container.
|
||||
#
|
||||
# StateDirectory creates /var/lib/dejima-guard and chowns it to this user, so it
|
||||
# replaces the ReadWritePaths line rather than joining it.
|
||||
User=dejima-guard
|
||||
Group=dejima-guard
|
||||
StateDirectory=dejima-guard
|
||||
|
||||
# NoNewPrivileges does not drop capabilities for root, so on the old unit it was
|
||||
# doing less than it looked like it was. Emptied outright: this opens two
|
||||
# outbound connections and writes one directory.
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
RestrictSUIDSGID=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelModules=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
|
||||
# It listens on nothing and needs almost nothing. Whatever this machine is, it
|
||||
# is not this program's to put at risk.
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/var/lib/dejima-guard
|
||||
ProtectKernelTunables=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=yes
|
||||
|
||||
# The watched host chooses how much it sends back. The collector caps that
|
||||
# itself and treats an over-long answer as a finding; these are the backstop for
|
||||
# everything the cap does not cover, on a machine that is running other things.
|
||||
MemoryMax=256M
|
||||
TasksMax=64
|
||||
|
||||
@@ -8,7 +8,7 @@ Description=Watch the devbox on a cadence
|
||||
# almost nothing; much longer and a compromise has room to tidy up.
|
||||
OnBootSec=5min
|
||||
OnUnitActiveSec=15min
|
||||
# Without this a fleet of watchers all collect on the same second.
|
||||
# Without this a fleet of dejima-guards all collect on the same second.
|
||||
RandomizedDelaySec=60s
|
||||
Persistent=true
|
||||
|
||||
|
||||
+1185
-15
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user