Compare commits

...
Author SHA1 Message Date
Alex Holliday 308672d558 pass locale to formatting 2026-09-14 09:45:24 -07:00
Alex Holliday 0f474d82d6 use exact unit for duration 2026-09-14 09:45:09 -07:00
Alex Holliday 650cfe00ce Merge branch 'develop' into feat/localized-monitor-times 2026-09-14 09:33:47 -07:00
Alexander Holliday 69eb9957c6 Merge pull request #3881 from fabiangigler/feat/custom-monitor-intervals
Add custom monitor interval options
2026-09-14 09:29:15 -07:00
fabiangigler 5da472da14 Prevent clearing monitor intervals 2026-09-13 20:28:27 +02:00
fabiangigler 4bb366f5ab Add custom monitor interval options 2026-09-13 20:28:27 +02:00
Alexander Holliday f41e0f0619 Merge pull request #3949 from kah-ja/fix/register-invite-token-validation
validate invite token type on registration
2026-09-12 15:02:48 -07:00
Alexander Holliday 3f10b6e567 Merge pull request #3952 from bluewave-labs/chore/release-prep
chore: bump minor version, fix unused import
2026-09-12 14:57:45 -07:00
Alex Holliday 9ad20daa51 bump minor version, fix unused import 2026-09-12 14:56:07 -07:00
Alexander Holliday 6a9068a151 Merge pull request #3951 from bluewave-labs/fix/svg-optimization
fix: reoptimize svg
2026-09-12 14:52:01 -07:00
Alex Holliday b31ffa5318 reoptimize svg 2026-09-12 14:46:05 -07:00
Alexander Holliday 102a9f1484 Merge pull request #3950 from bluewave-labs/chore/readme
chore: update readme
2026-09-12 14:17:01 -07:00
Alex Holliday 581ec9382b docker monitor instructions 2026-09-12 14:16:14 -07:00
Alex Holliday cebe9350f7 update readme 2026-09-12 14:07:31 -07:00
Jan Kahmen 406518e0a5 validate invite token type on registration 2026-09-12 12:08:33 +00:00
Alexander Holliday c458d5fb65 Merge pull request #3948 from bluewave-labs/feat/client-side-docker-tls
feat: client side docker tls
2026-09-11 12:10:28 -07:00
Alex Holliday ba0d685c6d commetns 2026-09-11 12:07:45 -07:00
Alex Holliday 912f1f960d add missing timeout 2026-09-11 11:50:26 -07:00
Alex Holliday 1de0edf7e3 Merge branch 'develop' into feat/client-side-docker-tls 2026-09-11 11:37:11 -07:00
Alex Holliday 8c9cb4be77 update create monitor form 2026-09-11 11:23:11 -07:00
Alex Holliday 727b904ca9 add docker TLS to monitor type 2026-09-11 11:21:39 -07:00
Alex Holliday 5c0279a6a7 add dockerTLS to form 2026-09-11 11:21:13 -07:00
Alex Holliday 9d72d20dd2 docker TLS validation 2026-09-11 11:20:47 -07:00
Alexander Holliday 62d0630702 Merge pull request #3947 from bluewave-labs/feat/validate-and-encrypt-docker-tls-key
feat: add validation for docker TLS fields, encrypt TLS key on create/edit monitor
2026-09-11 11:13:52 -07:00
Alex Holliday 2260b4a4d2 add validation for docker TLS fields, encrypt TLS key on create/edit monitor 2026-09-11 11:10:37 -07:00
Alexander Holliday cfb50afe42 Merge pull request #3946 from bluewave-labs/feat/secret-rotation
feat: secret rotation
2026-09-11 10:13:58 -07:00
Alex Holliday b14b2142e3 add secrets rotation service and tests 2026-09-11 10:09:30 -07:00
Alex Holliday c64c4cc5ff update tests 2026-09-10 12:26:25 -07:00
Alex Holliday 7071de294f add docker TLS fields to toEntity 2026-09-10 12:26:12 -07:00
Alex Holliday 5eb16fdbdb add validation, helpers, tests 2026-09-10 12:23:20 -07:00
Alex Holliday 131fd1a6b0 update tests 2026-09-10 11:58:26 -07:00
Alex Holliday f9bc02ed1c update msgs 2026-09-10 11:58:12 -07:00
Alex Holliday 1035e397e0 inject encryptionService into DockerProvider 2026-09-10 11:57:43 -07:00
Alex Holliday 9c788561a7 update docker provider to add a TLS path 2026-09-10 11:49:53 -07:00
Alex Holliday 449e96d842 add dockerTlsKey to context for docker type monitors 2026-09-10 11:30:26 -07:00
Alex Holliday c7626c669a Add docker tls key to check context 2026-09-10 11:29:41 -07:00
Alex Holliday ede5bc4ecd add docker TLS fields to type, model, access methods to repo 2026-09-10 11:15:20 -07:00
Alexander Holliday 8bb60bfd03 Merge pull request #3938 from bluewave-labs/feat/encryption-module
feat: encryption module
2026-09-10 10:43:02 -07:00
Alex Holliday f009aa17d9 Add encryption service and tests. Update README 2026-09-10 10:31:41 -07:00
Alexander Holliday ef7ac8bef8 Merge pull request #3936 from bluewave-labs/feat/docker-chart-tooltips
feat: add docker chart tooltips
2026-09-09 10:26:15 -07:00
Alex Holliday b75f895a1f add docker chart tooltips 2026-09-09 10:22:02 -07:00
Alexander Holliday 53f5b61e8e Merge pull request #3935 from bluewave-labs/fix/404-illustration-credit
fix(client): credit Annie Hsu on the 404 illustration
2026-09-09 09:37:30 -07:00
Alex Holliday 64a2c69540 convert styles 2026-09-09 09:35:20 -07:00
Alex Holliday c4ec14db5d add link to SVG 2026-09-09 09:28:40 -07:00
gorkem-bwl 4560820649 fix(client): balance the 404 credit colour across both themes
The signature was #9a9a9a, picked against the dark ground. The app
switches the whole tree between lightTheme and darkTheme at App.tsx:25
and the 404 page sits inside that, so it also renders on the light
background - where #9a9a9a manages only 2.64:1 and reads as washed out.

#787878 is the balance point: 4.13:1 on the dark ground (#151518) and
4.14:1 on the light one (#F6F8F7), chosen by maximising the worse of
the two rather than tuning for either. Checked by rendering on both.
2026-09-08 19:31:22 -04:00
gorkem-bwl 3051bc7edc fix(client): credit Annie Hsu on the 404 illustration
The sushi artwork on the 404 page was designed by Annie Hsu and carried
no attribution. Adds her name in the bottom-right corner, in a script
face at a size that reads as a signature rather than a label.

The glyphs are embedded as vector outlines rather than a `<text>`
element. The asset is loaded through an `<img>` tag in
Pages/NotFound/index.tsx, and an img-loaded SVG cannot fetch external
resources, so a web font would silently fall back to whatever cursive
face the viewer happens to have - different on every platform. Outlines
render identically everywhere and stay sharp at any size, which a raster
export would not.

Adds ~21KB to the asset.
2026-09-08 19:23:44 -04:00
Alexander Holliday 22232489a8 Merge pull request #3934 from bluewave-labs/fix/admin-gate-docker-logs
fix: admin gate docker logs
2026-09-08 15:59:17 -07:00
Alex Holliday 04277a6eb4 add admin check to fetch request 2026-09-08 15:55:17 -07:00
Alex Holliday a8dc867e78 add admin gate to API route 2026-09-08 15:52:43 -07:00
Alex Holliday 533783ba3e gate client side log page behind admin check 2026-09-08 15:51:17 -07:00
Alexander Holliday fd7b3e5f20 Merge pull request #3933 from bluewave-labs/feat/docker-state-palette
feat: docker state palette
2026-09-08 15:36:31 -07:00
Alex Holliday ae4b851620 pass palette to stat box for docker state 2026-09-08 15:33:50 -07:00
Alex Holliday e83f97a05a add function to translate docker state to palette keys 2026-09-08 15:33:31 -07:00
Alexander Holliday 070682af74 Merge pull request #3932 from bluewave-labs/fix/test-all-admin-guard
fix: add admin guard to test all button, fix tests
2026-09-08 11:01:21 -07:00
Alex Holliday 8609e4bca0 add admin guard to test all button, fix tests 2026-09-08 10:46:36 -07:00
Alexander Holliday 2661d7a384 Merge pull request #3922 from alishanahmad907/fix/rbac-auth
fix(api): enforce RBAC role checks on maintenance windows, notifications, and check deletion
2026-09-08 10:43:01 -07:00
Alexander Holliday 5b9d554f46 Merge pull request #3910 from bluewave-labs/fix/checks-table-copy-message
fix(client): don't navigate away when selecting text in a table row
2026-09-08 10:31:56 -07:00
Alex Holliday 7fb8bab4be purge narrative comment 2026-09-08 10:21:44 -07:00
Alexander Holliday 75d29124b6 Merge pull request #3909 from signalgridco/feat/signalgrid-notifications
feat: add Signalgrid notification provider
2026-09-08 10:14:19 -07:00
Alexander Holliday 1f0ea6cedc Merge pull request #3914 from eegrok/fix/maintenance-window-timezone-copy
fix Start Time description to accurately reflect that the time zone is local not UTC
2026-09-08 10:07:10 -07:00
Alexander Holliday 07a38b9c2c Merge pull request #3925 from egeoztass/fix/create-monitor-return-document
fix: return the created monitor from createMonitor
2026-09-08 10:03:34 -07:00
egeoztassandClaude Fable 5.1 67806c94b1 fix: return the created monitor from createMonitor
The controller writes the service's return value into the response, but
createMonitor was typed Promise<void> and returned nothing, so every
successful POST /monitors answered data: null and callers had no way to
read the new monitor's id.

Fixes #3917

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7eViZ2Np5ZpWUYNoRhmAt
2026-09-07 12:31:07 +02:00
Ali Shan Ahmad 2eeb088b61 [FIX]: rbac by adding the missing isAllowed(["admin", "superadmin"]) authorization middleware to mutating API endpoints for maintenance windows, notifications, and monitor check history. 2026-09-06 08:35:43 +00:00
gorkem-bwl 9c796edbde refactor(client): drop the copy button, keep the selection fix
The selection guard alone closes #3907: drag-selecting a check's error
message no longer navigates away, so the text can be copied. The copy
button was a second, larger answer to the same problem, and it brought a
reusable component, an HTTP clipboard fallback, four translation keys and
a timer to maintain.

Reverting it leaves the fix at ~29 lines in one file, and keeps the
behaviour change to the thing that was actually broken. CopyButton is
worth having for monitor URLs, tokens and incident ids, but it should
land on its own merits rather than inside a bug fix.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:24:07 -04:00
gorkem-bwl 84a1b994a1 fix(client): address review feedback on the copy-message change
Four issues from code review:

- The selection guard read the document-wide selection, so a leftover
  selection anywhere on the page could swallow the first click on any
  row. It now checks that the selection is inside the row that was
  clicked, with the event threaded through to both call sites.

- CopyButton's reset timer was never cleared, so it fired against an
  unmounted component when a row went away on pagination, and a rapid
  second click could not cancel the pending reset. Held in a ref and
  cleared on unmount and on re-copy.

- navigator.clipboard only exists on secure origins, and the README
  documents reaching Checkmate over plain HTTP on a LAN address. There
  the API is undefined, so every click showed "could not copy" with no
  way forward. Falls back to a hidden textarea.

- The two message cells were byte-identical, so they are now one shared
  CopyableText component.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:24:07 -04:00
gorkem-bwl db4689207a fix(client): make check error messages selectable and copyable
Closes #3907.

Selecting the text of a failed check's error message navigated away
before the selection could be copied. The mouseup that ends a drag-select
lands on the table row, which fires the row's onClick.

Two changes:

- DataTable ignores a row click when the user has an active text
  selection, so drag-selecting text inside any row no longer navigates.
  This applies to all eleven tables that pass onRowClick, not just checks.
- The message column gets an explicit copy button, which is the more
  discoverable of the two fixes the reporter suggested. Added as a shared
  CopyButton in design-elements so other columns can reuse it.

Note the row click on the uptime details checks table navigates to
/checks/<check id>, but that route's parameter is :monitorId. A check id
never matches a monitor, so the destination page renders empty - which is
the "all this information disappears" in the report. Left alone here to
keep this change reviewable; it needs its own decision about what that
row should link to.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:24:07 -04:00
eegrok af465766c8 fix Start Time description to accurately reflect that the time zone is local time, not UTC 2026-09-03 09:13:32 +12:00
fabiangigler c9b99eabb4 Localize monitor timing details 2026-09-02 20:15:39 +02:00
signalgridco c13f26a4c3 feat: add Signalgrid notification provider 2026-09-01 23:17:11 +02:00
80 changed files with 3304 additions and 1883 deletions

No files matched your search

+123 -8
View File
@@ -92,7 +92,7 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). Note that the Helm chart has not yet been migrated to the all-in-one image: it still deploys the legacy `checkmate-client`, `checkmate-backend` and `checkmate-mongo` images, pinned at v3.8.1.
### Configuration
@@ -104,7 +104,15 @@ The image is configured entirely through environment variables on the server con
| `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` |
| `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` |
| `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails |
| `ENCRYPTION_KEY` | No | Encrypts stored Docker TLS client keys at rest; generate one with `openssl rand -base64 32`. Comma-separated list: the first key encrypts, every key decrypts. Must be identical on the API and every worker. To rotate without downtime, deploy `OLD_KEY,NEW_KEY` everywhere, then `NEW_KEY,OLD_KEY` everywhere, wait for the worker to re-encrypt every row, then drop `OLD_KEY`. |
| `PORT` | No | Port the API and web client are served on (default `52345`) |
| `HEALTH_PORT` | No | Port for the `/livez`, `/readyz` and `/metrics` endpoints, served by any process that runs the job worker (default `52346`) |
| `NODE_ENV` | No | `development`, `production` or `test` (default `development`). `development` disables the general API rate limiter; set `production` on real deployments |
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
| `TOKEN_TTL` | No | Lifetime of issued auth tokens, e.g. `12h` or `7d` (default `99d`) |
| `QUEUE_MODE` | No | `primary` (default) runs the API, the web client and the job scheduler; `worker` runs a job-processing worker only, with no API |
| `QUEUE_PRIMARY_PROCESSES` | No | `true` (default) or `false`. Whether a `primary` node also processes monitoring jobs itself; set `false` when dedicated `worker` nodes handle all checks. Ignored in `worker` mode |
| `STATUS_PAGE_THEMES_ENABLED` | No | `true` (default) or `false`. When `false`, status pages ignore theme settings and always render the default theme |
The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables:
@@ -118,12 +126,119 @@ The web client needs no configuration by default: it calls the API on the same o
See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs).
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) (legacy images, pinned at v3.8.1), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
### Using a Custom CA
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options.
### Docker monitors
A Docker monitor connects to a Docker daemon and reports on every container it runs. The daemon's ping response decides whether the monitor is up or down and its latency is the response time. Each check also records every container's state, health, CPU and memory usage, restart count, published ports and mounts. Enabling **Collect container logs** additionally stores the latest 200 log lines per container on every check; logs are kept for 7 days.
The **Docker host** field accepts two forms:
| Host | Example | Notes |
|---|---|---|
| Local socket | `unix:///var/run/docker.sock` | Also accepts a bare absolute path such as `/var/run/docker.sock`. Use this for the daemon on the same machine Checkmate runs on. |
| Remote daemon | `tcp://docker.example.com:2376` | Always uses mutual TLS; the port defaults to `2376`. Unencrypted daemons on `2375` are not supported. |
**Monitoring the local socket.** The reference Compose file does not mount the socket, so add it and grant the container the host's `docker` group. The image runs as an unprivileged user and cannot read the socket otherwise. Find the group id with `stat -c %g /var/run/docker.sock`, then:
```yaml
services:
checkmate:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
group_add:
- "989" # the gid printed by stat
```
**Monitoring a remote daemon.** Set the host to `tcp://host:port` and fill in the **TLS credentials** section of the monitor form with the same PEM files you would pass to `docker --tlsverify`:
- **CA certificate**: the CA that signed the daemon's server certificate. Required unless **Ignore TLS/SSL errors** is on, which skips verifying the daemon's identity.
- **Client certificate**: the certificate the daemon uses to authenticate Checkmate.
- **Client key**: the matching private key, unencrypted (no passphrase). Checkmate checks that it matches the certificate, encrypts it with `ENCRYPTION_KEY`, and never shows it again. Leave the field blank when editing to keep the stored key.
TLS Docker monitors require `ENCRYPTION_KEY` to be set on the server (see [Configuration](#configuration)). Saving one without it fails with an error telling you so. If the key is ever removed or rotated incorrectly, affected checks fail with a decryption error until it is restored.
If the daemon's certificate is signed by a private CA that you also want the rest of Checkmate to trust, see the [Custom CA Trust Guide](./docs/custom-ca-trust.md); for Docker monitors alone, the **CA certificate** field is enough.
### Securing the Docker daemon with TLS
The Docker daemon does not enable TLS by default. Follow these steps on the Docker host to generate a CA, a server certificate, and a client certificate for Checkmate. This is the procedure from [Docker's own guide](https://docs.docker.com/engine/security/protect-access/), condensed. Replace `docker.example.com` and `203.0.113.10` with your daemon's DNS name and IP.
**1. Create a CA.** The CA key gets a passphrase; keep it offline once the certificates are issued.
```bash
openssl genrsa -aes256 -out ca-key.pem 4096
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj "/CN=docker-ca" -out ca.pem
```
**2. Create the server certificate.** The subject alternative names must cover every name or address Checkmate will use to reach the daemon.
```bash
openssl genrsa -out server-key.pem 4096
openssl req -subj "/CN=docker.example.com" -sha256 -new -key server-key.pem -out server.csr
cat > server-ext.cnf <<EOF
subjectAltName = DNS:docker.example.com,IP:203.0.113.10
extendedKeyUsage = serverAuth
EOF
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out server-cert.pem -extfile server-ext.cnf
```
**3. Create the client certificate for Checkmate.** Do not add a passphrase to this key; Checkmate cannot use encrypted private keys.
```bash
openssl genrsa -out key.pem 4096
openssl req -subj "/CN=checkmate" -new -key key.pem -out client.csr
echo "extendedKeyUsage = clientAuth" > client-ext.cnf
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out cert.pem -extfile client-ext.cnf
rm client.csr server.csr server-ext.cnf client-ext.cnf
chmod 0400 ca-key.pem key.pem server-key.pem
chmod 0444 ca.pem server-cert.pem cert.pem
```
**4. Point the daemon at the certificates.** Move `ca.pem`, `server-cert.pem` and `server-key.pem` to `/etc/docker/certs/` and configure `/etc/docker/daemon.json`:
```json
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
"tls": true,
"tlsverify": true,
"tlscacert": "/etc/docker/certs/ca.pem",
"tlscert": "/etc/docker/certs/server-cert.pem",
"tlskey": "/etc/docker/certs/server-key.pem"
}
```
On distributions where the systemd unit already passes `-H fd://` (Debian, Ubuntu and derivatives), the daemon refuses to start with `hosts` set in both places. Remove the flag from the unit with an override, then restart:
```bash
sudo systemctl edit docker.service
```
```ini
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd
```
```bash
sudo systemctl daemon-reload && sudo systemctl restart docker
```
Open port `2376` on the host firewall only to the machine running Checkmate.
**5. Verify from the Checkmate host**, then paste `ca.pem`, `cert.pem` and `key.pem` into the monitor form:
```bash
docker --tlsverify --tlscacert=ca.pem --tlscert=cert.pem --tlskey=key.pem \
-H=docker.example.com:2376 version
```
For more documentation, see the [docs directory](./docs/).
<a id="performance"></a>
@@ -133,7 +248,7 @@ Thanks to extensive optimizations, Checkmate operates with an exceptionally smal
![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9)
You can see the memory footprint of MongoDB and Redis on the same server (398Mb and 15Mb) for the same amount of servers:
You can see the memory footprint of MongoDB on the same server (398Mb) for the same amount of servers:
![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751)
@@ -151,16 +266,16 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
## Features
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server
- Several monitoring options: HTTP (with SSL certificate expiry), Ping, Port, DNS, Docker, gRPC, WebSocket, Game server
- Page speed monitoring
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
- Selective disk monitoring with mountpoint selection
- Incidents at a glance
- Status pages with 4 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Status pages with 5 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, ntfy, SignalGrid, Twilio (SMS) notifications
- Scheduled maintenance
- JSON query monitoring
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
- Multi-language support for Arabic, Catalan, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Italian, Japanese, Polish, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
## Monitor Lifecycle
@@ -213,7 +328,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Malena](https://github.com/malenacaroline) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K stars and attracted 150+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "client",
"version": "3.11.0",
"version": "3.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.11.0",
"version": "3.12.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "3.11.0",
"version": "3.12.0",
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
@@ -8,8 +8,11 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatDuration } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme();
const { t, i18n } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
if (!monitor) {
@@ -52,7 +55,12 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
<>
<Dot />
<Typography>
Checking every {formatDuration(monitor?.interval, true)}
{t("pages.common.monitors.statBoxes.checkingEvery", {
interval: formatDuration(monitor.interval, {
long: true,
locale: i18n.resolvedLanguage,
}),
})}
</Typography>
</>
)}
@@ -81,6 +81,21 @@ export function DataTable<
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const hasTextSelectionWithin = (element: Element) => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return false;
}
return (
selection.anchorNode !== null &&
element.contains(
selection.anchorNode.nodeType === Node.TEXT_NODE
? selection.anchorNode.parentNode
: selection.anchorNode
)
);
};
const isInteractive = Boolean(onRowClick) || expandableRows;
if (data.length === 0 || headers.length === 0) {
@@ -102,7 +117,10 @@ export function DataTable<
keys.push(key);
return (
<Stack
onClick={() => (onRowClick ? onRowClick(row) : null)}
onClick={(e) => {
if (hasTextSelectionWithin(e.currentTarget)) return;
if (onRowClick) onRowClick(row);
}}
spacing={theme.spacing(LAYOUT.XS)}
sx={{
borderStyle: "solid",
@@ -256,7 +274,8 @@ export function DataTable<
}),
...(getRowSx?.(row) as object),
}}
onClick={() => {
onClick={(e) => {
if (hasTextSelectionWithin(e.currentTarget)) return;
if (expandableRows) handleExpand(row);
else if (onRowClick) onRowClick(row);
}}
@@ -5,9 +5,10 @@ import {
type FieldValues,
} from "react-hook-form";
import { Autocomplete } from "@/Components/inputs";
import type { AutocompleteProps } from "@mui/material/Autocomplete";
export interface AutocompleteOption {
id: string;
id: string | number;
name: string;
}
@@ -16,6 +17,9 @@ interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOpt
options: O[];
fieldLabel?: string;
placeholder?: string;
filterOptions?: AutocompleteProps<O, false, false, false>["filterOptions"];
getOptionDisabled?: AutocompleteProps<O, false, false, false>["getOptionDisabled"];
disableClearable?: boolean;
}
export const FormAutocompleteField = <
@@ -26,6 +30,9 @@ export const FormAutocompleteField = <
options,
fieldLabel,
placeholder,
filterOptions,
getOptionDisabled,
disableClearable,
}: FormAutocompleteProps<T, O>) => {
const { control } = useFormContext<T>();
return (
@@ -39,10 +46,14 @@ export const FormAutocompleteField = <
isOptionEqualToValue={(option, value) => option.id === value.id}
value={options.find((o) => o.id === field.value) ?? null}
onChange={(_: unknown, newValue: O | null) => {
if (newValue === null && disableClearable) return;
field.onChange(newValue?.id ?? "");
}}
fieldLabel={fieldLabel}
placeholder={placeholder}
filterOptions={filterOptions}
getOptionDisabled={getOptionDisabled}
disableClearable={disableClearable}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
@@ -60,20 +60,22 @@ export const HeaderMonitorControls = ({
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)}
>
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
{isAdmin && (
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
)}
<Button
variant="contained"
color="secondary"
@@ -21,7 +21,7 @@ export const MonitorStatBoxes = ({
domainExpiry,
}: MonitorStatBoxesProps) => {
const theme = useTheme();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
if (!monitorStats || !monitor) {
return null;
}
@@ -29,13 +29,16 @@ export const MonitorStatBoxes = ({
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
// Determine time since last check
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
const streakTime = formatDuration(timeSinceLastFailure);
const lastCheckTime = formatDuration(timeSinceLastCheck);
const lastCheckTime = timeOfLastCheck
? formatDuration(Date.now() - timeOfLastCheck, {
long: true,
relative: true,
locale: i18n.resolvedLanguage,
})
: t("common.labels.na");
const isActive =
monitor?.status === "up" ||
monitor?.status === "paused" ||
@@ -1,15 +1,58 @@
import { BaseChart } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
import { BaseChart, BaseBox } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
import Typography from "@mui/material/Typography";
// Types
import type { DockerContainerStats } from "@/Types/Monitor";
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import type { Theme } from "@mui/material/styles";
import type { RootState } from "@/Types/state";
// Hooks
import { useTheme } from "@mui/material";
import { Fragment, useId } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
// Utils
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
import { tooltipDateFormatLookup, formatDateWithTz } from "@/Utils/TimeUtils";
import type { DateRange } from "@/Types/Query";
type DockerHistogramToolTipProps = TooltipProps<ValueType, NameType> & {
dataKey: string;
range: string;
theme: Theme;
uiTimezone: string;
valueFormatter: (value: number) => string;
labelKey: string;
};
const DockerHistogramToolTip = ({
dataKey,
active,
payload,
range,
theme,
uiTimezone,
valueFormatter,
labelKey,
}: DockerHistogramToolTipProps) => {
const { t } = useTranslation();
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
const ts = payload[0].payload?._id ?? null;
const dataPoint = payload[0].payload?.[dataKey] ?? null;
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(ts, format, uiTimezone)}</Typography>
<Typography>{t(labelKey, { value: valueFormatter(dataPoint) })}</Typography>
</BaseBox>
);
};
interface HistogramDockerContainerProps {
title: string;
rightTitle: string;
@@ -17,6 +60,9 @@ interface HistogramDockerContainerProps {
dataKey: string;
strokeColor: string;
gradientStartColor: string;
range: DateRange;
formatter: (value: number) => string;
labelKey: string;
}
export const HistogramDockerContainer = ({
@@ -26,11 +72,14 @@ export const HistogramDockerContainer = ({
dataKey,
strokeColor,
gradientStartColor,
range,
formatter,
labelKey,
}: HistogramDockerContainerProps) => {
const uniqueId = useId();
const theme = useTheme();
const gradientId = `gradient-${uniqueId}`;
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<BaseChart
icon="null"
@@ -56,6 +105,19 @@ export const HistogramDockerContainer = ({
endColor: "transparent",
direction: "vertical",
})}
<Tooltip
content={(props) => (
<DockerHistogramToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
dataKey={dataKey}
valueFormatter={formatter}
labelKey={labelKey}
/>
)}
/>
<Area
dataKey={dataKey}
type="monotone"
+6
View File
@@ -77,6 +77,12 @@ export const getMonitorDefaults = (
...base,
type: "docker",
url: data?.url || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
dockerTlsCa: data?.dockerTlsCa || "",
dockerTlsCert: data?.dockerTlsCert || "",
// The key is write-only; the API never returns it.
dockerTlsKey: "",
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
};
break;
+130 -12
View File
@@ -38,6 +38,7 @@ import {
PageSpeedStrategies,
DnsRecordTypes,
MonitorIntervalOptions,
MIN_MONITOR_INTERVAL_MS,
DefaultMonitorMatchMethod,
MonitorMatchMethods,
GeoCheckIntervalOptions,
@@ -50,6 +51,7 @@ import type { AppSettingsResponse } from "@/Types/Settings";
import {
stepFieldsFor,
monitorStepCount,
isDockerTlsUrl,
type MonitorFormData,
} from "@/Validation/monitor";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
@@ -57,15 +59,62 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import {
FormAutocompleteField,
type AutocompleteOption,
} from "@/Components/inputs/forms/FormAutoCompleteField";
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
import type { ProxyResponse } from "@/Types/Proxy";
import { formatDuration } from "@/Utils/TimeUtils";
const httpMethodOptions = HttpMethods.map((method) => ({
value: method,
label: method,
}));
const PEM_FIELD_ROWS = 6;
// Hides the pasted key while the field is not focused. Ignored by browsers
// without -webkit-text-security, which then show the key as plain text.
const maskedInputSx = {
"&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
};
interface IntervalOption extends AutocompleteOption {
id: number;
}
const INTERVAL_UNIT_MULTIPLIERS = [1000, 60000, 3600000, 86400000] as const;
const toIntervalOption = (
intervalMs: number,
locale: string | undefined
): IntervalOption => ({
id: intervalMs,
name: formatDuration(intervalMs, { long: true, locale }),
});
const filterIntervalOptions = (
options: IntervalOption[],
inputValue: string,
locale: string | undefined
): IntervalOption[] => {
const input = inputValue.trim();
if (input === "" || options.some((option) => option.name === input)) {
return options;
}
const amount = Number(input);
if (!Number.isFinite(amount) || amount <= 0) {
return [];
}
return INTERVAL_UNIT_MULTIPLIERS.map((multiplier) => amount * multiplier)
.filter(Number.isSafeInteger)
.map((ms) => toIntervalOption(ms, locale));
};
interface GeneralSettingsConfig {
urlLabel: string;
urlPlaceholder: string;
@@ -243,7 +292,7 @@ const getGeneralSettingsConfig = (
const CreateMonitorPage = () => {
const theme = useTheme();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { monitorId } = useParams();
const location = useLocation();
const navigate = useNavigate();
@@ -300,10 +349,12 @@ const CreateMonitorPage = () => {
const showStep = (step: number) => isEditMode || currentStep === step;
const watchedType = watch("type") as MonitorType;
const watchedUrl = watch("url") as string;
const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
const watchedInterval = watch("interval");
// Steps without an advanced section drop it, so the last step is the form's.
const totalSteps = monitorStepCount(watchedType);
@@ -334,6 +385,8 @@ const CreateMonitorPage = () => {
[watchedType, t]
);
const pemInputSx = { fontFamily: theme.typography.fontFamilyMonospace };
const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
const isSubmitting = isCreating || isUpdating;
@@ -459,16 +512,18 @@ const CreateMonitorPage = () => {
[t]
);
const intervalOptions = useMemo(
() =>
MonitorIntervalOptions.map((option) => ({
value: option.value,
label: t(
`pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}`
),
})),
[t]
);
const intervalOptions = useMemo(() => {
const options = MonitorIntervalOptions.map(({ value }) =>
toIntervalOption(value, i18n.resolvedLanguage)
);
if (
typeof watchedInterval === "number" &&
!options.some((option) => option.id === watchedInterval)
) {
options.push(toIntervalOption(watchedInterval, i18n.resolvedLanguage));
}
return options;
}, [watchedInterval, i18n.resolvedLanguage]);
const matchMethodOptions = useMemo(
() =>
@@ -689,17 +744,80 @@ const CreateMonitorPage = () => {
/>
)}
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
<ConfigBox
title={t("pages.createMonitor.form.dockerTls.title")}
subtitle={t("pages.createMonitor.form.dockerTls.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="dockerTlsCa"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.ca.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.ca.placeholder"
)}
slotProps={{ input: { sx: pemInputSx } }}
/>
<FormTextField
name="dockerTlsCert"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.cert.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.cert.placeholder"
)}
slotProps={{ input: { sx: pemInputSx } }}
/>
<FormTextField
name="dockerTlsKey"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.key.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.key.placeholder"
)}
helperText={
existingMonitor?.dockerTlsKeySet
? t("pages.createMonitor.form.dockerTls.option.key.stored")
: undefined
}
autoComplete="off"
slotProps={{ input: { sx: { ...pemInputSx, ...maskedInputSx } } }}
/>
<FormSwitchField
name="ignoreTlsErrors"
label={t("pages.createMonitor.form.dockerTls.option.ignoreTls.label")}
/>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t("pages.createMonitor.form.dockerTls.option.ignoreTls.description")}
</Typography>
</Stack>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.createMonitor.form.frequency.title")}
subtitle={t("pages.createMonitor.form.frequency.description")}
rightContent={
<FormSelectField
<FormAutocompleteField
name="interval"
fieldLabel={t(
"pages.createMonitor.form.frequency.option.frequency.label"
)}
options={intervalOptions}
filterOptions={(options, { inputValue }) =>
filterIntervalOptions(options, inputValue, i18n.resolvedLanguage)
}
getOptionDisabled={(option) => option.id < MIN_MONITOR_INTERVAL_MS}
disableClearable
/>
}
/>
Whitespace-only changes.
@@ -10,6 +10,7 @@ import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatDuration } from "@/Utils/TimeUtils";
import { getDockerPalette } from "@/Utils/MonitorUtils";
export const DockerContainerStatusBoxes = ({
container,
@@ -24,6 +25,7 @@ export const DockerContainerStatusBoxes = ({
const { state, restartCount, health } = container;
const startedAt = container.startedAt ?? "0";
const runningFor = Date.now() - new Date(startedAt).getTime();
const palette = getDockerPalette(state);
return (
<Stack
direction="row"
@@ -33,6 +35,7 @@ export const DockerContainerStatusBoxes = ({
<StatBox
title={t("common.labels.state")}
subtitle={state}
palette={palette}
/>
<StatBox
title={t("common.labels.uptime")}
@@ -23,6 +23,7 @@ import {
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useToast } from "@/Hooks/UseToast";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
// Utils
import { get } from "@/Utils/ApiClient";
@@ -50,6 +51,7 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
const theme = useTheme();
const { t } = useTranslation();
const { toastError } = useToast();
const isAdmin = useIsAdmin();
// logs is newest-first, matching the API and what flattenDockerLogs expects.
const [logs, setLogs] = useState<DockerLog[]>([]);
@@ -59,7 +61,7 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
const logsUrl =
enabled && monitorId && containerName
isAdmin && enabled && monitorId && containerName
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
: null;
@@ -168,6 +170,15 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
}
}, [logsUrl, oldestCursor, loadingOlder, t, toastError]);
if (!isAdmin) {
return (
<EmptyState
title={t("pages.docker.container.logs.forbidden.title")}
description={t("pages.docker.container.logs.forbidden.description")}
/>
);
}
if (!enabled) {
return (
<EmptyState
@@ -17,6 +17,7 @@ import { formatPercentage } from "@/Utils/FormatUtils";
import prettyBytes from "pretty-bytes";
import { BaseBox } from "@/Components/design-elements";
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
import type { DateRange } from "@/Types/Query";
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
{
@@ -24,20 +25,29 @@ const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats
key: "avgCpuPct",
color: theme.palette.success.main,
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
formatter: formatPercentage,
labelKey: "common.charts.histogram.avgCpuUsage",
},
{
title: t("common.charts.labels.memoryUsage"),
key: "avgMemoryUsedBytes",
color: theme.palette.primary.main,
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
formatter: prettyBytes,
labelKey: "common.charts.histogram.avgMemoryUsage",
},
];
export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
export const TabOverview = ({
stats,
range,
}: {
stats: DockerContainerStats;
range: DateRange;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { ports, mounts } = stats?.latest?.container ?? {};
const dedupedPorts = dedupeDockerPorts(ports ?? []);
return (
@@ -58,6 +68,9 @@ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
dataKey={config.key}
strokeColor={config.color}
gradientStartColor={config.color}
range={range}
formatter={config.formatter}
labelKey={config.labelKey}
/>
</Grid>
);
@@ -21,7 +21,7 @@ const TAB_MAP = {
logs: 1,
} as const;
const DockerDetailsPage = ({ open = "logs" }: DockerContainerDetailsProps) => {
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
const [dateRange, setDateRange] = useState<DateRange>("recent");
const { monitorId, containerName } = useParams<{
@@ -69,7 +69,12 @@ const DockerDetailsPage = ({ open = "logs" }: DockerContainerDetailsProps) => {
<Tab label={t("pages.docker.container.tabs.overview")} />
<Tab label={t("pages.docker.container.tabs.logs")} />
</Tabs>
{activeTab === 0 && <TabOverview stats={stats} />}
{activeTab === 0 && (
<TabOverview
stats={stats}
range={dateRange}
/>
)}
{activeTab === 1 && (
<TabLogs
monitorId={monitorId}
+6 -5
View File
@@ -1,4 +1,4 @@
import NotFoundSvg from "@/assets/Images/sushi_404.svg";
import NotFoundSvg from "@/assets/Images/sushi_404.svg?react";
import { Button } from "@/Components/inputs";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
@@ -36,10 +36,11 @@ const NotFoundPage = ({ title, desc }: NotFoundProps) => {
alignItems="center"
>
<Box
component="img"
src={NotFoundSvg}
alt="404"
maxHeight={"25rem"}
component={NotFoundSvg}
role="img"
aria-label="404"
width="100%"
maxHeight="25rem"
/>
<Typography
component="h1"
@@ -126,6 +126,7 @@ const NotificationsCreatePage = () => {
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "signalgrid" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
@@ -201,6 +202,31 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "signalgrid" && (
<ConfigBox
title={t("pages.notifications.form.signalgrid.title")}
subtitle={t("pages.notifications.form.signalgrid.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.signalgrid.optionClientKey")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderClientKey"
)}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.signalgrid.optionChannel")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderChannel"
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
+5
View File
@@ -60,6 +60,8 @@ export const monitorTypeLabelKey: Record<SelectableMonitorType, string> &
pagespeed: "optionPagespeed",
};
export const MIN_MONITOR_INTERVAL_MS = 10000;
export const MonitorIntervalOptions = [
{ value: 15000, labelKey: "fifteenSeconds" },
{ value: 30000, labelKey: "thirtySeconds" },
@@ -160,6 +162,9 @@ export interface Monitor {
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dockerLogsEnabled?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
dockerTlsKeySet?: boolean;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
+1
View File
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
+8
View File
@@ -40,6 +40,14 @@ export const getStatusPalette = (status: MonitorStatus): PaletteKey => {
return "warning";
};
export const getDockerPalette = (dockerState: DockerContainerState): PaletteKey => {
if (dockerState === "created") return "success";
if (dockerState === "running") return "success";
if (dockerState === "dead") return "error";
if (dockerState === "exited") return "error";
return "warning";
};
export const getValuePalette = (value: ValueType): PaletteKey => {
const paletteMap: Record<ValueType, PaletteKey> = {
positive: "success",
+46 -3
View File
@@ -104,9 +104,52 @@ export const formatMs = (ms: number, hasSpace: boolean = true): string => {
return formatted;
};
export const formatDuration = (ms: number, verbose: boolean = false, hasSpace = true) => {
if (verbose) {
return prettyMilliseconds(ms, { verbose: true });
type DurationUnit = "day" | "hour" | "minute" | "second";
interface FormatDurationOptions {
long?: boolean;
relative?: boolean;
hasSpace?: boolean;
locale?: string;
}
const DURATION_UNITS: Array<{ unit: DurationUnit; milliseconds: number }> = [
{ unit: "day", milliseconds: MS_PER_DAY },
{ unit: "hour", milliseconds: MS_PER_HOUR },
{ unit: "minute", milliseconds: MS_PER_MINUTE },
{ unit: "second", milliseconds: MS_PER_SECOND },
];
const FALLBACK_DURATION_UNIT = DURATION_UNITS[DURATION_UNITS.length - 1];
const getDurationUnit = (ms: number) =>
DURATION_UNITS.find(({ milliseconds }) => Math.abs(ms) >= milliseconds) ??
FALLBACK_DURATION_UNIT;
const getExactDurationUnit = (ms: number) =>
DURATION_UNITS.find(({ milliseconds }) => ms !== 0 && ms % milliseconds === 0) ??
FALLBACK_DURATION_UNIT;
export const formatDuration = (
ms: number,
{ long = false, relative = false, hasSpace = true, locale }: FormatDurationOptions = {}
): string => {
if (relative) {
const { unit, milliseconds } = getDurationUnit(ms);
const value = Math.sign(ms) * Math.floor(Math.abs(ms) / milliseconds);
return new Intl.RelativeTimeFormat(locale, {
numeric: "always",
style: long ? "long" : "narrow",
}).format(-value, unit);
}
if (long) {
const { unit, milliseconds } = getExactDurationUnit(ms);
return new Intl.NumberFormat(locale, {
style: "unit",
unit,
unitDisplay: "long",
}).format(ms / milliseconds);
}
const formatted = prettyMilliseconds(ms, {
+51 -4
View File
@@ -6,6 +6,7 @@ import {
HttpMethods,
PageSpeedStrategies,
ProxyModes,
MIN_MONITOR_INTERVAL_MS,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
@@ -27,7 +28,8 @@ const baseSchema = z.object({
description: z.string().optional(),
interval: z
.number()
.min(15000, "Interval must be at least 15 seconds")
.int("Interval must be a whole number of milliseconds")
.min(MIN_MONITOR_INTERVAL_MS, "Interval must be at least 10 seconds")
.register(monitorStepRegistry, { step: 1 }),
notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
@@ -108,10 +110,30 @@ const portSchema = baseSchema.extend({
.max(65535, "Port must be at most 65535"),
});
// Docker monitor schema
// Docker monitor schema. The url forms mirror DOCKER_TLS_URL and
// DOCKER_SOCKET_URL in server/src/utils/dockerHost.ts.
const dockerTlsUrlRegex = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
const dockerSocketUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
export const isDockerTlsUrl = (url: string | undefined): boolean =>
dockerTlsUrlRegex.test(url?.trim() ?? "");
const dockerSchema = baseSchema.extend({
type: z.literal("docker"),
url: z.string().min(1, "Docker host URL is required"),
url: z
.string()
.min(1, "Docker host URL is required")
.refine(
(url) => dockerSocketUrlRegex.test(url.trim()) || isDockerTlsUrl(url),
"Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]"
),
ignoreTlsErrors: z.boolean(),
dockerTlsCa: z.string(),
dockerTlsCert: z.string(),
// blank on edit keeps the stored key.
dockerTlsKey: z.string(),
// Read only mirror of the server flag, stripped on submit
dockerTlsKeySet: z.boolean(),
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
});
@@ -217,7 +239,8 @@ const monitorSchemaUnion = z.discriminatedUnion("type", [
dnsSchema,
]);
// Mirrors the server's refineProxySelection (monitorValidation.ts)
// Mirrors the server's refineProxySelection and refineDockerTls
// (monitorValidation.ts). PEM contents are only parsed on the server.
export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) {
ctx.addIssue({
@@ -226,6 +249,30 @@ export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
message: "A proxy must be selected when proxy mode is custom",
});
}
if (data.type === "docker" && isDockerTlsUrl(data.url)) {
if (!data.dockerTlsCert.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsCert"],
message: "Client certificate is required for a TLS Docker host",
});
}
if (!data.dockerTlsKeySet && !data.dockerTlsKey.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsKey"],
message: "Client key is required for a TLS Docker host",
});
}
if (!data.ignoreTlsErrors && !data.dockerTlsCa.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsCa"],
message: "CA certificate is required unless TLS errors are ignored",
});
}
}
});
export type MonitorFormData = z.infer<typeof monitorSchema>;
+7
View File
@@ -70,6 +70,12 @@ const pushoverSchema = baseSchema.extend({
accessToken: z.string().min(1, "App token is required"),
});
const signalgridSchema = baseSchema.extend({
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
});
const twilioSchema = baseSchema.extend({
type: z.literal("twilio"),
accountSid: z.string().min(1, "Account SID is required"),
@@ -95,6 +101,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
teamsSchema,
telegramSchema,
pushoverSchema,
signalgridSchema,
twilioSchema,
ntfySchema,
]);
File diff suppressed because it is too large. Load diff

Before

Width:  |  Height:  |  Size: 536 KiB

After

Width:  |  Height:  |  Size: 286 KiB

+1
View File
@@ -448,6 +448,7 @@
},
"statBoxes": {
"activeFor": "Aktiv seit",
"checkingEvery": "Check alle {{interval}}",
"certificateExpiry": "Zertifikatablauf",
"lastCheck": "Letzter Check",
"lastResponseTime": "Letzte Antwortzeit",
+42 -3
View File
@@ -80,7 +80,9 @@
"avg": "Avg: {{value}}",
"max": "Max: {{value}}",
"responseTime": "Response time: {{value}}",
"total": "Total: {{value}}"
"total": "Total: {{value}}",
"avgCpuUsage": "Average CPU usage: {{value}}",
"avgMemoryUsage": "Average memory usage: {{value}}"
},
"phases": {
"dns": "DNS",
@@ -545,6 +547,7 @@
},
"statBoxes": {
"activeFor": "Active for",
"checkingEvery": "Checking every {{interval}}",
"certificateExpiry": "Certificate expiry",
"domainExpiry": "Domain expiry",
"lastCheck": "Last check",
@@ -716,7 +719,7 @@
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.",
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
"docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock. Every container on the host is monitored.",
"docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock, or a remote daemon as tcp://host:2376 to connect with TLS client certificates. Every container on the host is monitored.",
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the <gamedigLink>supported games list</gamedigLink> for protocol details.",
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
@@ -832,6 +835,29 @@
"description": "This will permanently delete all monitoring history, statistics, and check data for your team. This action cannot be undone.",
"confirm": "Yes, clear all stats"
}
},
"dockerTls": {
"title": "TLS credentials",
"description": "Connect to a remote Docker daemon over mutual TLS. Paste the PEM files you would pass to docker --tlsverify. The client key is encrypted before it is stored and is never shown again.",
"option": {
"ca": {
"label": "CA certificate",
"placeholder": "-----BEGIN CERTIFICATE-----"
},
"cert": {
"label": "Client certificate",
"placeholder": "-----BEGIN CERTIFICATE-----"
},
"key": {
"label": "Client key",
"placeholder": "-----BEGIN PRIVATE KEY-----",
"stored": "A key is stored. Leave blank to keep it."
},
"ignoreTls": {
"label": "Ignore TLS/SSL errors",
"description": "Skips verifying the daemon's certificate, so the CA certificate becomes optional. The client certificate and key are still required because the daemon verifies them."
}
}
}
}
},
@@ -985,6 +1011,10 @@
"title": "Log collection is off for this host",
"description": "Turn on container logs in the monitor settings to start collecting.",
"action": "Configure monitor"
},
"forbidden": {
"title": "You don't have access to container logs",
"description": "Only administrators can view logs. Ask an administrator for access if you need to see them."
}
}
},
@@ -1247,7 +1277,7 @@
},
"startTime": {
"title": "Start time",
"description": "Set the start time and duration for your maintenance window. All values are in UTC",
"description": "Set the start time and duration for your maintenance window. All values are in your local time zone.",
"option": {
"duration": {
"label": "Duration"
@@ -1329,6 +1359,7 @@
"teams": "Teams",
"telegram": "Telegram",
"pushover": "Pushover",
"signalgrid": "Signalgrid",
"twilio": "Twilio",
"ntfy": "ntfy"
}
@@ -1349,6 +1380,14 @@
"optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
},
"signalgrid": {
"title": "Signalgrid configuration",
"description": "Configure Signalgrid to receive push notifications on your devices.",
"optionClientKey": "Client key",
"placeholderClientKey": "Your Signalgrid client key",
"optionChannel": "Channel",
"placeholderChannel": "Your Signalgrid channel"
},
"twilio": {
"title": "Twilio SMS configuration",
"description": "Configure Twilio to send SMS notifications to a phone number.",
+1
View File
@@ -9,6 +9,7 @@ services:
- DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db
- CLIENT_HOST=${CLIENT_HOST:-http://localhost:52345}
- JWT_SECRET=${JWT_SECRET:?set JWT_SECRET in your environment}
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-}
stop_grace_period: 60s
healthcheck:
test:
+11
View File
@@ -27,6 +27,17 @@ CLIENT_HOST=http://localhost:5173
# ----------------------
ORIGIN=localhost
# Encryption
# ------------------
# Encrypts stored Docker TLS client keys. Not needed for socket-only Docker monitors.
# Generate a key: openssl rand -base64 32
# Key list: comma-separated; the first key encrypts, every key decrypts.
# Split deployments: set the same value on the API and every worker.
# Rotate a key: 1. deploy OLD_KEY,NEW_KEY everywhere so every process can read both
# 2. deploy NEW_KEY,OLD_KEY everywhere so new writes use the new key
# 3. wait for the worker to re-encrypt every row, then drop OLD_KEY
# ENCRYPTION_KEY=<output of openssl rand -base64 32>
# Feature Flags
# -------------
# Set to "false" to disable the selectable theme feature on status pages.
+385 -21
View File
@@ -422,9 +422,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
@@ -466,6 +463,18 @@
"geoCheckInterval": {
"type": "number"
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
"dockerTlsCert": {
"type": "string"
},
"dockerTlsKeySet": {
"type": "boolean"
},
"dnsServer": {
"type": "string"
},
@@ -513,6 +522,7 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -640,9 +650,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
@@ -684,6 +691,18 @@
"geoCheckInterval": {
"type": "number"
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
"dockerTlsCert": {
"type": "string"
},
"dockerTlsKeySet": {
"type": "boolean"
},
"dnsServer": {
"type": "string"
},
@@ -733,6 +752,7 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -929,6 +949,9 @@
{
"$ref": "#/components/schemas/PushoverNotification"
},
{
"$ref": "#/components/schemas/SignalgridNotification"
},
{
"$ref": "#/components/schemas/TwilioNotification"
},
@@ -949,6 +972,7 @@
"teams": "#/components/schemas/TeamsNotification",
"telegram": "#/components/schemas/TelegramNotification",
"pushover": "#/components/schemas/PushoverNotification",
"signalgrid": "#/components/schemas/SignalgridNotification",
"twilio": "#/components/schemas/TwilioNotification",
"ntfy": "#/components/schemas/NtfyNotification"
}
@@ -1380,6 +1404,34 @@
"accessToken": "a1234567890abcdef"
}
},
"SignalgridNotification": {
"type": "object",
"properties": {
"notificationName": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"enum": ["signalgrid"]
},
"address": {
"type": "string",
"minLength": 1
},
"accessToken": {
"type": "string",
"minLength": 1
}
},
"required": ["notificationName", "type", "address", "accessToken"],
"example": {
"notificationName": "Signalgrid",
"type": "signalgrid",
"address": "your-channel",
"accessToken": "your-client-key"
}
},
"TwilioNotification": {
"type": "object",
"properties": {
@@ -7090,9 +7142,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
@@ -7134,6 +7183,18 @@
"geoCheckInterval": {
"type": "number"
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
"dockerTlsCert": {
"type": "string"
},
"dockerTlsKeySet": {
"type": "boolean"
},
"dnsServer": {
"type": "string"
},
@@ -7183,6 +7244,7 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -7655,9 +7717,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
@@ -7699,6 +7758,18 @@
"geoCheckInterval": {
"type": "number"
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
"dockerTlsCert": {
"type": "string"
},
"dockerTlsKeySet": {
"type": "boolean"
},
"dnsServer": {
"type": "string"
},
@@ -7748,6 +7819,7 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -7984,6 +8056,231 @@
}
}
},
"/monitors/docker/details/{monitorId}/containers/{containerName}/logs": {
"get": {
"tags": ["monitors"],
"summary": "Get stored log lines for one container on a Docker host monitor, newest first",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"schema": {
"type": "string",
"minLength": 1
},
"required": true,
"name": "monitorId",
"in": "path"
},
{
"schema": {
"type": "string",
"minLength": 1
},
"required": true,
"name": "containerName",
"in": "path"
},
{
"schema": {
"type": "string",
"format": "date-time"
},
"required": false,
"name": "before",
"in": "query"
},
{
"schema": {
"type": "string",
"format": "date-time"
},
"required": false,
"name": "after",
"in": "query"
},
{
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 20
},
"required": false,
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [true]
},
"msg": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"logs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"metadata": {
"type": "object",
"properties": {
"monitorId": {
"type": "string"
},
"teamId": {
"type": "string"
},
"containerId": {
"type": "string"
},
"containerName": {
"type": "string"
}
},
"required": ["monitorId", "teamId", "containerId", "containerName"]
},
"lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"ts": {
"type": "string"
},
"stream": {
"type": "string",
"enum": ["stdout", "stderr"]
},
"text": {
"type": "string"
}
},
"required": ["ts", "stream", "text"]
}
},
"gap": {
"type": "boolean"
},
"checkedAt": {
"type": "string"
},
"expiry": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": ["id", "metadata", "lines", "gap", "checkedAt", "expiry", "createdAt", "updatedAt"]
}
},
"nextCursor": {
"type": "string",
"nullable": true
}
},
"required": ["logs", "nextCursor"]
}
},
"required": ["success", "msg", "data"]
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
}
}
}
},
"/monitors/pagespeed/details/{monitorId}": {
"get": {
"tags": ["monitors"],
@@ -8908,9 +9205,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"jsonPath": {
"anyOf": [
{
@@ -9011,6 +9305,42 @@
"type": "number",
"minimum": 300000
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dockerTlsCert": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dockerTlsKey": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dnsServer": {
"type": "string",
"minLength": 1
@@ -9604,9 +9934,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number",
"default": 100
@@ -9702,6 +10029,10 @@
"minimum": 300000,
"default": 300000
},
"dockerLogsEnabled": {
"type": "boolean",
"default": false
},
"dnsServer": {
"type": "string",
"minLength": 1
@@ -10137,9 +10468,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"ignoreTlsErrors": {
"type": "boolean"
},
@@ -10269,6 +10597,42 @@
"type": "number",
"minimum": 300000
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dockerTlsCert": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dockerTlsKey": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dnsServer": {
"type": "string",
"minLength": 1
+4
View File
@@ -65,6 +65,10 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
component: "PushoverNotification",
example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" },
},
signalgrid: {
component: "SignalgridNotification",
example: { notificationName: "Signalgrid", type: "signalgrid", address: "your-channel", accessToken: "your-client-key" },
},
twilio: {
component: "TwilioNotification",
example: {
+2 -1
View File
@@ -5,6 +5,7 @@ import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from
import {
registrationBodyValidation,
registerInviteTokenValidation,
loginValidation,
recoveryValidation,
recoveryTokenBodyValidation,
@@ -48,7 +49,7 @@ class AuthController implements IAuthController {
registerUser = catchAsync(async (req: Request, res: Response) => {
const newUser = req.body.user;
const newUserToken = req.body.token;
const newUserToken = registerInviteTokenValidation.parse(req.body.token);
if (newUser?.email) {
const newUserEmail = requireUserEmail(newUser.email);
newUser.email = newUserEmail.toLowerCase();
+1 -1
View File
@@ -9,6 +9,6 @@ export const createCheckRoutes = (checkController: ICheckController): Router =>
router.get("/team", checkController.getChecksByTeam);
router.delete("/team", isAllowed(["admin", "superadmin"]), checkController.deleteChecksByTeamId);
router.get("/:monitorId", checkController.getChecksByMonitor);
router.delete("/:monitorId", checkController.deleteChecks);
router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), checkController.deleteChecks);
return router;
};
@@ -1,15 +1,16 @@
import { IMaintenanceWindowController } from "@/api/controllers/maintenanceWindowController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createMaintenanceWindowRoutes = (maintenanceWindowController: IMaintenanceWindowController): Router => {
const router = Router();
router.post("/", maintenanceWindowController.createMaintenanceWindows);
router.post("/", isAllowed(["admin", "superadmin"]), maintenanceWindowController.createMaintenanceWindows);
router.get("/team/", maintenanceWindowController.getMaintenanceWindowsByTeamId);
router.get("/monitor/:monitorId", maintenanceWindowController.getMaintenanceWindowsByMonitorId);
router.get("/:id", maintenanceWindowController.getMaintenanceWindowById);
router.patch("/:id", maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", maintenanceWindowController.deleteMaintenanceWindow);
router.patch("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.deleteMaintenanceWindow);
return router;
};
+5 -1
View File
@@ -21,7 +21,11 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout
// Docker routes
router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById);
router.get("/docker/details/:monitorId/containers/:containerName", monitorController.getDockerContainerByName);
router.get("/docker/details/:monitorId/containers/:containerName/logs", monitorController.getDockerContainerLogs);
router.get(
"/docker/details/:monitorId/containers/:containerName/logs",
isAllowed(["admin", "superadmin"]),
monitorController.getDockerContainerLogs
);
// Geo checks routes
router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId);
+6 -5
View File
@@ -1,14 +1,15 @@
import { INotificationController } from "@/api/controllers/notificationController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createNotificationRoutes = (notificationController: INotificationController): Router => {
const router = Router();
router.post("/", notificationController.createNotification);
router.post("/test/all", notificationController.testAllNotifications);
router.post("/test", notificationController.testNotification);
router.post("/", isAllowed(["admin", "superadmin"]), notificationController.createNotification);
router.post("/test/all", isAllowed(["admin", "superadmin"]), notificationController.testAllNotifications);
router.post("/test", isAllowed(["admin", "superadmin"]), notificationController.testNotification);
router.get("/team", notificationController.getNotificationsByTeamId);
router.get("/:id", notificationController.getNotificationById);
router.delete("/:id", notificationController.deleteNotification);
router.patch("/:id", notificationController.editNotification);
router.delete("/:id", isAllowed(["admin", "superadmin"]), notificationController.deleteNotification);
router.patch("/:id", isAllowed(["admin", "superadmin"]), notificationController.editNotification);
return router;
};
@@ -23,6 +23,8 @@ export const registrationBodyValidation = z.object({
inviteToken: z.string().optional().default(""),
});
export const registerInviteTokenValidation = z.string().optional().default("");
export const recoveryValidation = z.object({
email: z.email("Must be a valid email address"),
});
+61 -7
View File
@@ -15,6 +15,9 @@ import {
import { DateRanges, SortOrders } from "@/types/query.js";
import { DockerContainerStates, DockerHealthStatuses, DockerLogStreams, DockerPortProtocols } from "@/domain/docker/docker.type.js";
import { DOCKER_LOG_PAGE_DEFAULT, DOCKER_LOG_PAGE_MAX } from "@/domain/docker/docker-log.type.js";
import { isDockerSocketUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
import { X509Certificate } from "node:crypto";
import { keyMatchesCertificate, parseCertificates, parsePrivateKey } from "@/utils/pem.js";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
@@ -120,19 +123,60 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct
}
};
const dockerUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
if (data.type !== "docker" || data.url === undefined) return;
if (!dockerUrlRegex.test(data.url)) {
if (!isDockerSocketUrl(data.url) && !isDockerTlsUrl(data.url)) {
ctx.addIssue({
code: "custom",
path: ["url"],
message: "Docker host must be unix:///path or an absolute socket path",
message: "Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]",
});
}
};
type DockerTlsFields = {
type?: string;
url?: string;
ignoreTlsErrors?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
dockerTlsKey?: string;
};
const refineDockerTls = (mode: "create" | "edit") => (data: DockerTlsFields, ctx: z.RefinementCtx) => {
if (data.type !== "docker" || !isDockerTlsUrl(data.url)) return;
const issue = (path: string, message: string) => ctx.addIssue({ code: "custom", path: [path], message });
if (!data.dockerTlsCert) issue("dockerTlsCert", "TLS certificate is required for a TLS Docker host");
if (mode === "create" && !data.dockerTlsKey) issue("dockerTlsKey", "TLS key is required for a TLS Docker host");
if (!data.ignoreTlsErrors && !data.dockerTlsCa) issue("dockerTlsCa", "CA certificate is required unless TLS errors are ignored");
let certificate: X509Certificate | undefined;
try {
if (data.dockerTlsCa) parseCertificates(data.dockerTlsCa);
} catch (error: unknown) {
issue("dockerTlsCa", error instanceof Error ? error.message : "Docker TLS CA error");
}
try {
if (data.dockerTlsCert) [certificate] = parseCertificates(data.dockerTlsCert);
} catch (error) {
issue("dockerTlsCert", error instanceof Error ? error.message : "Docker TLS cert error");
}
try {
if (data.dockerTlsKey) {
const key = parsePrivateKey(data.dockerTlsKey);
if (certificate && !keyMatchesCertificate(key, certificate)) issue("dockerTlsKey", "Key does not match certificate");
}
} catch (error: unknown) {
issue("dockerTlsKey", error instanceof Error ? error.message : "Docker TLS key error");
}
};
export const createMonitorBodyValidation = z
.object({
_id: z.string().optional(),
@@ -170,6 +214,9 @@ export const createMonitorBodyValidation = z
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
dockerLogsEnabled: z.boolean().optional(),
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
})
@@ -178,7 +225,8 @@ export const createMonitorBodyValidation = z
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection)
.superRefine(refineDockerUrl);
.superRefine(refineDockerUrl)
.superRefine(refineDockerTls("create"));
export const editMonitorBodyValidation = z
.object({
@@ -215,6 +263,9 @@ export const editMonitorBodyValidation = z
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
dockerLogsEnabled: z.boolean().optional(),
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
})
@@ -223,7 +274,8 @@ export const editMonitorBodyValidation = z
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection)
.superRefine(refineDockerUrl);
.superRefine(refineDockerUrl)
.superRefine(refineDockerTls("edit"));
export const pauseMonitorParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -291,7 +343,6 @@ const importedMonitorSchema = z
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
geoCheckInterval: z.number().min(300000).default(300000),
dockerLogsEnabled: z.boolean().default(false),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
createdAt: z.string().optional(),
@@ -381,6 +432,9 @@ export const monitorResponseSchema = z
geoCheckLocations: z.array(z.enum(GeoContinents)),
geoCheckInterval: z.number(),
dockerLogsEnabled: z.boolean(),
dockerTlsCa: z.string().optional(),
dockerTlsCert: z.string().optional(),
dockerTlsKeySet: z.boolean().optional(),
dnsServer: z.string().optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
teamId: z.string(),
@@ -123,6 +123,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
address: z.string().min(1, "User key is required"),
accessToken: z.string().min(1, "App token is required"),
}),
// Signalgrid notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
}),
// Twilio SMS notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
+36
View File
@@ -3,6 +3,39 @@ import { DbTypes, LogLevels, QueueModes } from "@/domain/app-settings/app-settin
import { booleanCoercion } from "@/api/validation/shared.js";
import { ILogger } from "@/utils/logger.js";
// Padded standard base64 of exactly 32 bytes, as produced by `openssl rand -base64 32`
const ENCRYPTION_KEY_ENTRY = /^[A-Za-z0-9+/]{43}=$/;
export const encryptionKeyList = z
.string()
.optional()
.transform((raw, ctx) => {
if (raw === undefined) return [];
const entries = raw
.split(",")
.map((entry, index) => ({ entry: entry.trim(), slot: index + 1 }))
.filter(({ entry }) => entry.length > 0);
const seen = new Set<string>();
const keys: Buffer[] = [];
entries.forEach(({ entry, slot }) => {
if (!ENCRYPTION_KEY_ENTRY.test(entry)) {
ctx.addIssue({
code: "custom",
message: `ENCRYPTION_KEY entry ${slot} must be 32 bytes as padded standard base64 (openssl rand -base64 32)`,
});
return;
}
const decoded = Buffer.from(entry, "base64");
const fingerprint = decoded.toString("hex");
if (seen.has(fingerprint)) {
ctx.addIssue({ code: "custom", message: `ENCRYPTION_KEY entry ${slot} duplicates an earlier entry` });
}
seen.add(fingerprint);
keys.push(decoded);
});
return keys;
});
const envSchema = z.object({
// Server Configuration
PORT: z.string().default("52345"),
@@ -36,6 +69,9 @@ const envSchema = z.object({
// Feature flags
STATUS_PAGE_THEMES_ENABLED: booleanCoercion.default(true),
// Encryption
ENCRYPTION_KEY: encryptionKeyList,
});
export type ValidatedEnv = z.infer<typeof envSchema>;
+1
View File
@@ -80,6 +80,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
encryptionService: shared.encryptionService,
});
const maintenanceWindowService = new MaintenanceWindowService({
+13 -2
View File
@@ -1,6 +1,6 @@
import { Mongoose } from "mongoose";
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
import * as nodeCrypto from "node:crypto";
import fs from "fs";
import path from "path";
import nodemailer from "nodemailer";
@@ -20,6 +20,7 @@ import { INotificationsService, NotificationsService } from "@/domain/notificati
import { IEmailService, EmailService } from "@/service/emailService.js";
import { GlobalPingService } from "@/service/globalPingService.js";
import { ILogger } from "@/utils/logger.js";
import { IEncryptionService, EncryptionService } from "@/service/encryption/encryptionService.js";
// Notification providers
import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js";
@@ -33,6 +34,7 @@ import { MatrixProvider } from "@/domain/notifications/providers/matrix.js";
import { TeamsProvider } from "@/domain/notifications/providers/teams.js";
import { TelegramProvider } from "@/domain/notifications/providers/telegram.js";
import { PushoverProvider } from "@/domain/notifications/providers/pushover.js";
import { SignalgridProvider } from "@/domain/notifications/providers/signalgrid.js";
import { TwilioProvider } from "@/domain/notifications/providers/twilio.js";
import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js";
@@ -82,6 +84,7 @@ export interface SharedServices {
db: IDb<Mongoose>;
settingsService: ISettingsService;
emailService: IEmailService;
encryptionService: IEncryptionService;
notificationMessageBuilder: INotificationMessageBuilder;
incidentService: IIncidentService;
checkService: ICheckService;
@@ -117,13 +120,18 @@ export const buildShared = async ({
logger,
envSettings,
settingsService,
encryptionKeys,
}: {
logger: ILogger;
envSettings: EnvConfig;
settingsService: ISettingsService;
encryptionKeys: Buffer[];
}): Promise<SharedServices> => {
// Shared worker ID
const workerId = `${hostname()}:${process.pid}:${randomUUID()}`;
const workerId = `${hostname()}:${process.pid}:${nodeCrypto.randomUUID()}`;
// Create the encryption service
const encryptionService = new EncryptionService(encryptionKeys, logger, nodeCrypto);
// Create DB
let db: IDb<Mongoose> | null = null;
@@ -178,6 +186,7 @@ export const buildShared = async ({
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const signalgridProvider = new SignalgridProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
@@ -192,6 +201,7 @@ export const buildShared = async ({
teams: teamsProvider,
telegram: telegramProvider,
pushover: pushoverProvider,
signalgrid: signalgridProvider,
twilio: twilioProvider,
ntfy: ntfyProvider,
};
@@ -210,6 +220,7 @@ export const buildShared = async ({
db,
settingsService,
emailService,
encryptionService,
notificationMessageBuilder,
incidentService,
checkService,
+22 -1
View File
@@ -16,6 +16,7 @@ import { SharedServices } from "@/config/services.shared.js";
import { INetworkService, NetworkService } from "@/service/networkService.js";
import { IBufferService, BufferService } from "@/service/bufferService.js";
import { IStatusService, StatusService } from "@/service/statusService.js";
import { SecretsRotationService } from "@/service/encryption/secretsRotationService.js";
import { IQueueWorker } from "@/worker/worker.interface.js";
import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
import { WorkerHelper } from "@/worker/worker.helper.js";
@@ -39,6 +40,7 @@ import { GameProvider } from "@/service/network/GameProvider.js";
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/network/DNSProvider.js";
import { AppError } from "@/utils/AppError.js";
export interface WorkerServices {
worker: IQueueWorker;
networkService: INetworkService;
@@ -59,6 +61,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
jobsRepository,
queueWorkersRepository,
monitorsRepository,
encryptionService,
checksRepository,
geoChecksRepository,
dockerLogsRepository,
@@ -76,7 +79,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
const dockerProvider = new DockerProvider(logger, Docker);
const dockerProvider = new DockerProvider(logger, Docker, encryptionService);
const portProvider = new PortProvider(net);
const gameProvider = new GameProvider(logger, GameDig);
const grpcProvider = new GrpcProvider(grpc, protoLoader);
@@ -164,5 +167,23 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
workerId,
});
const secretsRotationService = new SecretsRotationService(monitorsRepository, encryptionService, logger);
try {
const result = await secretsRotationService.run();
logger.debug({
message: "Secrets rotation run successfully",
service: "WorkerServices",
method: "buildWorker",
details: { ...result },
});
} catch (error: unknown) {
logger.warn({
message: `Could not rotate Docker TLS secrets: ${error instanceof Error ? error.message : String(error)}`,
service: "WorkerServices",
method: "buildWorker",
details: { ...(error instanceof AppError ? error.details : {}) },
});
}
return { worker, networkService, bufferService, statusService };
};
@@ -307,6 +307,20 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: Boolean,
default: false,
},
dockerTlsCa: {
type: String,
},
dockerTlsCert: {
type: String,
},
dockerTlsKey: {
type: String,
select: false,
},
dockerTlsKeySet: {
type: Boolean,
default: false,
},
dnsServer: {
type: String,
},
@@ -55,6 +55,11 @@ export interface IMonitorsRepository {
findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise<number>;
findMonitorCountByProxyId(proxyId: string): Promise<number>;
// Docker TLS
findDockerTlsKeyById(monitorId: string): Promise<string | null>;
findAllDockerTlsKeys(): Promise<{ id: string; dockerTlsKey: string }[]>;
updateDockerTlsKey(monitorId: string, dockerTlsKey: string): Promise<void>; // Needed for worker to rotate keys, no teamId/userId
// other
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
removeNotificationFromMonitors(notificationId: string): Promise<void>;
@@ -298,6 +298,20 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return { monitors: this.mapDocuments(monitors), deletedCount };
};
findDockerTlsKeyById = async (monitorId: string): Promise<string | null> => {
const doc = await MonitorModel.findOne({ _id: monitorId }, { dockerTlsKey: 1 }).lean();
return doc?.dockerTlsKey ?? null;
};
findAllDockerTlsKeys = async (): Promise<{ id: string; dockerTlsKey: string }[]> => {
const docs = await MonitorModel.find({ dockerTlsKeySet: true }, { dockerTlsKey: 1 }).lean();
return docs.flatMap((doc) => (doc.dockerTlsKey ? [{ id: doc._id.toString(), dockerTlsKey: doc.dockerTlsKey }] : []));
};
updateDockerTlsKey = async (monitorId: string, dockerTlsKey: string): Promise<void> => {
await MonitorModel.updateOne({ _id: monitorId }, { $set: { dockerTlsKey } });
};
findMonitorsSummaryByTeamId = async (teamId: string, config: SummaryConfig): Promise<MonitorsSummary> => {
const match = this.queryBuilder(config, teamId);
const pipeline = [
@@ -454,6 +468,9 @@ class MongoMonitorsRepository implements IMonitorsRepository {
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
dockerLogsEnabled: doc.dockerLogsEnabled ?? false,
dockerTlsCa: doc.dockerTlsCa ?? undefined,
dockerTlsCert: doc.dockerTlsCert ?? undefined,
dockerTlsKeySet: doc.dockerTlsKeySet ?? false,
dnsServer: doc.dnsServer ?? undefined,
dnsRecordType: doc.dnsRecordType ?? undefined,
createdAt: toDateString(doc.createdAt),
+57 -2
View File
@@ -33,6 +33,8 @@ import { ILogger } from "@/utils/logger.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { DateRange } from "@/types/query.js";
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
import { isDockerTlsUrl } from "@/utils/dockerHost.js";
const SERVICE_NAME = "MonitorService";
@@ -60,7 +62,7 @@ const computeRestartsInRange = (aggregate: DockerContainerStatsBucket[]): number
export interface IMonitorService {
// create
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>;
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<Monitor>;
createMonitors(monitors: Array<Monitor>): Promise<Monitor[] | null>;
addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>;
@@ -144,6 +146,7 @@ export class MonitorService implements IMonitorService {
private monitorStatsRepository: IMonitorStatsRepository;
private statusPagesRepository: IStatusPagesRepository;
private incidentsRepository: IIncidentsRepository;
private encryptionService: IEncryptionService;
constructor({
scheduler,
@@ -156,6 +159,7 @@ export class MonitorService implements IMonitorService {
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
encryptionService,
}: {
scheduler: IJobScheduler;
logger: ILogger;
@@ -167,6 +171,7 @@ export class MonitorService implements IMonitorService {
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
incidentsRepository: IIncidentsRepository;
encryptionService: IEncryptionService;
}) {
this.scheduler = scheduler;
this.logger = logger;
@@ -178,19 +183,25 @@ export class MonitorService implements IMonitorService {
this.monitorStatsRepository = monitorStatsRepository;
this.statusPagesRepository = statusPagesRepository;
this.incidentsRepository = incidentsRepository;
this.encryptionService = encryptionService;
}
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<Monitor> => {
// proxyId is only needed in custom mode
if (body.proxyMode !== "custom") {
delete body.proxyId;
}
// Encrypt docker TLS keys
this.encryptDockerTls(body, "create");
const monitor = await this.monitorsRepository.create(body, teamId, userId);
if (!monitor) {
throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" });
}
this.scheduler.addJob(monitor.id, monitor);
return monitor;
};
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
@@ -536,6 +547,20 @@ export class MonitorService implements IMonitorService {
if (unsetProxyId) {
delete body.proxyId;
}
// Handle Docker TLS
this.encryptDockerTls(body, "edit");
if (body.type === "docker" && isDockerTlsUrl(body.url) && body.dockerTlsKey === undefined) {
const stored = await this.monitorsRepository.findById(monitorId, teamId);
if (!stored.dockerTlsKeySet) {
throw new AppError({
message: "Key is required for a TLS Docker host",
status: 422,
service: SERVICE_NAME,
method: "editMonitor",
});
}
}
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId });
await this.scheduler.updateJob(editedMonitor);
return editedMonitor;
@@ -723,4 +748,34 @@ export class MonitorService implements IMonitorService {
return { imported: createdMonitors!.length, errors };
};
private encryptDockerTls = (body: Partial<Monitor>, mode: "create" | "edit") => {
if (body.type !== "docker" && mode === "create") return;
if (body.url !== undefined && !isDockerTlsUrl(body.url)) {
body.dockerTlsCa = undefined;
body.dockerTlsCert = undefined;
body.dockerTlsKey = undefined;
body.dockerTlsKeySet = false;
return;
}
if (!body.dockerTlsKey) {
delete body.dockerTlsKey; // empty field means keeps stored key
return;
}
if (!this.encryptionService.isConfigured()) {
throw new AppError({
message:
'Docker TLS credentials require ENCRYPTION_KEY to be set on the server. Generate one with "openssl rand -base64 32", set the same value for both the API and worker, and restart.',
status: 422,
service: SERVICE_NAME,
method: "encryptDockerTls",
});
}
body.dockerTlsKey = this.encryptionService.encrypt(body.dockerTlsKey);
body.dockerTlsKeySet = true;
};
}
@@ -109,6 +109,11 @@ export interface Monitor {
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dockerLogsEnabled?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
// EncryptionService ciphertext. Normally excluded
dockerTlsKey?: string;
dockerTlsKeySet?: boolean;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
@@ -37,6 +37,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as NotificationChannel[],
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
@@ -0,0 +1,123 @@
const SERVICE_NAME = "SignalgridProvider";
import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
import got from "got";
export class SignalgridProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post("https://api.signalgrid.co/v1/push", {
form: {
client_key: notification.accessToken,
channel: notification.address,
title: "Checkmate Test Notification",
body: getTestMessage(),
type: "INFO",
critical: false,
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Signalgrid test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post("https://api.signalgrid.co/v1/push", {
form: {
client_key: notification.accessToken,
channel: notification.address,
title: message.content.title,
body: this.buildSignalgridText(message),
type: this.mapSeverity(message.severity),
critical: false,
},
...this.gotRequestOptions(),
});
this.logger.info({
message: "Signalgrid notification sent",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Signalgrid alert failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
private mapSeverity(severity: NotificationSeverity): "CRIT" | "WARN" | "INFO" | "SUCCESS" {
switch (severity) {
case "critical":
return "CRIT";
case "warning":
return "WARN";
case "success":
return "SUCCESS";
default:
return "INFO";
}
}
private buildSignalgridText(message: NotificationMessage): string {
const lines: string[] = [];
lines.push(message.content.summary);
lines.push("");
lines.push("Monitor Details:");
lines.push(`• Name: ${message.monitor.name}`);
lines.push(`• URL: ${message.monitor.url}`);
lines.push(`• Type: ${message.monitor.type}`);
lines.push(`• Status: ${message.monitor.status}`);
if (message.content.details && message.content.details.length > 0) {
lines.push("");
lines.push("Additional Information:");
message.content.details.forEach((detail) => lines.push(`• ${detail}`));
}
if (message.content.thresholds && message.content.thresholds.length > 0) {
lines.push("");
lines.push("Threshold Breaches:");
message.content.thresholds.forEach((breach) => {
lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
});
}
if (message.content.incident) {
lines.push("");
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
}
return lines.join("\n");
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ const startApp = async () => {
// ***********************
// Build shared services
// ***********************
const shared = await buildShared({ logger, envSettings, settingsService });
const shared = await buildShared({ logger, envSettings, settingsService, encryptionKeys: env.ENCRYPTION_KEY });
// ***********************
// Worker node path, don't need API
@@ -0,0 +1,176 @@
import { ILogger } from "@/utils/logger.js";
import type * as nodeCrypto from "node:crypto";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "EncryptionService";
const ENCRYPTION_KEY_BYTES = 32;
// Encryption consts
const FORMAT_VERSION = "v1";
const ALGORITHM = "aes-256-gcm";
const IV_BYTES = 12;
const TAG_BYTES = 16;
const KEY_ID_BYTES = 4;
const SEGMENT_COUNT = 5;
type V1Segments = [string, string, string, string, string];
const isV1Segments = (segments: string[]): segments is V1Segments => segments.length === SEGMENT_COUNT;
const BASE64URL = /^[A-Za-z0-9_-]*$/;
type KeyEntry = { id: string; key: Buffer };
export type EncryptionCryptoLib = Pick<typeof nodeCrypto, "createCipheriv" | "createDecipheriv" | "createHash" | "randomBytes">;
export interface IEncryptionService {
isConfigured(): boolean;
currentKeyId(): string;
keyIdOf(ciphertext: string): string;
needsReencryption(ciphertext: string): boolean;
encrypt(plaintext: string): string;
decrypt(ciphertext: string): string;
}
export class EncryptionService implements IEncryptionService {
static SERVICE_NAME = SERVICE_NAME;
private readonly entries: KeyEntry[];
constructor(
keys: Buffer[],
private logger: ILogger,
private cryptoLib: EncryptionCryptoLib
) {
this.entries = keys.map((key, index) => this.toEntry(key, index));
this.entries.forEach((entry, index) => {
if (this.entries.findIndex((other) => other.id === entry.id) !== index) {
throw this.fail("constructor", "ENCRYPTION_KEY key ids collide; regenerate one key", { index });
}
});
const current = this.entries[0];
this.logger.info({
message: current
? `Encryption configured with ${this.entries.length} key(s); current key id ${current.id}`
: "ENCRYPTION_KEY not set; encrypted fields are unavailable",
service: SERVICE_NAME,
method: "constructor",
});
}
isConfigured = () => {
return this.entries.length > 0;
};
currentKeyId = () => {
return this.requireCurrentKey("currentKeyId").id;
};
keyIdOf = (ciphertext: string) => {
return this.parse(ciphertext, "keyIdOf").keyId;
};
// Check if the ciphertext was encrypted with the current key
needsReencryption = (ciphertext: string) => {
const currentKey = this.requireCurrentKey("needsReencryption");
return this.parse(ciphertext, "needsReencryption").keyId !== currentKey.id;
};
encrypt = (plaintext: string) => {
const currentKey = this.requireCurrentKey("encrypt"); // Get the current key for encryption
const iv = this.cryptoLib.randomBytes(IV_BYTES); // Get IV_BYTES worth of random bytes for nonce
const cipher = this.cryptoLib.createCipheriv(ALGORITHM, currentKey.key, iv, { authTagLength: TAG_BYTES }); // Create the cipher
cipher.setAAD(this.aad(currentKey.id)); // set signing key as additional data so it is protected by the verification tag
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); // Encode plaintext as utf8 and run through cipher
const tag = cipher.getAuthTag(); // Get verification tag
return [FORMAT_VERSION, currentKey.id, iv.toString("base64url"), tag.toString("base64url"), data.toString("base64url")].join(".");
};
decrypt = (ciphertext: string) => {
this.requireCurrentKey("decrypt"); // Config check
const { keyId, iv, tag, data } = this.parse(ciphertext, "decrypt");
const key = this.entries.find((entry) => entry.id === keyId)?.key;
if (!key) {
throw this.fail("decrypt", "No encryption key matches ciphertext", { keyId });
}
const decipher = this.cryptoLib.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
decipher.setAAD(this.aad(keyId)); // reconstruct the additional data, must be done BEFORE auth tag
decipher.setAuthTag(tag); // Give the decipher the tag to verify
try {
return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
} catch {
throw this.fail("decrypt", "Ciphertext failed authentication");
}
};
private fail = (method: string, message: string, details?: Record<string, unknown>): AppError => {
return new AppError({
message,
status: 500,
service: SERVICE_NAME,
method,
details,
});
};
// Create an ID:Key pair for each key.
private toEntry = (key: Buffer, keyIdx: number): KeyEntry => {
if (key.length !== ENCRYPTION_KEY_BYTES) throw this.fail("constructor", "ENCRYPTION_KEY is not 32 bytes", { keyIdx });
const id = this.cryptoLib
.createHash("sha256") // Create a sha256 hasher
.update(key) // Feed it the key
.digest() // Digest a hash
.subarray(0, KEY_ID_BYTES) // Keep first KEY_ID_BYTES, enough to avoid collision
.toString("base64url");
return { id, key };
};
private requireCurrentKey = (method: string): KeyEntry => {
const current = this.entries[0];
if (!current) {
throw this.fail(method, "ENCRYPTION_KEY is not configured");
}
return current;
};
// v1 Ciphertext stored as a string with five segments:
// <version>.<keyId>.<iv>.<tag>.<encryptedData>
// iv and tag belong to aes-256-gcm standard
private parse = (ciphertext: string, method: string) => {
const segments = ciphertext.split(".");
const version = segments[0];
if (version !== FORMAT_VERSION) {
throw this.fail(method, "Unsupported ciphertext version", { version });
}
// **************************
// v1 parser
// **************************
if (!isV1Segments(segments)) {
throw this.fail(method, "Ciphertext is malformed");
}
const [, keyId, ivText, tagText, dataText] = segments; // Skip first segment, we already have version
if (![keyId, ivText, tagText, dataText].every((segment) => BASE64URL.test(segment))) {
throw this.fail(method, "Ciphertext is malformed");
}
const iv = Buffer.from(ivText, "base64url");
const tag = Buffer.from(tagText, "base64url");
if (keyId.length === 0 || iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
throw this.fail(method, "Ciphertext is malformed");
}
return { keyId, iv, tag, data: Buffer.from(dataText, "base64url") };
// **************************
// End v1 parser
// **************************
};
private aad = (keyId: string) => {
return Buffer.from(`${FORMAT_VERSION}.${keyId}`, "utf8");
};
}
@@ -0,0 +1,60 @@
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "SecretsRotationService";
export interface ISecretsRotationService {
run(): Promise<{ scanned: number; reencrypted: number; skipped: number }>;
}
export class SecretsRotationService implements ISecretsRotationService {
static SERVICE_NAME = SERVICE_NAME;
constructor(
private monitorsRepository: IMonitorsRepository,
private encryptionService: IEncryptionService,
private logger: ILogger
) {}
run = async () => {
const rows = await this.monitorsRepository.findAllDockerTlsKeys();
const result = { scanned: rows.length, reencrypted: 0, skipped: 0 };
if (rows.length === 0) return result;
if (!this.encryptionService.isConfigured()) {
this.logger.warn({
message: `${rows.length} monitor(s) have a stored Docker TLS key but ENCRYPTION_KEY is not set; their checks will fail until it is`,
service: SERVICE_NAME,
method: "run",
});
result.skipped = rows.length;
return result;
}
for (const monitor of rows) {
const { id, dockerTlsKey } = monitor;
try {
if (!this.encryptionService.needsReencryption(dockerTlsKey)) continue;
const plainText = this.encryptionService.decrypt(dockerTlsKey);
await this.monitorsRepository.updateDockerTlsKey(id, this.encryptionService.encrypt(plainText));
result.reencrypted += 1;
} catch (error: unknown) {
result.skipped += 1;
this.logger.warn({
message: `Could not reencrypt Docker TLS key for monitor ${id}: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
method: "run",
details: { monitorId: id, ...(error instanceof AppError ? error.details : {}) },
});
}
}
this.logger.info({
message: `Docker TLS key rotation: ${result.reencrypted} re-encrypted, ${result.skipped} skipped, ${result.scanned} scanned`,
service: SERVICE_NAME,
method: "run",
});
return result;
};
}
+81 -21
View File
@@ -1,5 +1,7 @@
import https from "node:https";
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { CheckContext, DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import {
DOCKER_LOG_TAIL_LINES,
@@ -23,13 +25,21 @@ import { AppError } from "@/utils/AppError.js";
import Dockerode from "dockerode";
import { timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
import { DOCKER_TLS_URL, isDockerTlsUrl } from "@/utils/dockerHost.js";
import { splitCertificateBundle } from "@/utils/pem.js";
type DockerodeType = typeof Dockerode;
type DockerOptions = Dockerode.DockerOptions & { agent?: https.Agent; connectionTimeout?: number };
type TlsCredentials = { ok: true; key: string } | { ok: false; message: string };
const SERVICE_NAME = "DockerProvider";
const TIMEOUT_MS = 10000;
const STATS_CONCURRENCY = 5;
const DOCKER_LOG_MAX_LINE_BYTES = 4096;
const DOCKER_LOG_TRUNCATION_MARKER = " …[truncated]";
const DOCKER_LOG_TS_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z /;
const DOCKER_TLS_DEFAULT_PORT = 2376;
export interface DockerError extends Error {
statusCode?: number;
@@ -42,28 +52,73 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
constructor(
private logger: ILogger,
private DockerLib: DockerodeType
private DockerLib: DockerodeType,
private encryptionService: IEncryptionService
) {}
supports(type: MonitorType): boolean {
return type === "docker";
}
private resolveTlsCredentials = (monitor: Monitor, ctx?: CheckContext): TlsCredentials | undefined => {
if (!isDockerTlsUrl(monitor.url)) return undefined;
if (!ctx?.dockerTlsKey) return { ok: false, message: "Docker TLS key is missing" };
try {
return { ok: true, key: this.encryptionService.decrypt(ctx.dockerTlsKey) };
} catch (error) {
if (error instanceof AppError) {
const keyId = typeof error.details?.keyId === "string" ? ` (key id ${error.details.keyId})` : "";
return { ok: false, message: `${error.message}${keyId}` };
}
return { ok: false, message: "Docker TLS key could not be decrypted" };
}
};
private isDockerError(error: unknown): error is DockerError {
return error instanceof Error && ("statusCode" in error || "reason" in error || "json" in error);
}
private toDockerOptions = (monitor: Monitor): Dockerode.DockerOptions => {
private splitCaBundle = (pem: string | undefined): string[] | undefined => {
const blocks = pem ? splitCertificateBundle(pem) : [];
return blocks.length > 0 ? blocks : undefined;
};
private invalidUrl = (message: string) => new AppError({ message, status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
private toDockerOptions = (monitor: Monitor, credentials?: TlsCredentials): DockerOptions => {
const url = monitor.url?.trim();
if (!url) throw new AppError({ message: "Docker host URL is required", status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
if (!url) throw this.invalidUrl("Docker host URL is required");
// Unix socket
if (url.startsWith("unix://")) {
const socketPath = url.slice("unix://".length);
if (!socketPath.startsWith("/"))
throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
return { socketPath };
if (!socketPath.startsWith("/")) throw this.invalidUrl(`Invalid Docker host URL: ${url}`);
return { socketPath, timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS };
}
if (url.startsWith("/")) return { socketPath: url };
throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
if (url.startsWith("/")) return { socketPath: url, timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS };
// Docker TLS
const match = DOCKER_TLS_URL.exec(url);
if (!match) throw this.invalidUrl(`Invalid Docker host URL: ${url}`);
if (!credentials?.ok) throw this.invalidUrl(credentials?.message ?? "Docker TLS key is missing");
const [, , host, port] = match;
const ca = monitor.ignoreTlsErrors ? undefined : this.splitCaBundle(monitor.dockerTlsCa);
const cert = monitor.dockerTlsCert;
const key = credentials.key;
return {
host,
port: port ? Number(port) : DOCKER_TLS_DEFAULT_PORT,
protocol: "https",
ca,
cert,
key,
agent: new https.Agent({ ca, cert, key, rejectUnauthorized: !monitor.ignoreTlsErrors }),
socketPath: undefined,
username: undefined,
sshOptions: undefined,
timeout: TIMEOUT_MS,
connectionTimeout: TIMEOUT_MS,
};
};
private async mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
@@ -260,9 +315,23 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
return rawLogs.filter((log) => log !== null);
};
handle = async (monitor: Monitor): Promise<MonitorStatusResponse<DockerStatusPayload>> => {
private failed = (monitor: Monitor, code: number, message: string, responseTime: number): MonitorStatusResponse<DockerStatusPayload> => ({
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code,
message,
responseTime,
payload: null,
});
handle = async (monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<DockerStatusPayload>> => {
try {
const docker = new this.DockerLib(this.toDockerOptions(monitor));
const credentials = this.resolveTlsCredentials(monitor, ctx);
if (credentials && !credentials.ok) return this.failed(monitor, NETWORK_ERROR, credentials.message, 0);
const docker = new this.DockerLib(this.toDockerOptions(monitor, credentials));
// Host reachability is the monitor's status; ping latency is the responseTime
const { responseTime, error } = await timeRequest(() => docker.ping());
if (error) {
@@ -274,16 +343,7 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
} else if (error instanceof Error) {
message = error.message;
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code,
message,
responseTime,
payload: null,
};
return this.failed(monitor, code, message, responseTime);
}
const summaries = await docker.listContainers({ all: true });
+1
View File
@@ -162,4 +162,5 @@ export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusRespon
export type CheckContext = {
proxyUrl?: string;
dockerTlsKey?: string;
};
+5
View File
@@ -0,0 +1,5 @@
export const DOCKER_TLS_URL = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
export const DOCKER_SOCKET_URL = /^(?:unix:\/\/\/\S+|\/\S+)$/;
export const isDockerTlsUrl = (url: string | undefined): boolean => DOCKER_TLS_URL.test(url?.trim() ?? "");
export const isDockerSocketUrl = (url: string | undefined): boolean => DOCKER_SOCKET_URL.test(url?.trim() ?? "");
+28
View File
@@ -0,0 +1,28 @@
import { createPrivateKey, KeyObject, X509Certificate } from "node:crypto";
const PEM_CERT_BLOCK = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
export const splitCertificateBundle = (pem: string): string[] => pem.match(PEM_CERT_BLOCK) ?? [];
export const parseCertificates = (pem: string): X509Certificate[] => {
const blocks = splitCertificateBundle(pem);
if (blocks.length === 0) throw new Error("No certificate found; expected one or more PEM CERTIFICATE blocks");
try {
return blocks.map((block) => new X509Certificate(block));
} catch {
throw new Error("Certificate is not valid PEM");
}
};
export const parsePrivateKey = (pem: string): KeyObject => {
if (/^-----BEGIN ENCRYPTED PRIVATE KEY-----/m.test(pem) || /^Proc-Type: 4,ENCRYPTED/m.test(pem)) {
throw new Error("Encrypted private keys are not supported; remove the passphrase first");
}
try {
return createPrivateKey(pem);
} catch {
throw new Error("Private key is not valid PEM");
}
};
export const keyMatchesCertificate = (key: KeyObject, certificate: X509Certificate): boolean => certificate.checkPrivateKey(key);
+9 -1
View File
@@ -36,6 +36,12 @@ export class CheckProducer implements ICheckProducer {
return windows.some((w) => isWindowActive(w, now));
}
private async resolveDockerTlsKey(monitor: Monitor): Promise<string | undefined> {
if (monitor.type !== "docker" || !monitor.dockerTlsKeySet || !monitor.id) return undefined;
const dockerTlsKey = await this.monitorsRepository.findDockerTlsKeyById(monitor.id);
return dockerTlsKey ?? undefined;
}
produce = async (monitor: Monitor) => {
if (!monitor.id) {
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "produce" });
@@ -61,7 +67,9 @@ export class CheckProducer implements ICheckProducer {
// Step 1b: Acquire status
const proxyUrl = await this.proxyResolver.resolve(monitor);
const status = await this.networkService.requestStatus(monitor, { proxyUrl });
const dockerTlsKey = await this.resolveDockerTlsKey(monitor);
const status = await this.networkService.requestStatus(monitor, { proxyUrl, dockerTlsKey });
if (!status) {
throw new Error("No network response");
}
+10
View File
@@ -0,0 +1,10 @@
Test fixtures for Docker TLS validation. Self-signed, 100-year, not trusted anywhere.
Regenerate with:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 36500 -subj "/CN=Checkmate Test CA" -keyout ca-key.pem -out ca.pem
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -subj "/CN=checkmate-test-client" -keyout client-key.pem -out client.csr
openssl x509 -req -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -days 36500 -out client-cert.pem
openssl ecparam -name prime256v1 -genkey -noout -out other-key.pem
openssl pkcs8 -topk8 -in client-key.pem -out encrypted-key.pem -passout pass:fixture
rm client.csr ca.srl ca-key.pem
+11
View File
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBkDCCATWgAwIBAgIUL3SODzLIekF+Tc3iHhHr/7uxvdIwCgYIKoZIzj0EAwIw
HDEaMBgGA1UEAwwRQ2hlY2ttYXRlIFRlc3QgQ0EwIBcNMjYwOTEwMTkxNDUyWhgP
MjEyNjA4MTcxOTE0NTJaMBwxGjAYBgNVBAMMEUNoZWNrbWF0ZSBUZXN0IENBMFkw
EwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEXRJ0Y0jc+pKwXbuioDmbaHOqr5n3oU9x
Y16vYajhUiFeWohEqGRG8lHA9S82KPNmpjthLWUykHvrTsKosBb6IKNTMFEwHQYD
VR0OBBYEFNfGlRFC2MFZrCkoF4UxktbZUve1MB8GA1UdIwQYMBaAFNfGlRFC2MFZ
rCkoF4UxktbZUve1MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSQAwRgIh
APOnYaaKk5ED6NV/AkdpNXOEqlWcXwIQzh5Ycj3cjby+AiEApuAGTnk8k4dtVNmB
aVV5bRmq7f3BjhxwZorP+KiIrdI=
-----END CERTIFICATE-----
+11
View File
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBgjCCASigAwIBAgIUBx7r8tVOUFNX8c3YYyBxXTNltuUwCgYIKoZIzj0EAwIw
HDEaMBgGA1UEAwwRQ2hlY2ttYXRlIFRlc3QgQ0EwIBcNMjYwOTEwMTkxNDUyWhgP
MjEyNjA4MTcxOTE0NTJaMCAxHjAcBgNVBAMMFWNoZWNrbWF0ZS10ZXN0LWNsaWVu
dDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKeUUNe9kE57nK7wTfnnvmqgzVj1
ngNPajQYIAh9SAR+pVLAoTWLinWq5YarW1LFAzxyRKl6p2NdKtyLpVYV5BKjQjBA
MB0GA1UdDgQWBBReuZBD6K0kWfgGDkt05zzM24wPejAfBgNVHSMEGDAWgBTXxpUR
QtjBWawpKBeFMZLW2VL3tTAKBggqhkjOPQQDAgNIADBFAiAvfzha4dEVcJlKjH45
yOkbFXiS/+tU+uTvn+7uutn6QAIhAI2qCXVvxX+i3Jk1xTS7R0mzJNXZXqabjODi
cisS1VJG
-----END CERTIFICATE-----
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgIHAuDMyYVMOi5PpA
yUPjuNjvy/QOI9dnjQZ+AKhuQO+hRANCAASnlFDXvZBOe5yu8E35575qoM1Y9Z4D
T2o0GCAIfUgEfqVSwKE1i4p1quWGq1tSxQM8ckSpeqdjXSrci6VWFeQS
-----END PRIVATE KEY-----
+8
View File
@@ -0,0 +1,8 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDak7FU8VAgafGR1iEl
eFHbAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQCUTF7P/8uj9a5kLU
3nBJdQSBkNpsmjC1FczojCBOn0g+2PlmMGI6MfR3vFvmPRAAF7SHLzQC1lXWhTU0
3v5yCCdjlC5gTGINKf8RhZQjKym+lIpf+16EAPFLzFbRJPvt/eKq65YiixvQP2PS
vysOG71YZJt1kSHrCOu5EPcCLvD2Kjyt48c/LJssCB81TOdqP1jV9iH29SCv4bnx
KtjXulGg3w==
-----END ENCRYPTED PRIVATE KEY-----
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIGnUNFbPMxfa3m7xo9J3Lu9qiss2ZxWpqp91B6X9rQbfoAoGCCqGSM49
AwEHoUQDQgAEGtbNBALMRIEROLtiEdNda4r/X0Hq1hcY/0V20gW3hEHhKRKkSilH
0LyFWtlZ5uILZh6zXCGWlOPm0PFO9rN/hQ==
-----END EC PRIVATE KEY-----
@@ -123,4 +123,70 @@ describe("MongoMonitorsRepository", () => {
expect(monitors[0].recentChecks).toEqual([]);
});
});
describe("docker tls key access", () => {
const seedDockerMonitor = (overrides: Record<string, unknown> = {}) =>
MonitorModel.create({
userId: new mongoose.Types.ObjectId(),
teamId: new mongoose.Types.ObjectId(),
name: "docker",
type: "docker",
url: "tcp://host",
...overrides,
});
it("never returns the key on a normal load, even with the flag set", async () => {
const repo = new MongoMonitorsRepository();
const doc = await seedDockerMonitor({ dockerTlsKey: "v1.abc123.iv.tag.data", dockerTlsKeySet: true });
const monitor = await repo.findByIdLean(doc._id.toString());
expect(monitor?.dockerTlsKeySet).toBe(true);
expect(monitor?.dockerTlsKey).toBeUndefined();
});
it("findDockerTlsKeyById returns the stored key despite select: false", async () => {
const repo = new MongoMonitorsRepository();
const doc = await seedDockerMonitor({ dockerTlsKey: "v1.abc123.iv.tag.data", dockerTlsKeySet: true });
await expect(repo.findDockerTlsKeyById(doc._id.toString())).resolves.toBe("v1.abc123.iv.tag.data");
});
it("findDockerTlsKeyById returns null for a monitor without a key and for an unknown id", async () => {
const repo = new MongoMonitorsRepository();
const doc = await seedDockerMonitor({ url: "unix:///var/run/docker.sock" });
await expect(repo.findDockerTlsKeyById(doc._id.toString())).resolves.toBeNull();
await expect(repo.findDockerTlsKeyById(new mongoose.Types.ObjectId().toString())).resolves.toBeNull();
});
it("findAllDockerTlsKeys returns only rows with the flag set and a key present", async () => {
const repo = new MongoMonitorsRepository();
const keyed = await seedDockerMonitor({ dockerTlsKey: "v1.abc123.k1", dockerTlsKeySet: true });
await seedDockerMonitor({ url: "unix:///var/run/docker.sock" });
await seedDockerMonitor({ dockerTlsKeySet: true }); // flag without key: inconsistent, dropped
await MonitorModel.create({
userId: new mongoose.Types.ObjectId(),
teamId: new mongoose.Types.ObjectId(),
name: "http",
type: "http",
url: "https://example.com",
});
await expect(repo.findAllDockerTlsKeys()).resolves.toEqual([{ id: keyed._id.toString(), dockerTlsKey: "v1.abc123.k1" }]);
});
it("updateDockerTlsKey replaces the key and nothing else", async () => {
const repo = new MongoMonitorsRepository();
const doc = await seedDockerMonitor({ dockerTlsKey: "v1.old.k", dockerTlsKeySet: true, dockerTlsCert: "cert" });
await repo.updateDockerTlsKey(doc._id.toString(), "v1.new.k");
const after = await MonitorModel.findById(doc._id).select("+dockerTlsKey").lean();
expect(after?.dockerTlsKey).toBe("v1.new.k");
expect(after?.dockerTlsCert).toBe("cert");
expect(after?.dockerTlsKeySet).toBe(true);
expect(after?.name).toBe("docker");
});
});
});
@@ -0,0 +1,79 @@
import { describe, expect, it, jest } from "@jest/globals";
import type { NextFunction, Request, Response } from "express";
import { isAllowed } from "../../../src/api/middleware/isAllowed.ts";
import { AppError } from "../../../src/utils/AppError.ts";
describe("isAllowed middleware", () => {
it("calls next() without error when user has an allowed role", () => {
const middleware = isAllowed(["admin", "superadmin"]);
const req = {
user: {
id: "user-1",
teamId: "team-1",
email: "admin@example.com",
role: ["admin"],
},
} as unknown as Request;
const res = {} as Response;
const next = jest.fn() as unknown as NextFunction;
middleware(req, res, next);
expect(next).toHaveBeenCalledWith();
});
it("calls next(error) with 403 when user does not have an allowed role", () => {
const middleware = isAllowed(["admin", "superadmin"]);
const req = {
user: {
id: "user-2",
teamId: "team-1",
email: "user@example.com",
role: ["user"],
},
} as unknown as Request;
const res = {} as Response;
const next = jest.fn() as unknown as NextFunction;
middleware(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
const error = (next as unknown as jest.Mock).mock.calls[0][0] as AppError;
expect(error).toBeInstanceOf(AppError);
expect(error.status).toBe(403);
expect(error.message).toBe("Unauthorized");
});
it("calls next(error) with 403 when req.user is undefined", () => {
const middleware = isAllowed(["admin", "superadmin"]);
const req = {} as Request;
const res = {} as Response;
const next = jest.fn() as unknown as NextFunction;
middleware(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
const error = (next as unknown as jest.Mock).mock.calls[0][0] as AppError;
expect(error).toBeInstanceOf(AppError);
expect(error.status).toBe(403);
expect(error.message).toBe("Unauthorized");
});
it("calls next() when user has multiple roles and at least one matches", () => {
const middleware = isAllowed(["admin", "superadmin"]);
const req = {
user: {
id: "user-3",
teamId: "team-1",
email: "multi@example.com",
role: ["user", "admin"],
},
} as unknown as Request;
const res = {} as Response;
const next = jest.fn() as unknown as NextFunction;
middleware(req, res, next);
expect(next).toHaveBeenCalledWith();
});
});
@@ -1,5 +1,7 @@
import { describe, expect, it, jest } from "@jest/globals";
import https from "node:https";
import { DockerProvider } from "../../../../src/service/network/DockerProvider.ts";
import type { IEncryptionService } from "../../../../src/service/encryption/encryptionService.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
@@ -9,6 +11,28 @@ import { DOCKER_LOG_TAIL_LINES } from "../../../../src/domain/docker/docker.type
// ── Fixtures ─────────────────────────────────────────────────────────────────
const CA_PEM = "-----BEGIN CERTIFICATE-----\nca-one\n-----END CERTIFICATE-----";
const CA_BUNDLE_PEM = `${CA_PEM}\n-----BEGIN CERTIFICATE-----\nca-two\n-----END CERTIFICATE-----\n`;
const CERT_PEM = "-----BEGIN CERTIFICATE-----\nclient\n-----END CERTIFICATE-----";
const KEY_PEM = "-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----";
const KEY_CIPHERTEXT = "v1.abc123.iv.tag.data";
const makeEncryptionService = (overrides?: Partial<IEncryptionService>): IEncryptionService => ({
isConfigured: jest.fn(() => true),
currentKeyId: jest.fn(() => "abc123"),
keyIdOf: jest.fn(() => "abc123"),
needsReencryption: jest.fn(() => false),
encrypt: jest.fn(() => KEY_CIPHERTEXT),
decrypt: jest.fn(() => KEY_PEM),
...overrides,
});
const TIMEOUT_MS = 10000;
const TIMEOUTS = { timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS };
const makeTlsMonitor = (overrides?: Partial<Monitor>): Monitor =>
makeMonitor({ url: "tcp://host", dockerTlsCa: CA_PEM, dockerTlsCert: CERT_PEM, dockerTlsKeySet: true, ...overrides });
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
@@ -60,7 +84,17 @@ const makeLogFrame = (streamType: number, text: string): Buffer => {
return Buffer.concat([header, payload]);
};
const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer: any; inspect: any; stats: any; logs: any }> = {}) => {
const setup = (
overrides: Partial<{
ping: any;
listContainers: any;
getContainer: any;
inspect: any;
stats: any;
logs: any;
encryptionService: IEncryptionService;
}> = {}
) => {
const inspect = overrides.inspect ?? jest.fn().mockResolvedValue(makeInspect());
const stats = overrides.stats ?? jest.fn().mockResolvedValue(makeStats());
const logs = overrides.logs ?? jest.fn().mockResolvedValue(Buffer.alloc(0));
@@ -72,8 +106,20 @@ const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer
};
const DockerLib = jest.fn().mockReturnValue(instance) as any;
const logger = createMockLogger();
const provider = new DockerProvider(logger as any, DockerLib);
return { provider, logger, DockerLib, ping: instance.ping, listContainers: instance.listContainers, getContainer, inspect, stats, logs };
const encryptionService = overrides.encryptionService ?? makeEncryptionService();
const provider = new DockerProvider(logger as any, DockerLib, encryptionService);
return {
provider,
logger,
DockerLib,
encryptionService,
ping: instance.ping,
listContainers: instance.listContainers,
getContainer,
inspect,
stats,
logs,
};
};
// ── Contract ─────────────────────────────────────────────────────────────────
@@ -96,7 +142,7 @@ describe("DockerProvider", () => {
await provider.handle(makeMonitor({ url: "unix:///run/docker.sock" }));
expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/run/docker.sock" });
expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/run/docker.sock", ...TIMEOUTS });
});
it("parses bare absolute paths into socketPath", async () => {
@@ -104,7 +150,7 @@ describe("DockerProvider", () => {
await provider.handle(makeMonitor({ url: "/var/run/docker.sock" }));
expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock" });
expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock", ...TIMEOUTS });
});
it("trims surrounding whitespace", async () => {
@@ -112,7 +158,130 @@ describe("DockerProvider", () => {
await provider.handle(makeMonitor({ url: " /var/run/docker.sock " }));
expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock" });
expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock", ...TIMEOUTS });
});
});
// ── TLS host urls ────────────────────────────────────────────────────
describe("tls host urls", () => {
const ctx = { dockerTlsKey: KEY_CIPHERTEXT };
const optionsPassedTo = (DockerLib: jest.Mock) => DockerLib.mock.calls[0]?.[0] as Record<string, unknown>;
const agentOf = (options: Record<string, unknown>) => options.agent as https.Agent & { options: Record<string, unknown> };
it("parses tcp:// into an https connection with the monitor's ca and cert and the decrypted key", async () => {
const { provider, DockerLib, encryptionService } = setup();
await provider.handle(makeTlsMonitor(), ctx);
expect(encryptionService.decrypt).toHaveBeenCalledWith(KEY_CIPHERTEXT);
const options = optionsPassedTo(DockerLib);
expect(options).toMatchObject({ host: "host", port: 2376, protocol: "https", ca: [CA_PEM], cert: CERT_PEM, key: KEY_PEM });
expect(agentOf(options)).toBeInstanceOf(https.Agent);
expect(agentOf(options).options).toMatchObject({ ca: [CA_PEM], cert: CERT_PEM, key: KEY_PEM, rejectUnauthorized: true });
});
it("parses https:// with an explicit port", async () => {
const { provider, DockerLib } = setup();
await provider.handle(makeTlsMonitor({ url: "https://host:2377" }), ctx);
expect(optionsPassedTo(DockerLib)).toMatchObject({ host: "host", port: 2377, protocol: "https" });
});
it("accepts a trailing slash", async () => {
const { provider, DockerLib } = setup();
await provider.handle(makeTlsMonitor({ url: "tcp://host:2376/" }), ctx);
expect(optionsPassedTo(DockerLib)).toMatchObject({ host: "host", port: 2376 });
});
it("splits a ca bundle into one entry per certificate on the options and the agent", async () => {
const { provider, DockerLib } = setup();
await provider.handle(makeTlsMonitor({ dockerTlsCa: CA_BUNDLE_PEM }), ctx);
const options = optionsPassedTo(DockerLib);
expect(options.ca).toHaveLength(2);
expect(agentOf(options).options.ca).toEqual(options.ca);
});
it("omits the ca and stops rejecting unauthorized certificates when ignoreTlsErrors is set", async () => {
const { provider, DockerLib } = setup();
await provider.handle(makeTlsMonitor({ ignoreTlsErrors: true }), ctx);
const options = optionsPassedTo(DockerLib);
expect(options.ca).toBeUndefined();
expect(agentOf(options).options).toMatchObject({ ca: undefined, rejectUnauthorized: false });
});
it("passes explicit undefined for every field docker-modem would otherwise fill from the environment", async () => {
const { provider, DockerLib } = setup();
await provider.handle(makeTlsMonitor(), ctx);
const options = optionsPassedTo(DockerLib);
for (const field of ["socketPath", "username", "sshOptions"]) {
expect(options).toHaveProperty(field, undefined);
}
});
it("bounds every request with the provider timeout so a filtered port fails fast", async () => {
const { provider, DockerLib } = setup();
await provider.handle(makeTlsMonitor(), ctx);
expect(optionsPassedTo(DockerLib)).toMatchObject(TIMEOUTS);
});
it("returns a down check and never constructs a client when the key is missing from the context", async () => {
const { provider, DockerLib, encryptionService } = setup();
const result = await provider.handle(makeTlsMonitor());
expect(result).toMatchObject({ status: false, code: NETWORK_ERROR, message: "Docker TLS key is missing", payload: null });
expect(encryptionService.decrypt).not.toHaveBeenCalled();
expect(DockerLib).not.toHaveBeenCalled();
});
it("returns a down check naming the key id when the stored key cannot be decrypted", async () => {
const decrypt = jest.fn(() => {
throw new AppError({
message: "No encryption key matches ciphertext",
status: 500,
service: "EncryptionService",
method: "decrypt",
details: { keyId: "abc123" },
});
});
const { provider, DockerLib } = setup({ encryptionService: makeEncryptionService({ decrypt }) });
const result = await provider.handle(makeTlsMonitor(), ctx);
expect(result).toMatchObject({ status: false, code: NETWORK_ERROR, message: "No encryption key matches ciphertext (key id abc123)" });
expect(DockerLib).not.toHaveBeenCalled();
});
it("returns a generic down check when decryption fails with a non-AppError", async () => {
const decrypt = jest.fn(() => {
throw new Error("boom");
});
const { provider, DockerLib } = setup({ encryptionService: makeEncryptionService({ decrypt }) });
const result = await provider.handle(makeTlsMonitor(), ctx);
expect(result).toMatchObject({ status: false, message: "Docker TLS key could not be decrypted" });
expect(DockerLib).not.toHaveBeenCalled();
});
it("does not touch the encryption service for socket monitors", async () => {
const { provider, encryptionService } = setup();
await provider.handle(makeMonitor(), ctx);
expect(encryptionService.decrypt).not.toHaveBeenCalled();
});
});
@@ -125,8 +294,9 @@ describe("DockerProvider", () => {
["unix:// with a relative path", "unix://run/docker.sock", "Invalid Docker host URL"],
["garbage", "not-a-url", "Invalid Docker host URL"],
["a container name (old semantics)", "my-container", "Invalid Docker host URL"],
["tcp engine urls (unsupported)", "tcp://host:2375", "Invalid Docker host URL"],
["https engine urls (unsupported)", "https://host", "Invalid Docker host URL"],
["http engine urls (plaintext tcp is unsupported)", "http://host", "Invalid Docker host URL"],
["a bare host and port", "host:2376", "Invalid Docker host URL"],
["a tcp url with a path", "tcp://host/engine", "Invalid Docker host URL"],
["ssh engine urls (unsupported)", "ssh://deploy@host", "Invalid Docker host URL"],
];
@@ -0,0 +1,155 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { SignalgridProvider } = await import("../../../../src/domain/notifications/providers/signalgrid.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new SignalgridProvider(logger as any), logger };
};
testNotificationProviderContract("SignalgridProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("SignalgridProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends to Signalgrid API and returns true", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://api.signalgrid.co/v1/push",
expect.objectContaining({
form: expect.objectContaining({
client_key: "token-abc",
channel: "https://hooks.example.com/webhook",
type: "INFO",
critical: false,
}),
})
);
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } }));
});
it("handles non-Error thrown values in sendTestAlert", async () => {
mockGotPost.mockRejectedValue("string error");
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } }));
});
});
describe("sendMessage", () => {
it("sends message and returns true", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const form = mockGotPost.mock.calls[0][1].form;
expect(form.body).toContain("Monitor Details:");
expect(form.title).toBeDefined();
expect(form.critical).toBe(false);
});
it("maps critical severity to CRIT", async () => {
const { provider } = createProvider();
const message = makeMessage();
message.severity = "critical";
await provider.sendMessage(makeNotification() as any, message);
expect(mockGotPost.mock.calls[0][1].form.type).toBe("CRIT");
});
it("maps warning severity to WARN", async () => {
const { provider } = createProvider();
const message = makeMessage();
message.severity = "warning";
await provider.sendMessage(makeNotification() as any, message);
expect(mockGotPost.mock.calls[0][1].form.type).toBe("WARN");
});
it("maps success severity to SUCCESS", async () => {
const { provider } = createProvider();
const message = makeMessage();
message.severity = "success";
await provider.sendMessage(makeNotification() as any, message);
expect(mockGotPost.mock.calls[0][1].form.type).toBe("SUCCESS");
});
it("maps info severity to INFO", async () => {
const { provider } = createProvider();
const message = makeMessage();
message.severity = "info";
await provider.sendMessage(makeNotification() as any, message);
expect(mockGotPost.mock.calls[0][1].form.type).toBe("INFO");
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
});
it("handles non-Error thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(42);
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } }));
});
it("includes thresholds in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
expect(mockGotPost.mock.calls[0][1].form.body).toContain("CPU");
});
it("includes incident link in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
expect(mockGotPost.mock.calls[0][1].form.body).toContain("View Incident");
});
it("omits optional sections when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
msg.content.incident = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const text = mockGotPost.mock.calls[0][1].form.body;
expect(text).not.toContain("Threshold");
expect(text).not.toContain("Additional");
expect(text).not.toContain("View Incident");
});
});
});
@@ -22,7 +22,7 @@ const activeWindow = () => {
const createProducer = (overrides?: Record<string, any>) => {
const defaults = {
logger: createMockLogger(),
monitorsRepository: { updateById: jest.fn().mockResolvedValue({}) },
monitorsRepository: { updateById: jest.fn().mockResolvedValue({}), findDockerTlsKeyById: jest.fn().mockResolvedValue(null) },
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) },
checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) },
networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
@@ -111,6 +111,48 @@ describe("CheckProducer", () => {
// ── acquire / record ──────────────────────────────────────────────────────
// ── docker tls key resolution ─────────────────────────────────────────────
it("fetches the stored key for a docker monitor with a key set and passes it in the context", async () => {
const { producer, defaults } = createProducer({
monitorsRepository: { updateById: jest.fn(), findDockerTlsKeyById: jest.fn().mockResolvedValue("v1.abc123.iv.tag.data") },
});
const monitor = makeMonitor({ type: "docker", url: "tcp://host", dockerTlsKeySet: true });
await producer.produce(monitor);
expect(defaults.monitorsRepository.findDockerTlsKeyById).toHaveBeenCalledWith("m1");
expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: "v1.abc123.iv.tag.data" });
});
it("does not fetch a key for a docker monitor without one set", async () => {
const { producer, defaults } = createProducer();
const monitor = makeMonitor({ type: "docker", url: "unix:///var/run/docker.sock", dockerTlsKeySet: false });
await producer.produce(monitor);
expect(defaults.monitorsRepository.findDockerTlsKeyById).not.toHaveBeenCalled();
expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined });
});
it("does not fetch a key for non-docker monitors even if the flag is set", async () => {
const { producer, defaults } = createProducer();
await producer.produce(makeMonitor({ type: "http", dockerTlsKeySet: true }));
expect(defaults.monitorsRepository.findDockerTlsKeyById).not.toHaveBeenCalled();
});
it("passes undefined when the flag is set but the repository has no key", async () => {
const { producer, defaults } = createProducer();
const monitor = makeMonitor({ type: "docker", url: "tcp://host", dockerTlsKeySet: true });
await producer.produce(monitor);
expect(defaults.monitorsRepository.findDockerTlsKeyById).toHaveBeenCalledWith("m1");
expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined });
});
it("throws when the network response is null", async () => {
const { producer } = createProducer({
networkService: { requestStatus: jest.fn().mockResolvedValue(null) },
@@ -0,0 +1,239 @@
import { describe, expect, it } from "@jest/globals";
import * as nodeCrypto from "node:crypto";
import { EncryptionService } from "../../../src/service/encryption/encryptionService.ts";
import type { EncryptionCryptoLib } from "../../../src/service/encryption/encryptionService.ts";
import { AppError } from "../../../src/utils/AppError.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
const newKey = () => nodeCrypto.randomBytes(32);
const setup = (keys: Buffer[], cryptoLib: EncryptionCryptoLib = nodeCrypto) => {
const logger = createMockLogger();
const service = new EncryptionService(keys, logger, cryptoLib);
return { service, logger };
};
const expectAppError = (fn: () => unknown, message: string, details?: Record<string, unknown>) => {
let caught: unknown;
try {
fn();
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(AppError);
const appError = caught as AppError;
expect(appError.message).toBe(message);
expect(appError.status).toBe(500);
expect(appError.service).toBe("EncryptionService");
if (details) expect(appError.details).toEqual(details);
};
const tamper = (ciphertext: string, segment: number, replacer: (value: string) => string) => {
const parts = ciphertext.split(".");
parts[segment] = replacer(parts[segment] ?? "");
return parts.join(".");
};
const flipFirstChar = (value: string) => (value[0] === "A" ? "B" : "A") + value.slice(1);
describe("EncryptionService", () => {
describe("construction", () => {
it("is unconfigured with no keys and says so", () => {
const { service, logger } = setup([]);
expect(service.isConfigured()).toBe(false);
expectAppError(() => service.currentKeyId(), "ENCRYPTION_KEY is not configured");
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "ENCRYPTION_KEY not set; encrypted fields are unavailable" }));
});
it("is configured with one key and exposes a 6-character base64url key id", () => {
const { service, logger } = setup([newKey()]);
expect(service.isConfigured()).toBe(true);
expect(service.currentKeyId()).toMatch(/^[A-Za-z0-9_-]{6}$/);
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Encryption configured with 1 key(s)") }));
});
it("rejects an entry that is not 32 bytes, naming its index", () => {
const short = nodeCrypto.randomBytes(31);
const long = nodeCrypto.randomBytes(33);
expectAppError(() => setup([newKey(), short]), "ENCRYPTION_KEY is not 32 bytes", { keyIdx: 1 });
expectAppError(() => setup([long]), "ENCRYPTION_KEY is not 32 bytes", { keyIdx: 0 });
});
it("rejects the same key listed twice", () => {
const key = newKey();
expectAppError(() => setup([key, key]), "ENCRYPTION_KEY key ids collide; regenerate one key", { index: 1 });
});
it("treats the first key as current", () => {
const a = newKey();
const b = newKey();
const { service: onlyA } = setup([a]);
const { service: onlyB } = setup([b]);
const { service: bThenA } = setup([b, a]);
expect(bThenA.currentKeyId()).toBe(onlyB.currentKeyId());
expect(bThenA.currentKeyId()).not.toBe(onlyA.currentKeyId());
});
});
describe("round trip", () => {
it.each([
["ascii", "hello"],
["pem-shaped", "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7\n-----END PRIVATE KEY-----\n"],
["multi-byte utf-8", "clé privée — 秘密鍵 🔑"],
["empty", ""],
])("decrypts what it encrypted (%s)", (_label, plaintext) => {
const { service } = setup([newKey()]);
expect(service.decrypt(service.encrypt(plaintext))).toBe(plaintext);
});
it("uses a fresh IV per call so equal plaintexts encrypt differently", () => {
const { service } = setup([newKey()]);
const first = service.encrypt("same");
const second = service.encrypt("same");
expect(first).not.toBe(second);
expect(service.decrypt(first)).toBe("same");
expect(service.decrypt(second)).toBe("same");
});
it("produces the documented five-segment base64url form", () => {
const { service } = setup([newKey()]);
expect(service.encrypt("x")).toMatch(/^v1\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]*$/);
});
it("does not leak the plaintext into the stored form", () => {
const { service } = setup([newKey()]);
const ciphertext = service.encrypt("-----BEGIN PRIVATE KEY-----");
expect(ciphertext).not.toContain("PRIVATE");
expect(Buffer.from(ciphertext.split(".")[4] ?? "", "base64url").toString("utf8")).not.toContain("PRIVATE");
});
it("is deterministic for a fixed IV, pinning the format", () => {
const fixedIv = Buffer.alloc(12, 7);
const cryptoLib: EncryptionCryptoLib = {
createCipheriv: nodeCrypto.createCipheriv,
createDecipheriv: nodeCrypto.createDecipheriv,
createHash: nodeCrypto.createHash,
randomBytes: () => fixedIv,
};
const key = newKey();
const { service } = setup([key], cryptoLib);
const first = service.encrypt("pinned");
const second = service.encrypt("pinned");
expect(first).toBe(second);
expect(first.split(".")[2]).toBe(fixedIv.toString("base64url"));
expect(setup([key]).service.decrypt(first)).toBe("pinned");
});
});
describe("key selection and rotation", () => {
it("decrypts with a key that is not first in the list and reports it needs re-encryption", () => {
const a = newKey();
const b = newKey();
const { service: withA } = setup([a]);
const ciphertext = withA.encrypt("secret");
const { service: withBThenA } = setup([b, a]);
expect(withBThenA.decrypt(ciphertext)).toBe("secret");
expect(withBThenA.keyIdOf(ciphertext)).toBe(withA.currentKeyId());
expect(withBThenA.needsReencryption(ciphertext)).toBe(true);
});
it("encrypts under the first key and does not flag it for re-encryption", () => {
const { service } = setup([newKey(), newKey()]);
const ciphertext = service.encrypt("secret");
expect(service.keyIdOf(ciphertext)).toBe(service.currentKeyId());
expect(service.needsReencryption(ciphertext)).toBe(false);
});
it("fails with the key id when no listed key matches, but keyIdOf still answers", () => {
const a = newKey();
const { service: withA } = setup([a]);
const ciphertext = withA.encrypt("secret");
const idOfA = withA.currentKeyId();
const { service: withB } = setup([newKey()]);
expectAppError(() => withB.decrypt(ciphertext), "No encryption key matches ciphertext", { keyId: idOfA });
expect(withB.keyIdOf(ciphertext)).toBe(idOfA);
});
});
describe("tampering and malformed input", () => {
it("rejects a modified data segment", () => {
const { service } = setup([newKey()]);
const ciphertext = service.encrypt("payload");
expectAppError(() => service.decrypt(tamper(ciphertext, 4, flipFirstChar)), "Ciphertext failed authentication");
});
it("rejects a modified tag segment", () => {
const { service } = setup([newKey()]);
const ciphertext = service.encrypt("payload");
expectAppError(() => service.decrypt(tamper(ciphertext, 3, flipFirstChar)), "Ciphertext failed authentication");
});
it("rejects a modified iv segment", () => {
const { service } = setup([newKey()]);
const ciphertext = service.encrypt("payload");
expectAppError(() => service.decrypt(tamper(ciphertext, 2, flipFirstChar)), "Ciphertext failed authentication");
});
it("rejects a ciphertext relabelled with another listed key's id", () => {
const a = newKey();
const b = newKey();
const { service } = setup([a, b]);
const ciphertext = service.encrypt("payload");
const idOfB = setup([b]).service.currentKeyId();
expectAppError(() => service.decrypt(tamper(ciphertext, 1, () => idOfB)), "Ciphertext failed authentication");
});
it("rejects a ciphertext whose key id matches a listed key made from different bytes", () => {
const a = newKey();
const b = newKey();
const ciphertext = setup([a]).service.encrypt("payload");
const { service: withB } = setup([b]);
const forged = tamper(ciphertext, 1, () => withB.currentKeyId());
expectAppError(() => withB.decrypt(forged), "Ciphertext failed authentication");
});
it.each([
["too few segments", "v1.abc.def"],
["too many segments", "v1.a.b.c.d.e"],
["empty key id", "v1..AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAA.AA"],
["standard base64 padding", "v1.abcdef.AAAAAAAAAAAAAAA=.AAAAAAAAAAAAAAAAAAAAAA.AA"],
["plus sign in a segment", "v1.abcdef.AAAAAAAAAAAAAA+A.AAAAAAAAAAAAAAAAAAAAAA.AA"],
["iv of the wrong length", "v1.abcdef.AAAA.AAAAAAAAAAAAAAAAAAAAAA.AA"],
["tag of the wrong length", "v1.abcdef.AAAAAAAAAAAAAAAA.AAAA.AA"],
])("rejects malformed v1 input (%s)", (_label, input) => {
const { service } = setup([newKey()]);
expectAppError(() => service.decrypt(input), "Ciphertext is malformed");
expectAppError(() => service.keyIdOf(input), "Ciphertext is malformed");
});
it.each([
["empty string", "", ""],
["no version segment", "abcdef.AAAA.AAAA.AA", "abcdef"],
["future version with a different layout", "v2.x.y.z.w.v.u", "v2"],
])("reports the version before any layout rule (%s)", (_label, input, version) => {
const { service } = setup([newKey()]);
expectAppError(() => service.decrypt(input), "Unsupported ciphertext version", { version });
expectAppError(() => service.keyIdOf(input), "Unsupported ciphertext version", { version });
});
});
describe("unconfigured service", () => {
it("refuses to encrypt, decrypt, or evaluate rotation", () => {
const { service: configured } = setup([newKey()]);
const ciphertext = configured.encrypt("x");
const { service } = setup([]);
expectAppError(() => service.encrypt("x"), "ENCRYPTION_KEY is not configured");
expectAppError(() => service.decrypt(ciphertext), "ENCRYPTION_KEY is not configured");
expectAppError(() => service.needsReencryption(ciphertext), "ENCRYPTION_KEY is not configured");
});
it("still parses a key id out of a well-formed ciphertext", () => {
const { service: configured } = setup([newKey()]);
const ciphertext = configured.encrypt("x");
const { service } = setup([]);
expect(service.keyIdOf(ciphertext)).toBe(configured.currentKeyId());
});
});
});
@@ -143,6 +143,21 @@ describe("JobScheduler", () => {
});
});
describe("addJob", () => {
it("preserves an arbitrary monitor interval in the scheduled job", async () => {
const { scheduler, mocks } = createScheduler();
await scheduler.addJob("m1", makeMonitor({ interval: 75000 }));
expect(mocks.jobsRepository.upsertJob).toHaveBeenCalledWith(
expect.objectContaining({
id: "check:m1",
intervalMs: 75000,
})
);
});
});
// Regression: a heartbeat upsert racing shutdown's deleteById could resurrect the
// registry row, leaving a ghost worker that inflates the alive count until TTL.
// shutdown() must await the in-flight beat and issue the delete as the LAST write.
@@ -7,6 +7,8 @@ import type { IIncidentsRepository } from "../../../src/domain/incidents/inciden
import type { IMonitorStatsRepository } from "../../../src/domain/monitor-stats/monitor-stats.repository.interface.ts";
import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts";
import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts";
import type { IEncryptionService } from "../../../src/service/encryption/encryptionService.ts";
import { AppError } from "../../../src/utils/AppError.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
const createMonitorsRepositoryMock = () =>
@@ -70,6 +72,17 @@ const createJobQueueMock = () => ({
deleteJob: jest.fn(),
});
const createEncryptionServiceMock = (overrides: Partial<IEncryptionService> = {}) =>
({
isConfigured: jest.fn(() => true),
currentKeyId: jest.fn(() => "key-1"),
keyIdOf: jest.fn(() => "key-1"),
needsReencryption: jest.fn(() => false),
encrypt: jest.fn((plaintext: string) => `enc:${plaintext}`),
decrypt: jest.fn((ciphertext: string) => ciphertext.replace(/^enc:/, "")),
...overrides,
}) as unknown as IEncryptionService;
const TEAM_ID = "team-1";
const MONITOR_ID = "monitor-1";
const USER_ID = "user-1";
@@ -98,6 +111,7 @@ const createService = (
geoChecksRepository?: IGeoChecksRepository;
dockerLogsRepository?: IDockerLogsRepository;
incidentsRepository?: IIncidentsRepository;
encryptionService?: IEncryptionService;
jobQueue?: ReturnType<typeof createJobQueueMock>;
logger?: ReturnType<typeof createMockLogger>;
games?: Record<string, unknown>;
@@ -116,22 +130,24 @@ const createService = (
monitorStatsRepository: overrides.monitorStatsRepository ?? createMonitorStatsRepositoryMock(),
statusPagesRepository: overrides.statusPagesRepository ?? createStatusPagesRepositoryMock(),
incidentsRepository: overrides.incidentsRepository ?? createIncidentsRepositoryMock(),
encryptionService: overrides.encryptionService ?? createEncryptionServiceMock(),
});
return { service, jobQueue, logger };
};
describe("MonitorService", () => {
describe("createMonitor", () => {
it("creates a monitor and adds a job", async () => {
it("creates a monitor, adds a job and returns the created document", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const monitor = makeMonitor();
(monitorsRepository.create as jest.Mock).mockResolvedValue(monitor);
const { service, jobQueue } = createService({ monitorsRepository });
await service.createMonitor(TEAM_ID, USER_ID, monitor as any);
const result = await service.createMonitor(TEAM_ID, USER_ID, monitor as any);
expect(monitorsRepository.create).toHaveBeenCalledWith(monitor, TEAM_ID, USER_ID);
expect(jobQueue.addJob).toHaveBeenCalledWith(MONITOR_ID, monitor);
expect(result).toBe(monitor);
});
it("strips a stray proxyId when proxyMode is not custom", async () => {
@@ -1892,6 +1908,7 @@ describe("MonitorService", () => {
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
encryptionService: createEncryptionServiceMock(),
});
const result = await service.deleteAllMonitors({ teamId: TEAM_ID });
@@ -1980,3 +1997,167 @@ describe("MonitorService", () => {
});
});
});
describe("MonitorService — Docker TLS credentials", () => {
const KEY_PEM = "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----";
const tlsBody = { type: "docker" as const, url: "tcp://host:2376", dockerTlsCa: "ca-pem", dockerTlsCert: "cert-pem", dockerTlsKey: KEY_PEM };
const expectAppError = async (fn: () => Promise<unknown>, status: number, message: string | RegExp) => {
let caught: unknown;
try {
await fn();
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(AppError);
expect((caught as AppError).status).toBe(status);
expect((caught as AppError).message).toMatch(message);
};
describe("createMonitor", () => {
it("encrypts the key and sets dockerTlsKeySet for a TLS url", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.create as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.createMonitor(TEAM_ID, USER_ID, makeMonitor(tlsBody) as any);
expect(encryptionService.encrypt).toHaveBeenCalledWith(KEY_PEM);
expect(monitorsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ dockerTlsCa: "ca-pem", dockerTlsCert: "cert-pem", dockerTlsKey: `enc:${KEY_PEM}`, dockerTlsKeySet: true }),
TEAM_ID,
USER_ID
);
});
it("does not encrypt anything for a socket url and leaves dockerTlsKeySet false", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.create as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.createMonitor(TEAM_ID, USER_ID, makeMonitor({ type: "docker", url: "unix:///var/run/docker.sock" }) as any);
expect(encryptionService.encrypt).not.toHaveBeenCalled();
const [created] = (monitorsRepository.create as jest.Mock).mock.calls[0] as [Record<string, unknown>];
expect(created.dockerTlsKeySet).toBe(false);
expect(created.dockerTlsKey).toBeUndefined();
});
it("ignores TLS fields on non-docker monitors", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.create as jest.Mock).mockResolvedValue(makeMonitor());
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.createMonitor(TEAM_ID, USER_ID, makeMonitor({ dockerTlsKey: KEY_PEM }) as any);
expect(encryptionService.encrypt).not.toHaveBeenCalled();
expect(monitorsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ dockerTlsKey: KEY_PEM }), TEAM_ID, USER_ID);
});
it("rejects with a 422 naming ENCRYPTION_KEY when encryption is not configured", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const encryptionService = createEncryptionServiceMock({ isConfigured: jest.fn(() => false) as any });
const { service } = createService({ monitorsRepository, encryptionService });
await expectAppError(() => service.createMonitor(TEAM_ID, USER_ID, makeMonitor(tlsBody) as any), 422, /ENCRYPTION_KEY/);
expect(encryptionService.encrypt).not.toHaveBeenCalled();
expect(monitorsRepository.create).not.toHaveBeenCalled();
});
});
describe("editMonitor", () => {
it("keeps the stored key when the key field is blank", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker", dockerTlsKeySet: true }));
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { ...tlsBody, dockerTlsKey: "" } });
expect(encryptionService.encrypt).not.toHaveBeenCalled();
const [, , patch] = (monitorsRepository.updateById as jest.Mock).mock.calls[0] as [string, string, Record<string, unknown>];
expect(patch).not.toHaveProperty("dockerTlsKey");
expect(patch).not.toHaveProperty("dockerTlsKeySet");
expect(patch).toMatchObject({ dockerTlsCa: "ca-pem", dockerTlsCert: "cert-pem" });
});
it("rejects a blank key with a 422 when no key is stored", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker", dockerTlsKeySet: false }));
const { service } = createService({ monitorsRepository });
await expectAppError(
() => service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { ...tlsBody, dockerTlsKey: "" } }),
422,
/required for a TLS Docker host/
);
expect(monitorsRepository.updateById).not.toHaveBeenCalled();
});
it("encrypts a replacement key without reading the stored monitor", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: tlsBody });
expect(monitorsRepository.findById).not.toHaveBeenCalled();
expect(monitorsRepository.updateById).toHaveBeenCalledWith(
MONITOR_ID,
TEAM_ID,
expect.objectContaining({ dockerTlsKey: `enc:${KEY_PEM}`, dockerTlsKeySet: true }),
{ unsetProxyId: false }
);
});
it("encrypts a replacement key when the body omits the url", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { type: "docker", dockerTlsKey: KEY_PEM } });
expect(encryptionService.encrypt).toHaveBeenCalledWith(KEY_PEM);
expect(monitorsRepository.updateById).toHaveBeenCalledWith(
MONITOR_ID,
TEAM_ID,
expect.objectContaining({ dockerTlsKey: `enc:${KEY_PEM}`, dockerTlsKeySet: true }),
{ unsetProxyId: false }
);
});
it("clears dockerTlsKeySet when the url changes back to a socket", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.editMonitor({
teamId: TEAM_ID,
monitorId: MONITOR_ID,
body: { type: "docker", url: "unix:///var/run/docker.sock", dockerTlsKey: KEY_PEM },
});
expect(encryptionService.encrypt).not.toHaveBeenCalled();
expect(monitorsRepository.findById).not.toHaveBeenCalled();
const [, , patch] = (monitorsRepository.updateById as jest.Mock).mock.calls[0] as [string, string, Record<string, unknown>];
expect(patch.dockerTlsKeySet).toBe(false);
expect(patch.dockerTlsKey).toBeUndefined();
});
it("rejects with a 422 naming ENCRYPTION_KEY when encryption is not configured", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const encryptionService = createEncryptionServiceMock({ isConfigured: jest.fn(() => false) as any });
const { service } = createService({ monitorsRepository, encryptionService });
await expectAppError(() => service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: tlsBody }), 422, /ENCRYPTION_KEY/);
expect(monitorsRepository.updateById).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,156 @@
import { describe, expect, it, jest } from "@jest/globals";
import * as nodeCrypto from "node:crypto";
import { EncryptionService } from "../../../src/service/encryption/encryptionService.ts";
import { SecretsRotationService } from "../../../src/service/encryption/secretsRotationService.ts";
import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
type Row = { id: string; dockerTlsKey: string };
const PLAINTEXT = "-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----";
const newKey = () => nodeCrypto.randomBytes(32);
const encryptionServiceFor = (keys: Buffer[]) => new EncryptionService(keys, createMockLogger(), nodeCrypto);
const tamperData = (ciphertext: string) => {
const parts = ciphertext.split(".");
const data = parts[4] ?? "";
parts[4] = (data[0] === "A" ? "B" : "A") + data.slice(1);
return parts.join(".");
};
const setup = (keys: Buffer[], rows: Row[]) => {
const encryptionService = encryptionServiceFor(keys);
const repository = {
findAllDockerTlsKeys: jest.fn(async () => rows),
updateDockerTlsKey: jest.fn(async () => undefined),
};
const logger = createMockLogger();
const service = new SecretsRotationService(repository as unknown as IMonitorsRepository, encryptionService, logger);
return { service, encryptionService, repository, logger };
};
const updatedKeyFor = (repository: { updateDockerTlsKey: jest.Mock }, id: string): string => {
const call = repository.updateDockerTlsKey.mock.calls.find(([calledId]) => calledId === id);
expect(call).toBeDefined();
return (call as [string, string])[1];
};
describe("SecretsRotationService", () => {
it("re-encrypts stale rows under the current key and leaves current rows untouched", async () => {
const current = newKey();
const previous = newKey();
const stale = encryptionServiceFor([previous]);
const rows = [
{ id: "m-current", dockerTlsKey: encryptionServiceFor([current]).encrypt(PLAINTEXT) },
{ id: "m-stale-1", dockerTlsKey: stale.encrypt(PLAINTEXT) },
{ id: "m-stale-2", dockerTlsKey: stale.encrypt(PLAINTEXT) },
];
const { service, encryptionService, repository, logger } = setup([current, previous], rows);
const result = await service.run();
expect(result).toEqual({ scanned: 3, reencrypted: 2, skipped: 0 });
expect(repository.updateDockerTlsKey).toHaveBeenCalledTimes(2);
for (const id of ["m-stale-1", "m-stale-2"]) {
const rotated = updatedKeyFor(repository, id);
expect(encryptionService.keyIdOf(rotated)).toBe(encryptionService.currentKeyId());
expect(encryptionService.decrypt(rotated)).toBe(PLAINTEXT);
}
expect(repository.updateDockerTlsKey).not.toHaveBeenCalledWith("m-current", expect.anything());
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({ message: "Docker TLS key rotation: 2 re-encrypted, 0 skipped, 3 scanned", service: "SecretsRotationService" })
);
});
it("returns zero counts when every row is already under the current key", async () => {
const current = newKey();
const rows = [
{ id: "m-1", dockerTlsKey: encryptionServiceFor([current]).encrypt(PLAINTEXT) },
{ id: "m-2", dockerTlsKey: encryptionServiceFor([current]).encrypt(PLAINTEXT) },
];
const { service, repository, logger } = setup([current], rows);
const result = await service.run();
expect(result).toEqual({ scanned: 2, reencrypted: 0, skipped: 0 });
expect(repository.updateDockerTlsKey).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Docker TLS key rotation: 0 re-encrypted, 0 skipped, 2 scanned" }));
});
it("skips a row encrypted under an unknown key and logs its key id", async () => {
const unknown = encryptionServiceFor([newKey()]);
const rows = [{ id: "m-unknown", dockerTlsKey: unknown.encrypt(PLAINTEXT) }];
const { service, repository, logger } = setup([newKey()], rows);
const result = await service.run();
expect(result).toEqual({ scanned: 1, reencrypted: 0, skipped: 1 });
expect(repository.updateDockerTlsKey).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Could not reencrypt Docker TLS key for monitor m-unknown"),
service: "SecretsRotationService",
details: { monitorId: "m-unknown", keyId: unknown.currentKeyId() },
})
);
});
it("continues past a row that fails to decrypt", async () => {
const current = newKey();
const previous = newKey();
const stale = encryptionServiceFor([previous]);
const rows = [
{ id: "m-broken", dockerTlsKey: tamperData(stale.encrypt(PLAINTEXT)) },
{ id: "m-stale", dockerTlsKey: stale.encrypt(PLAINTEXT) },
];
const { service, encryptionService, repository, logger } = setup([current, previous], rows);
const result = await service.run();
expect(result).toEqual({ scanned: 2, reencrypted: 1, skipped: 1 });
expect(repository.updateDockerTlsKey).toHaveBeenCalledTimes(1);
expect(encryptionService.decrypt(updatedKeyFor(repository, "m-stale"))).toBe(PLAINTEXT);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Could not reencrypt Docker TLS key for monitor m-broken: Ciphertext failed authentication"),
details: expect.objectContaining({ monitorId: "m-broken" }),
})
);
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Docker TLS key rotation: 1 re-encrypted, 1 skipped, 2 scanned" }));
});
it("warns and skips every row when ENCRYPTION_KEY is not configured", async () => {
const rows = [
{ id: "m-1", dockerTlsKey: encryptionServiceFor([newKey()]).encrypt(PLAINTEXT) },
{ id: "m-2", dockerTlsKey: encryptionServiceFor([newKey()]).encrypt(PLAINTEXT) },
];
const { service, repository, logger } = setup([], rows);
const result = await service.run();
expect(result).toEqual({ scanned: 2, reencrypted: 0, skipped: 2 });
expect(repository.updateDockerTlsKey).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "2 monitor(s) have a stored Docker TLS key but ENCRYPTION_KEY is not set; their checks will fail until it is",
service: "SecretsRotationService",
})
);
expect(logger.info).not.toHaveBeenCalled();
});
it("logs nothing when ENCRYPTION_KEY is not configured and there are no rows", async () => {
const { service, repository, logger } = setup([], []);
const result = await service.run();
expect(result).toEqual({ scanned: 0, reencrypted: 0, skipped: 0 });
expect(repository.updateDockerTlsKey).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
});
});
@@ -16,6 +16,7 @@ const makeEnv = (overrides?: Partial<ValidatedEnv>): ValidatedEnv =>
DB_CONNECTION_STRING: "mongodb://localhost:27017/test_db",
DB_TYPE: "mongodb",
STATUS_PAGE_THEMES_ENABLED: false,
ENCRYPTION_KEY: [],
...overrides,
}) as ValidatedEnv;
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "@jest/globals";
import { DOCKER_TLS_URL, isDockerSocketUrl, isDockerTlsUrl } from "../../../src/utils/dockerHost.ts";
describe("isDockerTlsUrl", () => {
it.each(["tcp://host", "tcp://host:2376", "https://host", "https://host:2377", "tcp://host/", "tcp://10.0.0.1:2376", " tcp://host "])(
"accepts %s",
(url) => {
expect(isDockerTlsUrl(url)).toBe(true);
}
);
it.each([
"http://host",
"host:2376",
"tcp://host/x",
"tcp://host:abc",
"tcp://a:b:c",
"tcp://[::1]:2376",
"tcp://ho st",
"ssh://deploy@host",
"unix:///var/run/docker.sock",
"",
undefined,
])("rejects %s", (url) => {
expect(isDockerTlsUrl(url)).toBe(false);
});
it("captures scheme, host and port for the provider", () => {
expect(DOCKER_TLS_URL.exec("https://host:2377")?.slice(1)).toEqual(["https", "host", "2377"]);
expect(DOCKER_TLS_URL.exec("tcp://host")?.slice(1)).toEqual(["tcp", "host", undefined]);
});
});
describe("isDockerSocketUrl", () => {
it.each(["unix:///var/run/docker.sock", "/var/run/docker.sock", " /run/docker.sock "])("accepts %s", (url) => {
expect(isDockerSocketUrl(url)).toBe(true);
});
it.each(["unix://", "unix://relative/path", "tcp://host", "my-container", "", undefined])("rejects %s", (url) => {
expect(isDockerSocketUrl(url)).toBe(false);
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "@jest/globals";
import { readFileSync } from "node:fs";
import { X509Certificate } from "node:crypto";
import { keyMatchesCertificate, parseCertificates, parsePrivateKey, splitCertificateBundle } from "../../../src/utils/pem.ts";
const fixture = (name: string) => readFileSync(new URL(`../../fixtures/docker-tls/${name}`, import.meta.url), "utf8");
const CA = fixture("ca.pem");
const CLIENT_CERT = fixture("client-cert.pem");
const CLIENT_KEY = fixture("client-key.pem");
const OTHER_KEY = fixture("other-key.pem");
const ENCRYPTED_KEY = fixture("encrypted-key.pem");
describe("splitCertificateBundle", () => {
it("returns one block for a single certificate", () => {
expect(splitCertificateBundle(CA)).toHaveLength(1);
});
it("returns every block of a bundle in order", () => {
const blocks = splitCertificateBundle(`${CA}\n${CLIENT_CERT}`);
expect(blocks).toHaveLength(2);
expect(blocks[0]).toBe(CA.trim());
expect(blocks[1]).toBe(CLIENT_CERT.trim());
});
it("ignores text outside the blocks", () => {
expect(splitCertificateBundle(`subject=CN=x\n${CA}\ntrailing`)).toHaveLength(1);
});
it("returns an empty array when there is no certificate block", () => {
expect(splitCertificateBundle("")).toEqual([]);
expect(splitCertificateBundle(CLIENT_KEY)).toEqual([]);
expect(splitCertificateBundle("-----BEGIN CERTIFICATE-----\nunterminated")).toEqual([]);
});
});
describe("parseCertificates", () => {
it("parses a single certificate", () => {
const [cert] = parseCertificates(CA);
expect(cert).toBeInstanceOf(X509Certificate);
expect(cert?.subject).toContain("CN=Checkmate Test CA");
});
it("parses every certificate in a bundle", () => {
const certs = parseCertificates(`${CA}\n${CLIENT_CERT}`);
expect(certs.map((c) => c.subject)).toEqual([expect.stringContaining("Checkmate Test CA"), expect.stringContaining("checkmate-test-client")]);
});
it("rejects input with no certificate block", () => {
expect(() => parseCertificates("")).toThrow("No certificate found");
expect(() => parseCertificates(CLIENT_KEY)).toThrow("No certificate found");
});
it("rejects a block whose body is not a certificate", () => {
expect(() => parseCertificates("-----BEGIN CERTIFICATE-----\nbm90IGEgY2VydA==\n-----END CERTIFICATE-----")).toThrow(
"Certificate is not valid PEM"
);
});
});
describe("parsePrivateKey", () => {
it("parses an unencrypted PKCS#8 key", () => {
expect(parsePrivateKey(CLIENT_KEY).type).toBe("private");
});
it("rejects an encrypted PKCS#8 key with a clear message", () => {
expect(() => parsePrivateKey(ENCRYPTED_KEY)).toThrow("Encrypted private keys are not supported");
});
it("rejects a legacy encrypted key by its Proc-Type header", () => {
const legacy = "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,00\n\nAAAA\n-----END RSA PRIVATE KEY-----";
expect(() => parsePrivateKey(legacy)).toThrow("Encrypted private keys are not supported");
});
it("rejects input that is not a key", () => {
expect(() => parsePrivateKey("")).toThrow("Private key is not valid PEM");
expect(() => parsePrivateKey(CA)).toThrow("Private key is not valid PEM");
});
});
describe("keyMatchesCertificate", () => {
it("is true for the key the certificate was issued to", () => {
const [cert] = parseCertificates(CLIENT_CERT);
expect(keyMatchesCertificate(parsePrivateKey(CLIENT_KEY), cert!)).toBe(true);
});
it("is false for an unrelated key", () => {
const [cert] = parseCertificates(CLIENT_CERT);
expect(keyMatchesCertificate(parsePrivateKey(OTHER_KEY), cert!)).toBe(false);
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from "@jest/globals";
import { registerInviteTokenValidation } from "../../../src/api/validation/authValidation.ts";
describe("authValidation", () => {
describe("registerInviteTokenValidation", () => {
it("accepts a valid token string", () => {
expect(registerInviteTokenValidation.parse("a".repeat(64))).toBe("a".repeat(64));
});
it("defaults an absent token to an empty string", () => {
expect(registerInviteTokenValidation.parse(undefined)).toBe("");
});
it("rejects a NoSQL operator object in place of a token", () => {
expect(() => registerInviteTokenValidation.parse({ $ne: null })).toThrow();
});
it("rejects an array token", () => {
expect(() => registerInviteTokenValidation.parse(["a", "b"])).toThrow();
});
});
});
@@ -0,0 +1,69 @@
import { describe, expect, it } from "@jest/globals";
import { randomBytes } from "node:crypto";
import { encryptionKeyList } from "../../../src/config/envValidation.ts";
const key = () => randomBytes(32).toString("base64");
const decoded = (k: string) => Buffer.from(k, "base64");
const issueMessages = (input: string) => {
const result = encryptionKeyList.safeParse(input);
expect(result.success).toBe(false);
return result.success ? [] : result.error.issues.map((issue) => issue.message);
};
describe("encryptionKeyList", () => {
it("is an empty list when unset or blank", () => {
expect(encryptionKeyList.parse(undefined)).toEqual([]);
expect(encryptionKeyList.parse("")).toEqual([]);
expect(encryptionKeyList.parse(" , ")).toEqual([]);
});
it("accepts one key, trimming whitespace and a trailing comma", () => {
const k = key();
expect(encryptionKeyList.parse(k)).toEqual([decoded(k)]);
expect(encryptionKeyList.parse(` ${k} ,`)).toEqual([decoded(k)]);
});
it("accepts several keys in the given order", () => {
const a = key();
const b = key();
expect(encryptionKeyList.parse(`${a},${b}`)).toEqual([decoded(a), decoded(b)]);
});
it.each([
["43 characters", key().slice(0, 43)],
["no trailing =", key().slice(0, 43) + "A"],
["a space inside", key().replace(/^(.{10})/, "$1 ")],
["base64url alphabet", key().replace(/[+/]/g, "-").replace(/=$/, "")],
["31 bytes", randomBytes(31).toString("base64")],
["33 bytes", randomBytes(33).toString("base64")],
])("rejects an entry that is not 32 standard-base64 bytes (%s)", (_label, bad) => {
expect(issueMessages(`${key()},${bad}`)).toContain("ENCRYPTION_KEY entry 2 must be 32 bytes as padded standard base64 (openssl rand -base64 32)");
});
it("reports every bad entry, not just the first", () => {
const messages = issueMessages(`${key().slice(0, 43)},${key()},${randomBytes(31).toString("base64")}`);
expect(messages).toContain("ENCRYPTION_KEY entry 1 must be 32 bytes as padded standard base64 (openssl rand -base64 32)");
expect(messages).toContain("ENCRYPTION_KEY entry 3 must be 32 bytes as padded standard base64 (openssl rand -base64 32)");
expect(messages).toHaveLength(2);
});
it("numbers entries by comma position, counting blank slots", () => {
const bad = randomBytes(31).toString("base64");
expect(issueMessages(`${key()},,${bad}`)).toContain(
"ENCRYPTION_KEY entry 3 must be 32 bytes as padded standard base64 (openssl rand -base64 32)"
);
});
it("rejects the same key listed twice", () => {
const k = key();
expect(issueMessages(`${k},${k}`)).toContain("ENCRYPTION_KEY entry 2 duplicates an earlier entry");
});
it("never echoes the entry in an issue message", () => {
const bad = randomBytes(31).toString("base64");
for (const message of issueMessages(bad)) {
expect(message).not.toContain(bad);
}
});
});
@@ -1,4 +1,5 @@
import { describe, expect, it } from "@jest/globals";
import { readFileSync } from "node:fs";
import {
createMonitorBodyValidation,
editMonitorBodyValidation,
@@ -620,8 +621,25 @@ describe("monitorValidation — Docker host url", () => {
expect(parsed.url).toBe("/run/docker.sock");
});
it("accepts tcp:// and https:// engine urls with an optional port and trailing slash", () => {
const fixture = (name: string) => readFileSync(new URL(`../../fixtures/docker-tls/${name}`, import.meta.url), "utf8");
const tlsFields = { dockerTlsCa: fixture("ca.pem"), dockerTlsCert: fixture("client-cert.pem"), dockerTlsKey: fixture("client-key.pem") };
for (const url of ["tcp://host", "tcp://host:2376", "https://host:2377", "tcp://host/"]) {
expect(createMonitorBodyValidation.parse({ ...baseDockerBody, ...tlsFields, url }).url).toBe(url);
}
});
it("rejects container names and unsupported engine urls", () => {
for (const badUrl of ["my-container", "tcp://host:2375", "https://host", "ssh://deploy@host", "unix://relative/path", ""]) {
for (const badUrl of [
"my-container",
"http://host",
"host:2376",
"tcp://host/x",
"tcp://host:abc",
"ssh://deploy@host",
"unix://relative/path",
"",
]) {
expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: badUrl })).toThrow();
}
});
@@ -658,3 +676,126 @@ describe("monitorValidation — Docker host url", () => {
});
});
});
describe("monitorValidation — Docker TLS credentials", () => {
const fixture = (name: string) => readFileSync(new URL(`../../fixtures/docker-tls/${name}`, import.meta.url), "utf8");
const CA = fixture("ca.pem");
const CLIENT_CERT = fixture("client-cert.pem");
const CLIENT_KEY = fixture("client-key.pem");
const OTHER_KEY = fixture("other-key.pem");
const ENCRYPTED_KEY = fixture("encrypted-key.pem");
const baseTlsBody = {
name: "Docker TLS check",
type: "docker" as const,
url: "tcp://host:2376",
};
const fullTlsBody = { ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT, dockerTlsKey: CLIENT_KEY };
const issuesFor = (result: ReturnType<typeof createMonitorBodyValidation.safeParse>) =>
result.success ? [] : result.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message }));
describe("createMonitorBodyValidation", () => {
it("accepts a CA, client certificate, and matching key", () => {
const parsed = createMonitorBodyValidation.parse(fullTlsBody);
expect(parsed.dockerTlsCa).toBe(CA);
expect(parsed.dockerTlsCert).toBe(CLIENT_CERT);
expect(parsed.dockerTlsKey).toBe(CLIENT_KEY);
});
it("does not require TLS fields for a socket url", () => {
expect(createMonitorBodyValidation.safeParse({ ...baseTlsBody, url: "unix:///var/run/docker.sock" }).success).toBe(true);
});
it("requires the client certificate, key, and CA for a TLS url", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse(baseTlsBody));
expect(issues).toEqual(
expect.arrayContaining([
{ path: "dockerTlsCert", message: "TLS certificate is required for a TLS Docker host" },
{ path: "dockerTlsKey", message: "TLS key is required for a TLS Docker host" },
{ path: "dockerTlsCa", message: "CA certificate is required unless TLS errors are ignored" },
])
);
});
it("makes the CA optional when TLS errors are ignored", () => {
const result = createMonitorBodyValidation.safeParse({
...baseTlsBody,
ignoreTlsErrors: true,
dockerTlsCert: CLIENT_CERT,
dockerTlsKey: CLIENT_KEY,
});
expect(result.success).toBe(true);
});
it("still requires the client certificate and key when TLS errors are ignored", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...baseTlsBody, ignoreTlsErrors: true }));
expect(issues.map((issue) => issue.path)).toEqual(expect.arrayContaining(["dockerTlsCert", "dockerTlsKey"]));
expect(issues.map((issue) => issue.path)).not.toContain("dockerTlsCa");
});
it("rejects a CA that is not a PEM certificate", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsCa: "not a certificate" }));
expect(issues).toEqual([{ path: "dockerTlsCa", message: "No certificate found; expected one or more PEM CERTIFICATE blocks" }]);
});
it("rejects a client certificate that is not a PEM certificate", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsCert: "not a certificate" }));
expect(issues).toEqual([{ path: "dockerTlsCert", message: "No certificate found; expected one or more PEM CERTIFICATE blocks" }]);
});
it("rejects a key that is not a PEM private key", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsKey: "not a key" }));
expect(issues).toEqual([{ path: "dockerTlsKey", message: "Private key is not valid PEM" }]);
});
it("rejects an encrypted private key", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsKey: ENCRYPTED_KEY }));
expect(issues).toEqual([{ path: "dockerTlsKey", message: "Encrypted private keys are not supported; remove the passphrase first" }]);
});
it("rejects a key that does not match the client certificate", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsKey: OTHER_KEY }));
expect(issues).toEqual([{ path: "dockerTlsKey", message: "Key does not match certificate" }]);
});
it("accepts a CA bundle with more than one certificate", () => {
const result = createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsCa: `${CA}\n${CLIENT_CERT}` });
expect(result.success).toBe(true);
});
});
describe("editMonitorBodyValidation", () => {
it("accepts a TLS url without a key so a stored key is kept", () => {
const result = editMonitorBodyValidation.safeParse({ ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT });
expect(result.success).toBe(true);
});
it("accepts a blank key so a stored key is kept", () => {
const result = editMonitorBodyValidation.safeParse({ ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT, dockerTlsKey: "" });
expect(result.success).toBe(true);
});
it("still requires the client certificate and CA", () => {
const issues = issuesFor(editMonitorBodyValidation.safeParse(baseTlsBody));
expect(issues.map((issue) => issue.path)).toEqual(expect.arrayContaining(["dockerTlsCert", "dockerTlsCa"]));
expect(issues.map((issue) => issue.path)).not.toContain("dockerTlsKey");
});
it("validates a replacement key against the client certificate", () => {
const issues = issuesFor(
editMonitorBodyValidation.safeParse({ ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT, dockerTlsKey: OTHER_KEY })
);
expect(issues).toEqual([{ path: "dockerTlsKey", message: "Key does not match certificate" }]);
});
});
describe("importMonitorsBodyValidation", () => {
it("strips TLS credentials from imported monitors instead of validating them", () => {
const parsed = importMonitorsBodyValidation.parse({ monitors: [fullTlsBody] });
expect(parsed.monitors[0]).not.toHaveProperty("dockerTlsCa");
expect(parsed.monitors[0]).not.toHaveProperty("dockerTlsCert");
expect(parsed.monitors[0]).not.toHaveProperty("dockerTlsKey");
});
});
});