Compare commits
12
Commits
de1af50165
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c711e77bd | ||
|
|
4cb24f7085 | ||
|
|
cf4d5b3ce2 | ||
|
|
042a60152c | ||
|
|
b4fd31a182 | ||
|
|
4ea4fe5289 | ||
|
|
73f45fb3ae | ||
|
|
955e01333d | ||
|
|
7994ad492c | ||
|
|
b5d52b441a | ||
|
|
0cf3c760a4 | ||
|
|
0c5a57556a |
No files matched your search
@@ -0,0 +1,61 @@
|
||||
# Copy this file to .env and fill it in. Sandbar reads .env from its working directory
|
||||
# (or pass -env /path/to/file). Real environment variables always win over the file.
|
||||
# Never commit .env.
|
||||
|
||||
# Encrypts saved secrets (channel webhook URLs and email addresses) inside the database.
|
||||
# Generate one with: sandbar -genkey
|
||||
# Keep it apart from the data folder and back it up: without it those secrets cannot be read.
|
||||
# If you leave it unset, a key is generated into <data>/master.key instead.
|
||||
SANDBAR_MASTER_KEY=
|
||||
|
||||
# Creates the first administrator at startup (otherwise use the setup page in the browser).
|
||||
#SANDBAR_ADMIN_EMAIL=you@example.com
|
||||
#SANDBAR_ADMIN_PASSWORD=
|
||||
|
||||
# Address to listen on, and the folder for the database.
|
||||
#SANDBAR_LISTEN=:8090
|
||||
#SANDBAR_DATA=./sandbar-data
|
||||
|
||||
# Public address people use to reach Sandbar. Links in notifications use it.
|
||||
#SANDBAR_URL=https://sandbar.example.com
|
||||
|
||||
# Serves Prometheus metrics at /metrics for a scraper that sends this as a bearer token.
|
||||
# Leave empty and /metrics does not exist.
|
||||
#SANDBAR_METRICS_TOKEN=
|
||||
|
||||
# Install new releases by itself: checks every few hours, then downloads, verifies and restarts.
|
||||
# Works in Docker too (the new binary is kept in <data>/bin). Off by default.
|
||||
#SANDBAR_AUTO_UPDATE=1
|
||||
# Where releases come from, if not this project on git.veillax.com.
|
||||
#SANDBAR_UPDATE_SERVER=https://git.veillax.com
|
||||
#SANDBAR_UPDATE_REPO=Veillax/sandbar
|
||||
|
||||
# Behind a reverse proxy, take the client address from X-Forwarded-For. Needed for website analytics and
|
||||
# the sign-in limit to tell visitors apart. Only set it when the proxy is the only way to reach Sandbar.
|
||||
#SANDBAR_TRUST_PROXY=1
|
||||
|
||||
# How long topic messages and monitor results are kept, in days.
|
||||
#SANDBAR_MESSAGE_DAYS=30
|
||||
#SANDBAR_CHECK_DAYS=30
|
||||
#SANDBAR_ANALYTICS_DAYS=365
|
||||
|
||||
# Outgoing email, for email channels and for paging people by email.
|
||||
#SANDBAR_SMTP_HOST=smtp.example.com
|
||||
#SANDBAR_SMTP_PORT=587
|
||||
#SANDBAR_SMTP_USER=
|
||||
#SANDBAR_SMTP_PASSWORD=
|
||||
#SANDBAR_SMTP_FROM=sandbar@example.com
|
||||
|
||||
# Mobile companion app (the Remora PWA): Web Push, served from Sandbar or from another site.
|
||||
# Folder holding the app's files; Sandbar serves them at /app/. Needs HTTPS (or localhost) for push.
|
||||
#SANDBAR_APP_DIR=../remora/public
|
||||
# If the app is hosted on another origin instead, allow it to call the API.
|
||||
#SANDBAR_APP_ORIGIN=https://sandbar-app.example.com
|
||||
# Contact that push services (Google, Apple, Mozilla) can reach if Sandbar misbehaves. Defaults to SANDBAR_URL when that is https.
|
||||
#SANDBAR_VAPID_SUBJECT=mailto:you@example.com
|
||||
# Development only: allow push endpoints on plain http and private addresses.
|
||||
#SANDBAR_PUSH_DEV=1
|
||||
# Show Hammerhead's edges and tunnel state in the app. Use an account without two-factor sign-in, read-only if you can.
|
||||
#SANDBAR_HAMMERHEAD_URL=https://hammerhead.example.com
|
||||
#SANDBAR_HAMMERHEAD_EMAIL=
|
||||
#SANDBAR_HAMMERHEAD_PASSWORD=
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,52 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPRIVATE: git.veillax.com
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Go fetches Reef straight from this server. Harmless for public repositories and
|
||||
# what makes private ones work.
|
||||
- name: Let Go fetch modules from this server
|
||||
run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.veillax.com/".insteadOf "https://git.veillax.com/"
|
||||
|
||||
- name: Formatting
|
||||
run: |
|
||||
unformatted=$(gofmt -l cmd internal)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "These files need gofmt:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Shell scripts parse
|
||||
run: |
|
||||
for f in deploy/*.sh; do sh -n "$f"; done
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
- name: Design lint
|
||||
run: node scripts/lint-design.mjs
|
||||
|
||||
- name: Builds on every platform
|
||||
run: |
|
||||
for t in linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/amd64 darwin/arm64; do
|
||||
echo "building for $t"
|
||||
GOOS=${t%/*} GOARCH=${t#*/} GOARM=7 CGO_ENABLED=0 go build ./...
|
||||
done
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Release
|
||||
|
||||
# Push a tag such as v0.2.0 to build and publish a release:
|
||||
# git tag -a v0.2.0 -m "sandbar v0.2.0" && git push origin v0.2.0
|
||||
# The release carries the archives and SHA256SUMS that self-update downloads, and the
|
||||
# container image is pushed to the registry on this server.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPRIVATE: git.veillax.com
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # release notes come from the history since the previous tag
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Go fetches Reef straight from this server. Harmless for public repositories and
|
||||
# what makes private ones work.
|
||||
- name: Let Go fetch modules from this server
|
||||
run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.veillax.com/".insteadOf "https://git.veillax.com/"
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
go vet ./...
|
||||
go test ./...
|
||||
|
||||
- name: Build release archives
|
||||
run: sh deploy/build.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
# Optional secret RELEASE_TOKEN: an access token that can write releases, for
|
||||
# servers where the automatic job token can't.
|
||||
- name: Publish the release
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
# Skipped when the repository variable PUBLISH_DOCKER is "false", or fails alone
|
||||
# if this runner has no Docker: the release above is already published by then.
|
||||
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token
|
||||
# with package write permission, for servers where the job token can't push.
|
||||
- name: Build and push the container image
|
||||
if: ${{ vars.PUBLISH_DOCKER != 'false' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }}
|
||||
run: |
|
||||
host=${GITHUB_SERVER_URL#*://}
|
||||
owner=$(echo "$GITHUB_REPOSITORY_OWNER" | tr 'A-Z' 'a-z')
|
||||
image="$host/$owner/sandbar"
|
||||
echo "$REGISTRY_TOKEN" | docker login "$host" -u "$REGISTRY_USER" --password-stdin
|
||||
# a container-driver builder can build several architectures in one go
|
||||
docker buildx create --name release --driver docker-container --use >/dev/null 2>&1 || docker buildx use release
|
||||
tags="-t $image:$GITHUB_REF_NAME"
|
||||
case "$GITHUB_REF_NAME" in
|
||||
*-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest
|
||||
*) tags="$tags -t $image:latest" ;;
|
||||
esac
|
||||
docker buildx build --platform linux/amd64,linux/arm64 $tags --push .
|
||||
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
master.key
|
||||
.devdata/
|
||||
bin/
|
||||
dist/
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Packages a binary that was already built for each architecture, so the arm64 image needs
|
||||
# no emulation. Build the binaries, then the image:
|
||||
#
|
||||
# for a in amd64 arm64; do
|
||||
# mkdir -p dist/bin/linux_$a
|
||||
# CGO_ENABLED=0 GOOS=linux GOARCH=$a go build -trimpath -ldflags "-s -w" -o dist/bin/linux_$a/sandbar ./cmd/sandbar
|
||||
# done
|
||||
# docker buildx build --platform linux/amd64,linux/arm64 -t sandbar .
|
||||
FROM --platform=$BUILDPLATFORM alpine:3.22 AS prep
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& mkdir /data && chown 65532:65532 /data
|
||||
|
||||
FROM scratch
|
||||
ARG TARGETARCH
|
||||
LABEL org.opencontainers.image.title="Sandbar" \
|
||||
org.opencontainers.image.description="Notifications, monitoring, on-call and incident response" \
|
||||
org.opencontainers.image.source="https://git.veillax.com/Veillax/sandbar" \
|
||||
org.opencontainers.image.licenses="AGPL-3.0-only"
|
||||
COPY --from=prep /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=prep --chown=65532:65532 /data /data
|
||||
COPY dist/bin/linux_${TARGETARCH}/sandbar /sandbar
|
||||
USER 65532:65532
|
||||
ENV SANDBAR_DATA=/data
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["/sandbar"]
|
||||
CMD ["-listen", ":8090"]
|
||||
@@ -219,7 +219,7 @@ If you develop a new program, and you want it to be of the greatest possible use
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
Sandbar
|
||||
Reef
|
||||
Copyright (C) 2026 Veillax
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
@@ -1,2 +1,353 @@
|
||||
# Sandbar
|
||||
|
||||
Self-hosted notifications, monitoring, on-call and incident response in one small program.
|
||||
One binary, one SQLite file, and everything is done in the web UI.
|
||||
|
||||
Sandbar covers the ground of five tools:
|
||||
|
||||
| Area | Like | What it does |
|
||||
|---|---|---|
|
||||
| **Topics** | ntfy | Publish a message to a topic with one HTTP request. Subscribe in the browser, with `curl`, or with the ntfy phone app |
|
||||
| **Monitors** | Tianji, Uptime Kuma | Website, port, DNS and certificate checks, heartbeats for cron jobs, and a status page with groups, incident posts, maintenance and subscribers |
|
||||
| **Websites** | Tianji, Plausible, Umami | Page views, visitors, referrers, browsers, countries and custom events for your own sites, with no cookies and no personal data |
|
||||
| **On-call** | Opsgenie | Rotating schedules, escalation policies, and alerts that are acknowledged, snoozed or resolved |
|
||||
| **Incidents** | incident.io | Declare, assign, update and close an incident, with a timeline and a post-mortem |
|
||||
|
||||
It is built in the same style as Hammerhead, Blacktip and Bamboo and shares [Reef](https://git.veillax.com/Veillax/reef)
|
||||
with them (`dotenv`, `netguard`, `secretbox`).
|
||||
|
||||
## Quick start
|
||||
|
||||
```
|
||||
go build -o bin/ ./cmd/...
|
||||
|
||||
cp .env.example .env
|
||||
bin/sandbar -genkey # paste the output into .env as SANDBAR_MASTER_KEY=
|
||||
|
||||
bin/sandbar -listen :8090 -url https://sandbar.example.com
|
||||
```
|
||||
|
||||
Open `http://localhost:8090` and create the first administrator. Or set `SANDBAR_ADMIN_EMAIL` and
|
||||
`SANDBAR_ADMIN_PASSWORD` in `.env` to create it at startup.
|
||||
|
||||
## Deploying
|
||||
|
||||
### Docker
|
||||
|
||||
Pushing a version tag publishes a multi-architecture (amd64 and arm64) image to the registry on the same server as
|
||||
the repository, as `git.veillax.com/veillax/sandbar:<version>` and `:latest`. Generate a master key once with
|
||||
`docker run --rm git.veillax.com/veillax/sandbar -genkey`, then keep it in a `.env` file next to this compose file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
sandbar:
|
||||
image: git.veillax.com/veillax/sandbar:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8090:8090"
|
||||
env_file: .env
|
||||
environment:
|
||||
SANDBAR_URL: https://sandbar.example.com
|
||||
SANDBAR_AUTO_UPDATE: "1" # optional, see Updating below
|
||||
volumes:
|
||||
- sandbar-data:/data
|
||||
|
||||
volumes:
|
||||
sandbar-data:
|
||||
```
|
||||
|
||||
```
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
- The container runs as user 65532 and keeps its database in `/data`. Use a named volume as above. If you bind-mount
|
||||
a folder instead, make it writable by 65532 (`chown 65532:65532 ./data`).
|
||||
- Set `SANDBAR_MASTER_KEY` yourself. Without it a key is generated inside `/data`, next to the database it protects.
|
||||
- To build the image locally, see the comment at the top of `Dockerfile`.
|
||||
- Update by hand with `docker compose pull && docker compose up -d`, or let Sandbar do it (see Updating below).
|
||||
Your data stays in the volume. Copy the data folder before upgrading, since there is no migration tooling yet.
|
||||
|
||||
### Binary
|
||||
|
||||
Download an archive from the release page (Linux amd64 and arm64, Windows amd64) or build it (`go build -o bin/ ./cmd/sandbar`), copy it to the server with a `.env` file, and run it
|
||||
under a service manager. A minimal systemd unit:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Sandbar
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
User=sandbar
|
||||
WorkingDirectory=/var/lib/sandbar
|
||||
ExecStart=/usr/local/bin/sandbar -listen 127.0.0.1:8090 -env /etc/sandbar/.env
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Put `SANDBAR_DATA=/var/lib/sandbar` in the env file and make `/etc/sandbar/.env` readable only by the `sandbar` user.
|
||||
|
||||
### Updating
|
||||
|
||||
Pushing a version tag publishes a release with archives and `SHA256SUMS` as well as the image. Sandbar can install
|
||||
new releases itself, in Docker and as a plain binary, on Linux and macOS.
|
||||
|
||||
- **Automatic:** set `SANDBAR_AUTO_UPDATE=1` (or pass `-auto-update`). Sandbar checks a few minutes after start and
|
||||
then every few hours, downloads the archive for its platform, checks it against `SHA256SUMS`, checks that the new
|
||||
binary runs, replaces itself and restarts. Pre-releases (`v1.0.0-rc1`) are never installed.
|
||||
- **On demand:** an administrator can see the newest release and click **Update** on the Account page, with or
|
||||
without automatic updates on.
|
||||
- **In Docker:** the image is read-only to Sandbar, and a change inside the container would be lost when it is
|
||||
recreated. So the new binary is written to `<data>/bin/sandbar` on the data volume. At every start Sandbar runs
|
||||
that file when it is a newer release than the image, and deletes it once the image has caught up. The container
|
||||
keeps running through the update (it re-executes in place, so the process id does not change), and
|
||||
`docker compose pull` still works: a newer image takes over from the volume copy.
|
||||
- **Pin a version:** leave automatic updates off and use an image tag such as `:v0.2.0` instead of `:latest`.
|
||||
- **Requirements:** the release page must be readable without signing in, so the repository has to be public. A
|
||||
development build (`dev`) and Windows never update themselves. `SANDBAR_UPDATE_SERVER` and `SANDBAR_UPDATE_REPO`
|
||||
point it at a different Gitea server or fork. If a bad update leaves Sandbar unable to start, delete
|
||||
`<data>/bin/sandbar` (or set `SANDBAR_NO_OVERLAY=1`) and it starts from the image again.
|
||||
|
||||
### HTTPS and a reverse proxy
|
||||
|
||||
Sandbar serves plain HTTP, so put a reverse proxy in front of it and set `SANDBAR_URL` to the public address, since
|
||||
links in notifications use it. Also set `SANDBAR_TRUST_PROXY=1`, so website analytics and the sign-in limit see the
|
||||
real client address instead of the proxy's. Caddy needs one line:
|
||||
|
||||
```
|
||||
sandbar.example.com {
|
||||
reverse_proxy 127.0.0.1:8090
|
||||
}
|
||||
```
|
||||
|
||||
With nginx, turn off response buffering for the live topic feeds (`/<topic>/sse` and `/<topic>/json`), or
|
||||
subscribers see messages late: `proxy_buffering off; proxy_read_timeout 1h;`.
|
||||
|
||||
### After the first start
|
||||
|
||||
1. Open the address and create the administrator, or set `SANDBAR_ADMIN_EMAIL` and `SANDBAR_ADMIN_PASSWORD` first.
|
||||
2. Add an email or webhook channel and use its test button, so you know alerts will reach you.
|
||||
3. Create an API key on the Account page for scripts and monitoring agents.
|
||||
4. Set up backups (see Database below), including the master key, which must be stored apart from the data.
|
||||
|
||||
## How the pieces fit
|
||||
|
||||
```
|
||||
monitor fails ─┐
|
||||
API request ───┼─> alert ─> escalation policy ─> person ─> topic + email
|
||||
Alertmanager ──┘ │ (repeat until acknowledged)
|
||||
└─> broadcast channels (Slack, Discord, ntfy, email)
|
||||
└─> optionally an incident, with a timeline
|
||||
```
|
||||
|
||||
Every alert is deduplicated by its key, so a monitor that stays down is one alert, not one per check.
|
||||
|
||||
## Topics
|
||||
|
||||
```
|
||||
# publish (ntfy-style headers)
|
||||
curl -H "Authorization: Bearer sb_..." -H "Title: Disk full" -H "Priority: urgent" -H "Tags: warning,disk" \
|
||||
-d "web-1 is at 98%" https://sandbar.example.com/ops
|
||||
|
||||
# subscribe: server-sent events, or JSON lines; ?since=all replays stored messages
|
||||
curl -N -H "Authorization: Bearer sb_..." https://sandbar.example.com/ops/sse
|
||||
curl -N -H "Authorization: Bearer sb_..." "https://sandbar.example.com/ops/json?since=all"
|
||||
```
|
||||
|
||||
A topic is 1 to 64 letters, digits, `-` or `_`. Publishing and subscribing both need a signed-in session or an
|
||||
API key (create one on the Account page). Clients that use basic auth can send the key as the password.
|
||||
Messages are kept for `SANDBAR_MESSAGE_DAYS` (default 30).
|
||||
|
||||
`?since=` also takes unix seconds, a duration such as `10m`, or the id of the last message you have, and
|
||||
`?poll=1` returns just that replay and closes, which is how the ntfy apps catch up.
|
||||
|
||||
### Phone app (ntfy)
|
||||
|
||||
1. Create an API key on the Account page and copy it.
|
||||
2. In the ntfy app, open Settings, then Default server, and enter your Sandbar address
|
||||
(`https://sandbar.example.com`). Or tick "Use another server" when you add each subscription.
|
||||
3. Add a subscription. For a page when an escalation reaches you, use your personal topic from the Account page
|
||||
(`u-<id>`). For everything, use `alerts` and `incidents`.
|
||||
4. Under Manage users, add a user for the server. The username can be anything and the password is the API key.
|
||||
|
||||
The Android app keeps a connection open, so messages arrive at once. The iPhone app depends on ntfy.sh's push
|
||||
relay, which Sandbar does not use, so there messages arrive only when the app polls.
|
||||
|
||||
Two topics are filled for you: `alerts` gets every alert and its recovery, and `incidents` gets every incident
|
||||
update. Each person also has a personal topic (`u-<id>`, shown on the Account page). Subscribe to it in the ntfy
|
||||
app to be paged on your phone when an escalation policy reaches you.
|
||||
|
||||
## Monitors
|
||||
|
||||
| Type | Passes when | Notes |
|
||||
|---|---|---|
|
||||
| Website (HTTP) | The status is in the expected range (default 2xx) and the page contains the text you set | Redirects are not followed |
|
||||
| Port (TCP) | A connection opens | |
|
||||
| DNS lookup | The name resolves | |
|
||||
| Certificate expiry | The certificate has more than N days left (default 14) | |
|
||||
| Heartbeat | Your job requests `/api/v1/hb/<token>` within its interval plus the allowed delay | A request on a down heartbeat recovers it at once |
|
||||
|
||||
A monitor goes down after the set number of failures in a row (default 2), raises an alert with the monitor's
|
||||
escalation policy, and recovers on the first success. Monitors marked public appear on the status page (see below), together with
|
||||
incidents marked public. The status page never shows staff names or private monitors.
|
||||
|
||||
The status page shows each public monitor as 90 daily uptime bars, like most hosted status pages. A day is green with
|
||||
no failed checks, amber when at least 99% of checks passed, red below that, and grey with no data. Hovering a bar
|
||||
(or tapping it on a phone) gives the date, the uptime and the number of failed checks. Days are UTC. Counts are kept
|
||||
in a small per-day table, so the bars go back 90 days even though raw check results are kept for
|
||||
`SANDBAR_CHECK_DAYS` only.
|
||||
|
||||
Monitors cannot probe private or internal addresses unless you tick **Allow targets on private networks** on that
|
||||
monitor, so a user who can add a monitor cannot use Sandbar to scan your network.
|
||||
|
||||
## Status page
|
||||
|
||||
The public page is at `/status` (and at the root of its own domain, see below). Everything is set up under
|
||||
**Status page** in the sidebar.
|
||||
|
||||
- **Components and groups.** Every monitor with **Show on the public status page** ticked is a component, shown as
|
||||
90 daily uptime bars (see Monitors). Create groups on the Groups tab, then pick a group, and optionally a different
|
||||
public name, in the monitor's form. Groups are ordered by their order number; components without a group come first.
|
||||
- **Incidents.** An incident marked public is posted with its status, the components it affects, and the updates you
|
||||
write. Declare it as public, or switch it on later. **Post an update** (or changing the status) adds a post with
|
||||
your text, or standard wording if you leave it empty. Customers see only the posts. The internal timeline,
|
||||
comments, names and post-mortem never leave Sandbar. The page shows the last 14 days; everything older is on
|
||||
`/status/history`, and each incident has its own page with the full list of updates.
|
||||
- **What the page says.** A component is *Major outage* when its monitor is down or a sev1 incident names it,
|
||||
*Partial outage* for sev2, *Degraded performance* for sev3, and *Under maintenance* during a maintenance window for
|
||||
it. The banner shows the worst of them.
|
||||
- **Scheduled maintenance.** Give a window (your local time in the form, stored as UTC) and the affected
|
||||
components. It is announced when scheduled, listed on the page until it ends, and subscribers are told when it
|
||||
starts and when it ends. During the window its components show as under maintenance instead of as an outage.
|
||||
Windows can be moved or canceled, and subscribers are told. Alerts for failing checks still fire as usual.
|
||||
- **Branding.** Title, description, logo (an https address), accent color, a contact link and footer text. The
|
||||
accent is checked so that white text on it stays readable (WCAG 4.5 to 1).
|
||||
- **Custom domain.** Enter a host name such as `status.example.com`, point its DNS at your server and have the
|
||||
reverse proxy forward that host to Sandbar with the `Host` header unchanged (Caddy does this by default; with nginx
|
||||
add `proxy_set_header Host $host;`). Sandbar then serves the status page at `/` on that host, and nothing else:
|
||||
the app, API and topics are not reachable through it. Links in emails and feeds use `https://<domain>`. The proxy
|
||||
provides the certificate. With `SANDBAR_TRUST_PROXY=1` the host is read from `X-Forwarded-Host` when present.
|
||||
|
||||
### Following the page
|
||||
|
||||
Visitors choose **Subscribe to updates** on the page:
|
||||
|
||||
- **Email.** Needs `SANDBAR_SMTP_HOST`; the form is hidden without it. A visitor gets a confirmation link, and no
|
||||
mail is sent until it is opened. Every message has an unsubscribe link, which asks for one click on the page, so
|
||||
mail scanners cannot unsubscribe people. Unconfirmed addresses are forgotten after 7 days.
|
||||
- **Webhook.** The address must answer a test request with a 2xx status before it is subscribed. Requests to private
|
||||
networks are refused. Each update is a JSON `POST`:
|
||||
`{"page","event","title","status","message","url","time","unsubscribe"}`. The events are `incident.created`,
|
||||
`incident.updated`, `incident.resolved`, `maintenance.scheduled`, `maintenance.rescheduled`, `maintenance.started`,
|
||||
`maintenance.completed` and `maintenance.canceled`. The `unsubscribe` link works with a `POST` of
|
||||
`{"token": ...}` to `/status/unsubscribe`, or can be opened in a browser.
|
||||
- **RSS.** `/status/feed.rss` has every incident update and maintenance announcement, newest first.
|
||||
|
||||
Subscribers can be seen and removed on the Subscribers tab (webhook addresses show only the host). Addresses are
|
||||
stored encrypted. Subscribing is limited to 6 requests per hour per address.
|
||||
|
||||
### Status page API
|
||||
|
||||
| Request | |
|
||||
|---|---|
|
||||
| `GET /status.json` | Settings, groups, components with their 90 days, current incidents, upcoming and running maintenance |
|
||||
| `GET /status/api/history?before=<time>&limit=<n>` | Past incidents and maintenance, newest first, with the time to pass as `before` for the next page |
|
||||
| `GET /status/api/incidents/<id>` | One public incident with all its updates |
|
||||
| `GET/PUT /api/v1/statuspage` | Settings (PUT is for administrators) |
|
||||
| `/api/v1/status-groups` | Groups, as the other configuration lists |
|
||||
| `/api/v1/maintenance`, `POST .../<id>/cancel` | Scheduled maintenance (writes are for administrators) |
|
||||
| `PATCH /api/v1/incidents/<id>` | Also takes `components` (monitor ids) and `note` (the text of the public post) |
|
||||
|
||||
## Websites (analytics)
|
||||
|
||||
Add a website on the Websites page, then paste its one-line tracking code into the head of your pages:
|
||||
|
||||
```html
|
||||
<script defer src="https://sandbar.example.com/script.js" data-website="WEBSITE_ID"></script>
|
||||
```
|
||||
|
||||
The page for each website shows visitors, page views, visits, bounce rate and who is online now, a chart over the last
|
||||
24 hours, 7, 30 or 90 days, and top lists for pages, referrers, browsers, operating systems, devices, countries,
|
||||
languages and events.
|
||||
|
||||
- **Single-page apps** are counted as people move between pages. Add `data-hash` to count `#hash` changes as pages,
|
||||
or `data-auto="false"` to send nothing until your code calls `sandbar.view()`.
|
||||
- **Events:** call `sandbar.track("signup", { plan: "pro" })` from your code.
|
||||
- **Private by design:** no cookies, nothing stored in the browser, no IP address or user agent kept (only the labels
|
||||
"Chrome", "macOS", "Mobile" and so on), and nothing is sent when the visitor has Do Not Track on. A visitor is a hash
|
||||
of the address, the browser and the website with a secret and the date, so the same person is one visitor for a day
|
||||
and cannot be followed beyond it. Query strings are dropped from paths and only the referrer's host is kept.
|
||||
- **Domain:** a website's optional domain makes Sandbar ignore visits from other hosts. Bots, and anything sending
|
||||
more than 120 hits a minute from one address, are ignored.
|
||||
- **Countries** come from the `CF-IPCountry` or `CloudFront-Viewer-Country` header, so they appear only behind
|
||||
Cloudflare or CloudFront. Sandbar bundles no location database. Without the header, visits count as Unknown.
|
||||
- **Behind a reverse proxy,** set `SANDBAR_TRUST_PROXY=1` so the client address comes from `X-Forwarded-For`. Without
|
||||
it every visitor looks like the proxy. Only set it when the proxy is the only way to reach Sandbar.
|
||||
- Hits are kept for `SANDBAR_ANALYTICS_DAYS` (default 365).
|
||||
|
||||
Not included: funnels, goals, session replay, a public share link, and comparison with the previous period.
|
||||
|
||||
## On-call
|
||||
|
||||
- A **schedule** rotates through people, handing over every N hours from a start time. **Overrides** cover a
|
||||
holiday or swap a shift.
|
||||
- An **escalation policy** is a list of steps. Each step waits some minutes, then tells people and/or whoever is
|
||||
on call for a schedule. It can start over after the last step until someone acknowledges.
|
||||
- Alerts are stored with the time their next step is due, so restarting Sandbar loses nothing and repeats nothing.
|
||||
- **Acknowledge** stops escalation. **Snooze** pauses it and resumes at the same step. **Resolve** closes the alert.
|
||||
|
||||
## Incidents
|
||||
|
||||
Declare one from the UI or `POST /api/v1/incidents`, or from an open alert (which links them). An incident has a
|
||||
severity (sev1 to sev3), a status (investigating, identified, monitoring, resolved), a lead and a communications
|
||||
owner, a timeline of everything that happened, comments, and a post-mortem with a template filled from the
|
||||
timeline. Every change is announced on the `incidents` topic and on all channels.
|
||||
|
||||
## Sending alerts in
|
||||
|
||||
```
|
||||
# generic
|
||||
curl -H "Authorization: Bearer sb_..." -H "Content-Type: application/json" \
|
||||
-d '{"key":"cpu-web-1","summary":"CPU above 90%","severity":"warning","policy":"<policy id>"}' \
|
||||
https://sandbar.example.com/api/v1/alerts
|
||||
|
||||
# resolve it later with the same key
|
||||
curl ... -d '{"key":"cpu-web-1","status":"resolved"}' https://sandbar.example.com/api/v1/alerts
|
||||
```
|
||||
|
||||
Prometheus Alertmanager and Grafana can post their webhook to
|
||||
`/api/v1/integrations/alertmanager?policy=<policy id>` with an API key as the bearer token.
|
||||
|
||||
## Channels
|
||||
|
||||
A channel receives every alert and incident update: an email address list, or a webhook in Slack, Discord, ntfy or
|
||||
plain JSON format. The address is stored encrypted and is never sent back to the browser. Webhooks cannot point at
|
||||
internal addresses.
|
||||
|
||||
## Configuration and secrets
|
||||
|
||||
`.env.example` lists every setting. Secrets at rest use AES-256-GCM with `SANDBAR_MASTER_KEY`; passwords are bcrypt
|
||||
hashes and API keys and sessions are stored as SHA-256 hashes. With the wrong key, saved channel addresses cannot be
|
||||
read. If you do not set a key, one is generated into `<data>/master.key`; move it into `.env`, and **back it up
|
||||
separately**.
|
||||
|
||||
Prometheus metrics are served at `/metrics` when `SANDBAR_METRICS_TOKEN` is set.
|
||||
|
||||
## Database
|
||||
|
||||
SQLite (pure Go, no C compiler), in WAL mode, in `<data>/sandbar.db`. Messages, monitor results and alerts older
|
||||
than their retention are pruned hourly. Back up by copying the data folder while Sandbar is stopped, plus the key.
|
||||
|
||||
## Limits and notes
|
||||
|
||||
- One instance, one organization. SQLite means no clustering.
|
||||
- No ICMP ping monitor, no SMS or voice, and no two-factor or single sign-on yet.
|
||||
- Sandbar serves plain HTTP. Put it behind HTTPS and set `-url`, or keep it on a private network.
|
||||
- Sign-in attempts are limited to 10 per 10 minutes per address (the client address, see `SANDBAR_TRUST_PROXY`).
|
||||
- Tests: `go test ./...`. Style: `node scripts/lint-design.mjs` (see `docs/design.md`).
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0. See `LICENSE`.
|
||||
@@ -0,0 +1,158 @@
|
||||
// Command sandbar is a self-hosted notification, monitoring, on-call and incident tool.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/dotenv"
|
||||
"git.veillax.com/Veillax/reef/secretbox"
|
||||
"git.veillax.com/Veillax/reef/selfupdate"
|
||||
"git.veillax.com/Veillax/sandbar/internal/sandbar"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
envFile := "./.env"
|
||||
for i, a := range os.Args {
|
||||
if (a == "-env" || a == "--env") && i+1 < len(os.Args) {
|
||||
envFile = os.Args[i+1]
|
||||
}
|
||||
}
|
||||
if _, err := dotenv.Load(envFile); err != nil {
|
||||
log.Fatalf("reading %s: %v", envFile, err)
|
||||
}
|
||||
|
||||
listen := flag.String("listen", env("SANDBAR_LISTEN", ":8090"), "address for the web UI, API and topics")
|
||||
data := flag.String("data", env("SANDBAR_DATA", "./sandbar-data"), "directory for the database")
|
||||
url := flag.String("url", env("SANDBAR_URL", ""), "public URL (used for links in notifications)")
|
||||
flag.String("env", ".env", "file to read secrets and settings from")
|
||||
autoUpdate := flag.Bool("auto-update", os.Getenv("SANDBAR_AUTO_UPDATE") == "1", "check for new releases every few hours and update and restart by itself (Linux and macOS, including Docker)")
|
||||
genkey := flag.Bool("genkey", false, "print a new random SANDBAR_MASTER_KEY and exit")
|
||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println("sandbar", version)
|
||||
return
|
||||
}
|
||||
if *genkey {
|
||||
fmt.Println(secretbox.GenerateKey())
|
||||
return
|
||||
}
|
||||
|
||||
// In a container, an update lives on the data volume and takes over from the image.
|
||||
sandbar.UseNewerOverlay(*data, version, log.Printf)
|
||||
|
||||
key, err := masterKey(*data)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
box, err := secretbox.New(key)
|
||||
if err != nil {
|
||||
log.Fatalf("SANDBAR_MASTER_KEY: %v", err)
|
||||
}
|
||||
st, err := sandbar.OpenStore(*data, box)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
var hs *http.Server
|
||||
atoi := func(k string) int { n, _ := strconv.Atoi(os.Getenv(k)); return n }
|
||||
var srv *sandbar.Server
|
||||
srv = sandbar.New(sandbar.Options{
|
||||
DataDir: *data,
|
||||
URL: strings.TrimRight(*url, "/"),
|
||||
MetricsToken: os.Getenv("SANDBAR_METRICS_TOKEN"),
|
||||
MessageDays: atoi("SANDBAR_MESSAGE_DAYS"),
|
||||
CheckDays: atoi("SANDBAR_CHECK_DAYS"),
|
||||
HitDays: atoi("SANDBAR_ANALYTICS_DAYS"),
|
||||
TrustProxy: os.Getenv("SANDBAR_TRUST_PROXY") == "1",
|
||||
AppDir: os.Getenv("SANDBAR_APP_DIR"),
|
||||
AppOrigin: os.Getenv("SANDBAR_APP_ORIGIN"),
|
||||
VapidSubject: os.Getenv("SANDBAR_VAPID_SUBJECT"),
|
||||
|
||||
PushAllowPrivate: os.Getenv("SANDBAR_PUSH_DEV") == "1",
|
||||
HammerheadURL: os.Getenv("SANDBAR_HAMMERHEAD_URL"),
|
||||
HammerheadEmail: os.Getenv("SANDBAR_HAMMERHEAD_EMAIL"),
|
||||
HammerheadPassword: os.Getenv("SANDBAR_HAMMERHEAD_PASSWORD"),
|
||||
Version: version,
|
||||
AutoUpdate: *autoUpdate,
|
||||
UpdateServer: os.Getenv("SANDBAR_UPDATE_SERVER"),
|
||||
UpdateRepo: os.Getenv("SANDBAR_UPDATE_REPO"),
|
||||
Restart: func(exe string) error {
|
||||
// Let go of the port and the database, then become the new binary. If that fails, exit
|
||||
// so the container or service manager starts it again, now from the new file.
|
||||
hs.Close()
|
||||
srv.Close()
|
||||
st.Close()
|
||||
err := selfupdate.Restart(exe)
|
||||
log.Printf("could not restart into %s, exiting: %v", exe, err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
},
|
||||
SMTP: sandbar.SMTPConfig{Host: os.Getenv("SANDBAR_SMTP_HOST"), Port: os.Getenv("SANDBAR_SMTP_PORT"),
|
||||
User: os.Getenv("SANDBAR_SMTP_USER"), Password: os.Getenv("SANDBAR_SMTP_PASSWORD"), From: env("SANDBAR_SMTP_FROM", "sandbar@localhost")},
|
||||
}, st)
|
||||
srv.BootstrapAdmin(os.Getenv("SANDBAR_ADMIN_EMAIL"), os.Getenv("SANDBAR_ADMIN_PASSWORD"))
|
||||
srv.Start()
|
||||
if *autoUpdate {
|
||||
go srv.RunAutoUpdate(context.Background())
|
||||
}
|
||||
|
||||
hs = &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute}
|
||||
go func() {
|
||||
log.Printf("sandbar %s listening on %s", version, *listen)
|
||||
if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||
<-sig
|
||||
srv.Close() // ends open subscriptions so Shutdown doesn't wait on them
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
hs.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// masterKey returns SANDBAR_MASTER_KEY, or generates one into <data>/master.key so
|
||||
// it works out of the box (move it into .env so a backup of the folder doesn't carry it).
|
||||
func masterKey(dir string) (string, error) {
|
||||
if k := strings.TrimSpace(os.Getenv("SANDBAR_MASTER_KEY")); k != "" {
|
||||
return k, nil
|
||||
}
|
||||
p := filepath.Join(dir, "master.key")
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
k := secretbox.GenerateKey()
|
||||
if err := os.WriteFile(p, []byte(k+"\n"), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
log.Printf("generated %s; copy it into .env as SANDBAR_MASTER_KEY and delete the file. Back it up: without it, saved secrets can't be recovered", p)
|
||||
return k, nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
# Builds sandbar for every release platform and packs the archives into dist/.
|
||||
#
|
||||
# deploy/build.sh [version] version defaults to `git describe`
|
||||
#
|
||||
# Output:
|
||||
# dist/bin/<os>_<arch>/sandbar the raw binaries (the Docker image uses the linux ones)
|
||||
# dist/sandbar_<version>_<os>_<arch>.tar.gz or .zip
|
||||
# dist/SHA256SUMS
|
||||
#
|
||||
# Self-update downloads the linux tar.gz archives, so both linux architectures are always built.
|
||||
# Set TARGETS="linux/arm64 darwin/arm64" (any GOOS/GOARCH pair Go supports) to build something else by hand.
|
||||
set -eu
|
||||
NAME=sandbar
|
||||
cd "$(dirname "$0")/.."
|
||||
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
|
||||
TARGETS=${TARGETS:-"linux/amd64 linux/arm64 windows/amd64"}
|
||||
|
||||
rm -rf dist
|
||||
mkdir -p dist/bin dist/stage
|
||||
for t in $TARGETS; do
|
||||
os=${t%/*}
|
||||
arch=${t#*/}
|
||||
ext=""
|
||||
[ "$os" = windows ] && ext=".exe"
|
||||
out="dist/bin/${os}_${arch}"
|
||||
mkdir -p "$out"
|
||||
echo "building $NAME $VERSION for $os/$arch"
|
||||
CGO_ENABLED=0 GOOS=$os GOARCH=$arch GOARM=7 \
|
||||
go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$out/$NAME$ext" "./cmd/$NAME"
|
||||
|
||||
pkg="${NAME}_${VERSION}_${os}_${arch}"
|
||||
stage="dist/stage/$pkg"
|
||||
mkdir -p "$stage"
|
||||
cp "$out/$NAME$ext" "$stage/"
|
||||
for f in LICENSE README.md .env.example; do
|
||||
[ -f "$f" ] && cp "$f" "$stage/"
|
||||
done
|
||||
if [ "$os" = windows ] && command -v zip >/dev/null 2>&1; then
|
||||
(cd dist/stage && zip -qr "../$pkg.zip" "$pkg")
|
||||
else
|
||||
tar -C dist/stage -czf "dist/$pkg.tar.gz" "$pkg"
|
||||
fi
|
||||
done
|
||||
rm -rf dist/stage
|
||||
(cd dist && sha256sum $(ls | grep -E '\.(tar\.gz|zip)$') > SHA256SUMS)
|
||||
echo "done: $(ls dist | grep -cE '\.(tar\.gz|zip)$') archives in dist/"
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/bin/sh
|
||||
# Creates (or updates) the Gitea release for a tag and uploads what deploy/build.sh made.
|
||||
#
|
||||
# TOKEN=<access token that can write releases> deploy/publish-release.sh v0.2.0
|
||||
#
|
||||
# In Gitea Actions GITHUB_SERVER_URL and GITHUB_REPOSITORY are already set. Safe to
|
||||
# re-run: an existing release is reused and assets with the same name are replaced.
|
||||
set -eu
|
||||
TAG=${1:?usage: publish-release.sh <tag>}
|
||||
: "${TOKEN:?set TOKEN to a Gitea access token that can write releases}"
|
||||
SERVER=${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-https://git.veillax.com}}
|
||||
REPO=${GITHUB_REPOSITORY:-Veillax/sandbar}
|
||||
API="$SERVER/api/v1/repos/$REPO"
|
||||
AUTH="Authorization: token $TOKEN"
|
||||
|
||||
# a JSON helper without needing jq: any python will do
|
||||
PY=python3
|
||||
python3 -c 'pass' > /dev/null 2>&1 || PY=python
|
||||
json() { "$PY" -c "$1"; }
|
||||
|
||||
# release notes: the commit subjects since the previous tag
|
||||
prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
|
||||
if [ -n "$prev" ]; then
|
||||
notes=$(git log --pretty='format:- %s' "$prev..$TAG")
|
||||
else
|
||||
notes=$(git log --pretty='format:- %s' "$TAG")
|
||||
fi
|
||||
prerelease=false
|
||||
case "$TAG" in *-*) prerelease=true ;; esac
|
||||
|
||||
body=$(NOTES="$notes" TAG="$TAG" PRE="$prerelease" json '
|
||||
import json, os
|
||||
print(json.dumps({"tag_name": os.environ["TAG"], "name": os.environ["TAG"], "body": os.environ["NOTES"],
|
||||
"draft": False, "prerelease": os.environ["PRE"] == "true"}))')
|
||||
|
||||
# reuse the release when a failed job is re-run
|
||||
id=$(curl -fsS -H "$AUTH" "$API/releases/tags/$TAG" 2>/dev/null | json 'import sys, json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
|
||||
if [ -z "$id" ]; then
|
||||
echo "creating release $TAG"
|
||||
id=$(curl -fsS -X POST -H "$AUTH" -H 'Content-Type: application/json' -d "$body" "$API/releases" | json 'import sys, json; print(json.load(sys.stdin)["id"])')
|
||||
else
|
||||
echo "release $TAG already exists (id $id): replacing its files"
|
||||
fi
|
||||
|
||||
# the ids of the release's assets with a given name (a failed upload can leave a partial one behind)
|
||||
asset_ids() {
|
||||
curl -fsS -H "$AUTH" "$API/releases/$id/assets" | "$PY" -c 'import sys, json
|
||||
for a in json.load(sys.stdin):
|
||||
if a["name"] == sys.argv[1]:
|
||||
print(a["id"])' "$1"
|
||||
}
|
||||
|
||||
for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do
|
||||
[ -f "$f" ] || continue
|
||||
name=$(basename "$f")
|
||||
n=1
|
||||
while :; do
|
||||
for aid in $(asset_ids "$name"); do
|
||||
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null
|
||||
done
|
||||
echo "uploading $name (attempt $n)"
|
||||
if curl -fsS --max-time 1200 -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null; then
|
||||
break
|
||||
fi
|
||||
# large uploads can hit a proxy timeout (HTTP 502); try again
|
||||
[ "$n" -lt 5 ] || { echo "giving up on $name after $n attempts" >&2; exit 1; }
|
||||
n=$((n + 1))
|
||||
sleep $((n * 10))
|
||||
done
|
||||
done
|
||||
echo "published $SERVER/$REPO/releases/tag/$TAG"
|
||||
@@ -0,0 +1,70 @@
|
||||
# Sandbar design language
|
||||
|
||||
Sandbar's pages follow the Lightwell design language, as Hammerhead does. The references are `docs/design.md` in the Lightwell repository and in the Hammerhead repository. This page records how that language is applied here. When they disagree, Lightwell's document wins.
|
||||
|
||||
## What is shared
|
||||
|
||||
* **Tokens.** Colors, spacing (4px scale), radius (6px and 10px), type sizes (12 to 28px), the single shadow and the dark theme are the Lightwell values, copied from Hammerhead. The accent is Lightwell's teal, `#1f5c5c`, with white text on it.
|
||||
* **Principles.** Quiet neutrals with one accent, plain and specific wording, one way to do each thing, calm density, and WCAG 2.1 AA.
|
||||
* **Voice.** No emoji, no em or en dashes, no exclamation marks, sentence case, buttons that name the result, and errors that say what happened and what to do next. Errors from the server are capitalized in one place (`bad` in `api.go`).
|
||||
* **Status carries text.** A status is a badge with a label ("Firing", "Down", "Resolved"), never a colored dot on its own.
|
||||
|
||||
## Where it lives
|
||||
|
||||
| File | What it holds |
|
||||
| --- | --- |
|
||||
| `internal/sandbar/web/index.html` | The app. The token block sits between `tokens:begin` and `tokens:end`; raw colors are allowed only there. |
|
||||
| `internal/sandbar/web/status.html` | The public status page: home, history and incident pages in one file. Same token block, repeated. |
|
||||
|
||||
Both are single files with no build step. Change a token in both, and in Hammerhead when the language changes.
|
||||
|
||||
## Components
|
||||
|
||||
The helpers at the top of the script in `index.html` are the same as Hammerhead's: `card`, `pageHeader`, `field`, `chk`, `badge`, `emptyState`, `modal`, `dialogForm`, `confirmDialog`, `ask`, `toast`, `skeleton`. Feature code uses these and does not style one-off. Sandbar adds three, built only from tokens:
|
||||
|
||||
| Helper | Use |
|
||||
| --- | --- |
|
||||
| `table(head, rows)` | A table inside a flush card, with column headers |
|
||||
| `rowList(addLabel, make, initial)` | A list the person adds to and removes from, such as people in a rotation or steps in a policy |
|
||||
| `timeline(events)` | Dated entries for an alert or an incident |
|
||||
|
||||
## Status page bars and charts
|
||||
|
||||
* **Uptime bars** on the public page are one bar per UTC day, 90 of them. Green is no failures, amber is at least 99% passing, red is below that, grey is no data. Color is never the only signal: the row carries a text status badge and the uptime percentage, each bar's tooltip says the date and the numbers in words, and the whole strip has an `aria-label` summary.
|
||||
* **Component states** are badges with text: Operational (success), Degraded performance and Partial outage (warning), Major outage (danger), Under maintenance (accent). A group heading shows the worst state of its components.
|
||||
* **Incident posts** are a short list with a rule on the left: the status in bold, the time, then the text, newest first. Maintenance windows use the same card with a Scheduled, In progress or Completed badge.
|
||||
* **Branding** changes one token only, `--accent`, which the page sets from the saved color. The admin form refuses colors that give white text less than 4.5 to 1, so the contrast rule survives any choice.
|
||||
* **Analytics charts** are plain bars made of tokens, with two series (visitors in the accent, page views in a lighter mix of it), a legend with text, a native tooltip per bar, and an `aria-label` that gives the totals. There are no chart libraries.
|
||||
* **Top lists** are tables with a proportional bar behind the name, so the numbers stay readable without the bar.
|
||||
|
||||
## Icons
|
||||
|
||||
Lucide style, outline only, stroke 1.75, round caps, `currentColor`. Sizes are 16, 18 and 22 pixels. They are inline in the `ICONS` table. An icon goes with a text label, and icon-only buttons carry an `aria-label`.
|
||||
|
||||
Approved names: `sandbar`, `alerts`, `incidents`, `oncall`, `monitors`, `websites`, `topics`, `channels`, `status`, `users`, `account`, `out`, `menu`, `close`, `add`, `check`, `back`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together.
|
||||
|
||||
## Patterns
|
||||
|
||||
* **One primary action per view.** It sits in the page header ("Declare incident", "Add monitor").
|
||||
* **List pages** show the page header, a filter or tab row where there is one, then the table in a card with no second title.
|
||||
* **Detail pages** (an incident) use a back link, the title with its status badge, then cards, most important first.
|
||||
* **Dialogs and forms** end with Cancel and then the primary action, at the bottom right. Labels sit above fields, and rules such as "At least 10 characters" are hints below them.
|
||||
* **Destructive actions** always use `confirmDialog`. The title names the object, the body says what is lost and whether it can be undone.
|
||||
* **Dates** have the month written out ("Nov 2, 2026"), with `fmtDate` and `fmtTime`.
|
||||
* **Empty states** say why the list is empty and what will appear there.
|
||||
* **Secrets** such as a channel's address are never sent back to the browser. The form says it is stored encrypted and hidden.
|
||||
|
||||
## Layout
|
||||
|
||||
* Content is capped at 1120px. Below 860px the sidebar becomes a slide-in drawer with a top bar. Phones keep a 16px side gutter.
|
||||
* On touch screens controls are at least 44px high and inputs are 16px so the page does not zoom.
|
||||
* Motion is minimal and turns off under `prefers-reduced-motion`. The theme follows the system and can be switched from the sidebar.
|
||||
* The public status page is a single column, 720px wide, and has no navigation.
|
||||
|
||||
## Checking a change
|
||||
|
||||
```
|
||||
node scripts/lint-design.mjs
|
||||
```
|
||||
|
||||
It fails on em and en dashes, emoji, and raw colors outside the token block, in the pages, the docs and the Go source. Then check the page in light and dark, at phone and desktop width, using only the keyboard.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Mobile companion API
|
||||
|
||||
Used by the Remora PWA. All calls take `Authorization: Bearer <api key>`.
|
||||
|
||||
| Call | What it does |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/mobile/login` `{email, password, device}` | Returns `{token, keyId, user}`. The token is a new API key named `Mobile: <device>`; delete it under API keys to sign that phone out. No cookie is set. |
|
||||
| `GET /api/v1/push/key` | The VAPID public key (base64url) for `pushManager.subscribe`. Created on first use, stored sealed in the database. |
|
||||
| `POST /api/v1/push/subscriptions` `{endpoint, keys:{p256dh, auth}, topics, device}` | Registers or updates a device (matched by endpoint). The caller's personal topic is always added. |
|
||||
| `GET /api/v1/push/subscriptions` | The caller's devices. |
|
||||
| `DELETE /api/v1/push/subscriptions/{id}` | Removes one. |
|
||||
| `POST /api/v1/push/test` | Sends a test push to the caller's devices and reports each result. |
|
||||
| `POST /api/v1/alerts/{id}/escalate` | Runs the alert's next escalation step now. |
|
||||
| `GET /api/v1/hammerhead/edges` | Read-only edges and online state from a linked Hammerhead (`{configured, edges}`). |
|
||||
|
||||
Push payload (JSON, encrypted with aes128gcm): `id, topic, title, message, priority, tags, time, status`, plus `alert`
|
||||
(the alert id) on on-call alerts, so the app can offer Acknowledge, Snooze and Escalate. Messages are cut to 1200 characters.
|
||||
A push service answering 404 or 410 removes the subscription.
|
||||
|
||||
`SANDBAR_APP_DIR` serves the app at `/app/`. `SANDBAR_APP_ORIGIN` lets an app hosted elsewhere call `/api/v1/*` (CORS).
|
||||
@@ -0,0 +1,21 @@
|
||||
module git.veillax.com/Veillax/sandbar
|
||||
|
||||
go 1.27.3
|
||||
|
||||
require (
|
||||
git.veillax.com/Veillax/reef v0.1.8
|
||||
golang.org/x/crypto v0.58.0
|
||||
modernc.org/sqlite v1.61.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
modernc.org/libc v1.79.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
git.veillax.com/Veillax/reef v0.1.8 h1:RfTKu2hGVHy+pzB2/3zk7SEvDr5QKsaqIYEkF74B2Ns=
|
||||
git.veillax.com/Veillax/reef v0.1.8/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
|
||||
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
|
||||
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
|
||||
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
|
||||
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
|
||||
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,431 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Website analytics, in the manner of Tianji, Plausible and Umami: a small script on a site sends
|
||||
// page views and custom events here, and the Websites page shows visitors, pages, referrers,
|
||||
// browsers, systems, devices, countries and events.
|
||||
//
|
||||
// Privacy by construction: no cookies, no stored IP address or user agent. A visitor is a hash of the
|
||||
// IP address, the user agent and the website, salted with a secret and the UTC date, so the same
|
||||
// person is the same visitor within a day and cannot be followed beyond it.
|
||||
|
||||
//go:embed web/script.js
|
||||
var trackerJS []byte
|
||||
|
||||
// Website is a site whose visits are counted.
|
||||
type Website struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain"` // optional: when set, only hits from this host and its subdomains count
|
||||
}
|
||||
|
||||
func prepareWebsite(v any, id string) error {
|
||||
w := v.(*Website)
|
||||
w.ID = id
|
||||
w.Name = strings.TrimSpace(w.Name)
|
||||
if w.Name == "" {
|
||||
return errors.New("give the website a name")
|
||||
}
|
||||
d := strings.ToLower(strings.TrimSpace(w.Domain))
|
||||
if d != "" {
|
||||
if !strings.Contains(d, "//") {
|
||||
d = "//" + d
|
||||
}
|
||||
u, err := url.Parse(d)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return errors.New("the domain should look like example.com")
|
||||
}
|
||||
d = u.Hostname()
|
||||
}
|
||||
w.Domain = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteWebsiteData(id string) {
|
||||
s.st.db.Exec(`DELETE FROM hits WHERE website=?`, id)
|
||||
}
|
||||
|
||||
// realIP is the address of the client. Behind a reverse proxy, set TrustProxy so the address the
|
||||
// proxy added (the last entry of X-Forwarded-For) is used; otherwise every visitor looks like the proxy.
|
||||
func (s *Server) realIP(r *http.Request) string {
|
||||
if s.opts.TrustProxy {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
if ip := strings.TrimSpace(parts[len(parts)-1]); net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
h, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (s *Server) serveTracker(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Write(trackerJS)
|
||||
}
|
||||
|
||||
var botRe = regexp.MustCompile(`(?i)bot|crawl|spider|slurp|headless|lighthouse|preview|monitor|curl/|wget|python-requests|go-http-client|okhttp`)
|
||||
|
||||
// parseUA reduces a user agent to a browser, a system and a kind of device. Only these labels are stored.
|
||||
func parseUA(ua string) (browser, os, device string) {
|
||||
switch {
|
||||
case strings.Contains(ua, "Edg/") || strings.Contains(ua, "EdgA/") || strings.Contains(ua, "EdgiOS/"):
|
||||
browser = "Edge"
|
||||
case strings.Contains(ua, "OPR/") || strings.Contains(ua, "Opera"):
|
||||
browser = "Opera"
|
||||
case strings.Contains(ua, "SamsungBrowser"):
|
||||
browser = "Samsung Internet"
|
||||
case strings.Contains(ua, "Firefox/") || strings.Contains(ua, "FxiOS/"):
|
||||
browser = "Firefox"
|
||||
case strings.Contains(ua, "Chrome/") || strings.Contains(ua, "CriOS/"):
|
||||
browser = "Chrome"
|
||||
case strings.Contains(ua, "Safari/"):
|
||||
browser = "Safari"
|
||||
default:
|
||||
browser = "Other"
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(ua, "Windows"):
|
||||
os = "Windows"
|
||||
case strings.Contains(ua, "Android"):
|
||||
os = "Android"
|
||||
case strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad") || strings.Contains(ua, "iPod"):
|
||||
os = "iOS"
|
||||
case strings.Contains(ua, "Mac OS X") || strings.Contains(ua, "Macintosh"):
|
||||
os = "macOS"
|
||||
case strings.Contains(ua, "CrOS"):
|
||||
os = "ChromeOS"
|
||||
case strings.Contains(ua, "Linux") || strings.Contains(ua, "X11"):
|
||||
os = "Linux"
|
||||
default:
|
||||
os = "Other"
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(ua, "iPad") || strings.Contains(ua, "Tablet") || (strings.Contains(ua, "Android") && !strings.Contains(ua, "Mobile")):
|
||||
device = "Tablet"
|
||||
case strings.Contains(ua, "Mobi") || strings.Contains(ua, "iPhone"):
|
||||
device = "Mobile"
|
||||
default:
|
||||
device = "Desktop"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// secret is the random value that salts visitor hashes. It is made once and kept with the other settings.
|
||||
func (s *Server) analyticsSecret() string {
|
||||
s.anaMu.Lock()
|
||||
defer s.anaMu.Unlock()
|
||||
if s.anaSecret != "" {
|
||||
return s.anaSecret
|
||||
}
|
||||
var v struct{ Secret string }
|
||||
if s.st.get("setting", "analytics", &v) != nil || v.Secret == "" {
|
||||
v.Secret = newToken()
|
||||
s.st.put("setting", "analytics", &v)
|
||||
}
|
||||
s.anaSecret = v.Secret
|
||||
return v.Secret
|
||||
}
|
||||
|
||||
func (s *Server) visitorID(website, ip, ua string, now time.Time) string {
|
||||
h := sha256.Sum256([]byte(s.analyticsSecret() + "|" + now.UTC().Format("2006-01-02") + "|" + website + "|" + ip + "|" + ua))
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
// hitAllowed limits how fast one address can send hits: a script that loops must not fill the database.
|
||||
func (s *Server) hitAllowed(ip string) bool {
|
||||
now := s.now()
|
||||
s.anaMu.Lock()
|
||||
defer s.anaMu.Unlock()
|
||||
if len(s.anaTry) > 20000 {
|
||||
s.anaTry = map[string][]time.Time{}
|
||||
}
|
||||
recent := s.anaTry[ip][:0]
|
||||
for _, t := range s.anaTry[ip] {
|
||||
if now.Sub(t) < time.Minute {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
if len(recent) >= 120 {
|
||||
s.anaTry[ip] = recent
|
||||
return false
|
||||
}
|
||||
s.anaTry[ip] = append(recent, now)
|
||||
return true
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > n {
|
||||
s = strings.ToValidUTF8(s[:n], "")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// collect receives a page view or event from the tracker script. It is public by design, so it
|
||||
// answers 204 to everything it ignores and gives nothing away.
|
||||
func (s *Server) collect(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
}
|
||||
defer w.WriteHeader(204)
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<10))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Website, Path, Referrer, Hostname, Language, Event string
|
||||
Data map[string]any
|
||||
}
|
||||
if json.Unmarshal(body, &in) != nil {
|
||||
return
|
||||
}
|
||||
var site Website
|
||||
if s.st.get("website", in.Website, &site) != nil {
|
||||
return
|
||||
}
|
||||
host := strings.ToLower(in.Hostname)
|
||||
if site.Domain != "" && host != site.Domain && !strings.HasSuffix(host, "."+site.Domain) {
|
||||
return
|
||||
}
|
||||
ua := r.UserAgent()
|
||||
if ua == "" || botRe.MatchString(ua) {
|
||||
return
|
||||
}
|
||||
ip := s.realIP(r)
|
||||
if !s.hitAllowed(ip) {
|
||||
return
|
||||
}
|
||||
path := in.Path
|
||||
if i := strings.IndexAny(path, "?"); i >= 0 {
|
||||
path = path[:i]
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
ref := ""
|
||||
if u, err := url.Parse(in.Referrer); err == nil && u.Host != "" {
|
||||
if rh := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www."); rh != strings.TrimPrefix(host, "www.") {
|
||||
ref = rh
|
||||
}
|
||||
}
|
||||
country := strings.ToUpper(clip(r.Header.Get("CF-IPCountry"), 2))
|
||||
if country == "" {
|
||||
country = strings.ToUpper(clip(r.Header.Get("CloudFront-Viewer-Country"), 2))
|
||||
}
|
||||
if country == "XX" || country == "T1" {
|
||||
country = ""
|
||||
}
|
||||
lang := strings.ToLower(clip(in.Language, 12))
|
||||
if i := strings.IndexAny(lang, "-_"); i > 0 {
|
||||
lang = lang[:i]
|
||||
}
|
||||
data := ""
|
||||
if in.Event != "" && len(in.Data) > 0 {
|
||||
if b, err := json.Marshal(in.Data); err == nil && len(b) <= 1024 {
|
||||
data = string(b)
|
||||
}
|
||||
}
|
||||
browser, osName, device := parseUA(ua)
|
||||
now := s.now()
|
||||
s.st.db.Exec(`INSERT INTO hits(website,time,visitor,event,path,referrer,browser,os,device,country,lang,data) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
site.ID, now.Unix(), s.visitorID(site.ID, ip, ua, now), clip(in.Event, 80), clip(path, 200), ref, browser, osName, device, country, lang, data)
|
||||
}
|
||||
|
||||
// ---------- reading ----------
|
||||
|
||||
type topRow struct {
|
||||
Name string `json:"name"`
|
||||
Visitors int `json:"visitors"`
|
||||
Views int `json:"views"` // page views, or the number of times for an event
|
||||
}
|
||||
|
||||
type seriesPoint struct {
|
||||
Time int64 `json:"t"`
|
||||
Views int `json:"views"`
|
||||
Visitors int `json:"visitors"`
|
||||
}
|
||||
|
||||
type siteStats struct {
|
||||
Range string `json:"range"`
|
||||
Bucket string `json:"bucket"` // "hour" or "day"
|
||||
Visitors int `json:"visitors"`
|
||||
Views int `json:"views"`
|
||||
Visits int `json:"visits"`
|
||||
BounceRate float64 `json:"bounceRate"` // percent of visits with one page view; -1 if no visits
|
||||
Online int `json:"online"` // distinct visitors in the last 5 minutes
|
||||
Series []seriesPoint `json:"series"`
|
||||
Pages []topRow `json:"pages"`
|
||||
Referrers []topRow `json:"referrers"`
|
||||
Browsers []topRow `json:"browsers"`
|
||||
Systems []topRow `json:"systems"`
|
||||
Devices []topRow `json:"devices"`
|
||||
Countries []topRow `json:"countries"`
|
||||
Languages []topRow `json:"languages"`
|
||||
Events []topRow `json:"events"`
|
||||
}
|
||||
|
||||
var ranges = map[string]time.Duration{"24h": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour, "90d": 90 * 24 * time.Hour}
|
||||
|
||||
// visitGap is how long a visitor can be quiet before their next page view starts a new visit.
|
||||
const visitGap = 30 * 60
|
||||
|
||||
func (s *Server) websiteStats(id, rng string) (*siteStats, error) {
|
||||
span, ok := ranges[rng]
|
||||
if !ok {
|
||||
rng, span = "7d", ranges["7d"]
|
||||
}
|
||||
now := s.now()
|
||||
from := now.Add(-span).Unix()
|
||||
out := &siteStats{Range: rng, Bucket: "day", BounceRate: -1, Series: []seriesPoint{}}
|
||||
step := int64(86400)
|
||||
if rng == "24h" {
|
||||
out.Bucket, step = "hour", 3600
|
||||
}
|
||||
db := s.st.db
|
||||
|
||||
db.QueryRow(`SELECT count(DISTINCT visitor), count(*) FROM hits WHERE website=? AND time>=? AND event=''`, id, from).Scan(&out.Visitors, &out.Views)
|
||||
db.QueryRow(`SELECT count(DISTINCT visitor) FROM hits WHERE website=? AND time>=?`, id, now.Add(-5*time.Minute).Unix()).Scan(&out.Online)
|
||||
|
||||
// Visits and bounces: a visitor's page views, split wherever they were quiet for half an hour.
|
||||
if rows, err := db.Query(`SELECT visitor,time FROM hits WHERE website=? AND time>=? AND event='' ORDER BY visitor,time`, id, from); err == nil {
|
||||
var prev string
|
||||
var prevT int64
|
||||
views, bounces := 0, 0
|
||||
closeVisit := func() {
|
||||
if views > 0 {
|
||||
out.Visits++
|
||||
}
|
||||
if views == 1 {
|
||||
bounces++
|
||||
}
|
||||
}
|
||||
for rows.Next() {
|
||||
var v string
|
||||
var t int64
|
||||
if rows.Scan(&v, &t) != nil {
|
||||
continue
|
||||
}
|
||||
if v != prev || t-prevT > visitGap {
|
||||
closeVisit()
|
||||
views = 0
|
||||
}
|
||||
views++
|
||||
prev, prevT = v, t
|
||||
}
|
||||
closeVisit()
|
||||
rows.Close()
|
||||
if out.Visits > 0 {
|
||||
out.BounceRate = float64(bounces) * 100 / float64(out.Visits)
|
||||
}
|
||||
}
|
||||
|
||||
// The chart: one point per hour or UTC day, empty ones included so the line has no gaps.
|
||||
buckets := map[int64]seriesPoint{}
|
||||
if rows, err := db.Query(`SELECT (time/?)*?, count(*), count(DISTINCT visitor) FROM hits WHERE website=? AND time>=? AND event='' GROUP BY 1`, step, step, id, from); err == nil {
|
||||
for rows.Next() {
|
||||
var p seriesPoint
|
||||
if rows.Scan(&p.Time, &p.Views, &p.Visitors) == nil {
|
||||
buckets[p.Time] = p
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
for t := (from / step) * step; t <= now.Unix(); t += step {
|
||||
p := buckets[t]
|
||||
p.Time = t
|
||||
out.Series = append(out.Series, p)
|
||||
}
|
||||
|
||||
top := func(col string, events bool, empty string) []topRow {
|
||||
cond := "event=''"
|
||||
sel := col
|
||||
if events {
|
||||
cond, sel = "event!=''", "event"
|
||||
}
|
||||
rows, err := db.Query(`SELECT `+sel+`, count(DISTINCT visitor), count(*) FROM hits WHERE website=? AND time>=? AND `+cond+
|
||||
` GROUP BY 1 ORDER BY 3 DESC, 1 LIMIT 10`, id, from)
|
||||
list := []topRow{}
|
||||
if err != nil {
|
||||
return list
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var r topRow
|
||||
if rows.Scan(&r.Name, &r.Visitors, &r.Views) == nil {
|
||||
if r.Name == "" {
|
||||
r.Name = empty
|
||||
}
|
||||
list = append(list, r)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
out.Pages = top("path", false, "/")
|
||||
out.Referrers = top("referrer", false, "Direct")
|
||||
out.Browsers = top("browser", false, "Other")
|
||||
out.Systems = top("os", false, "Other")
|
||||
out.Devices = top("device", false, "Other")
|
||||
out.Countries = top("country", false, "Unknown")
|
||||
out.Languages = top("lang", false, "Unknown")
|
||||
out.Events = top("", true, "")
|
||||
sort.SliceStable(out.Series, func(i, j int) bool { return out.Series[i].Time < out.Series[j].Time })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) websiteStatsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var site Website
|
||||
if s.st.get("website", id, &site) != nil {
|
||||
writeJSON(w, 404, map[string]string{"error": "No such website."})
|
||||
return
|
||||
}
|
||||
st, _ := s.websiteStats(id, r.URL.Query().Get("range"))
|
||||
writeJSON(w, 200, st)
|
||||
}
|
||||
|
||||
// websiteSummary gives the list page a number for each site: visitors and page views in the last day.
|
||||
func (s *Server) websiteSummary(w http.ResponseWriter, r *http.Request) {
|
||||
out := map[string]map[string]int{}
|
||||
since := s.now().Add(-24 * time.Hour).Unix()
|
||||
rows, err := s.st.db.Query(`SELECT website, count(DISTINCT visitor), count(*) FROM hits WHERE time>=? AND event='' GROUP BY website`, since)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var v, p int
|
||||
if rows.Scan(&id, &v, &p) == nil {
|
||||
out[id] = map[string]int{"visitors": v, "views": p}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
uaChrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
||||
uaPhone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
|
||||
)
|
||||
|
||||
func TestParseUA(t *testing.T) {
|
||||
for _, c := range []struct{ ua, browser, os, device string }{
|
||||
{uaChrome, "Chrome", "Windows", "Desktop"},
|
||||
{uaPhone, "Safari", "iOS", "Mobile"},
|
||||
{"Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0", "Firefox", "Linux", "Desktop"},
|
||||
{"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/126.0 Safari/537.36 Edg/126.0", "Edge", "Windows", "Desktop"},
|
||||
{"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/126.0 Mobile Safari/537.36", "Chrome", "Android", "Mobile"},
|
||||
{"Mozilla/5.0 (Linux; Android 13; SM-X700) AppleWebKit/537.36 Chrome/126.0 Safari/537.36", "Chrome", "Android", "Tablet"},
|
||||
} {
|
||||
b, o, d := parseUA(c.ua)
|
||||
if b != c.browser || o != c.os || d != c.device {
|
||||
t.Errorf("%q: got %s/%s/%s, want %s/%s/%s", c.ua, b, o, d, c.browser, c.os, c.device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebsiteAnalytics(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
s.st.put("website", "w1", &Website{ID: "w1", Name: "Blog", Domain: "example.com"})
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
send := func(ua string, body string) int {
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/send", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
req.Header.Set("User-Agent", ua)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
view := func(ua, path, ref string) {
|
||||
b, _ := json.Marshal(map[string]string{"website": "w1", "hostname": "www.example.com", "path": path, "referrer": ref, "language": "en-GB"})
|
||||
if code := send(ua, string(b)); code != 204 {
|
||||
t.Fatalf("send: %d", code)
|
||||
}
|
||||
}
|
||||
view(uaChrome, "/", "https://news.example.org/story?id=1")
|
||||
view(uaChrome, "/about?utm=x", "")
|
||||
view(uaPhone, "/", "")
|
||||
send(uaChrome, `{"website":"w1","hostname":"example.com","path":"/pricing","event":"signup","data":{"plan":"pro"}}`)
|
||||
|
||||
// Things that must not be counted.
|
||||
send("Googlebot/2.1", `{"website":"w1","hostname":"example.com","path":"/bot"}`)
|
||||
send(uaChrome, `{"website":"w1","hostname":"evil.test","path":"/wrong-host"}`)
|
||||
send(uaChrome, `{"website":"nope","hostname":"example.com","path":"/unknown-site"}`)
|
||||
send(uaChrome, `not json`)
|
||||
|
||||
c := newAPIClient(t, ts)
|
||||
if code := c.do("GET", "/api/v1/websites/w1/stats", nil, nil); code != 401 {
|
||||
t.Fatalf("stats without sign-in: %d", code)
|
||||
}
|
||||
c.setup()
|
||||
var st siteStats
|
||||
if code := c.do("GET", "/api/v1/websites/w1/stats?range=7d", nil, &st); code != 200 {
|
||||
t.Fatalf("stats: %d", code)
|
||||
}
|
||||
if st.Views != 3 || st.Visitors != 2 || st.Visits != 2 || st.BounceRate != 50 {
|
||||
t.Fatalf("totals: views %d visitors %d visits %d bounce %.0f", st.Views, st.Visitors, st.Visits, st.BounceRate)
|
||||
}
|
||||
has := func(rows []topRow, name string, views int) bool {
|
||||
for _, r := range rows {
|
||||
if r.Name == name && r.Views == views {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
rows []topRow
|
||||
name string
|
||||
n int
|
||||
}{
|
||||
{"page /", st.Pages, "/", 2}, {"page /about without the query", st.Pages, "/about", 1},
|
||||
{"referrer host only", st.Referrers, "news.example.org", 1}, {"direct", st.Referrers, "Direct", 2},
|
||||
{"browser", st.Browsers, "Chrome", 2}, {"os", st.Systems, "iOS", 1},
|
||||
{"device", st.Devices, "Mobile", 1}, {"language", st.Languages, "en", 3}, {"event", st.Events, "signup", 1},
|
||||
} {
|
||||
if !has(c.rows, c.name, c.n) {
|
||||
t.Errorf("%s: %q x%d not in %+v", c.what, c.name, c.n, c.rows)
|
||||
}
|
||||
}
|
||||
for _, p := range st.Pages {
|
||||
if p.Name == "/bot" || p.Name == "/wrong-host" || p.Name == "/unknown-site" || p.Name == "/pricing" {
|
||||
t.Errorf("counted something it should not: %+v", p)
|
||||
}
|
||||
}
|
||||
if st.Online != 2 {
|
||||
t.Fatalf("online %d", st.Online)
|
||||
}
|
||||
nonEmpty := 0
|
||||
for _, p := range st.Series {
|
||||
if p.Views > 0 {
|
||||
nonEmpty++
|
||||
}
|
||||
}
|
||||
if nonEmpty != 1 || len(st.Series) < 7 {
|
||||
t.Fatalf("series: %d points, %d with views", len(st.Series), nonEmpty)
|
||||
}
|
||||
|
||||
// No IP address or user agent is stored.
|
||||
var n int
|
||||
s.st.db.QueryRow(`SELECT count(*) FROM hits WHERE visitor LIKE '%127.0.0.1%' OR browser LIKE '%Mozilla%'`).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatal("raw identifiers stored")
|
||||
}
|
||||
|
||||
// The script and the browser preflight.
|
||||
resp, _ := http.Get(ts.URL + "/script.js")
|
||||
body := make([]byte, 64)
|
||||
resp.Body.Read(body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Type"), "javascript") {
|
||||
t.Fatalf("script.js: %d %s", resp.StatusCode, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
req, _ := http.NewRequest("OPTIONS", ts.URL+"/api/send", nil)
|
||||
resp, _ = http.DefaultClient.Do(req)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 204 || resp.Header.Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Fatalf("preflight: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Deleting the website deletes its hits.
|
||||
if code := c.do("DELETE", "/api/v1/websites/w1", nil, nil); code != 200 {
|
||||
t.Fatalf("delete: %d", code)
|
||||
}
|
||||
s.st.db.QueryRow(`SELECT count(*) FROM hits`).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("%d hits left after deleting the website", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHitRateLimitAndProxy(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
for i := 0; i < 120; i++ {
|
||||
if !s.hitAllowed("1.2.3.4") {
|
||||
t.Fatalf("hit %d refused too early", i)
|
||||
}
|
||||
}
|
||||
if s.hitAllowed("1.2.3.4") {
|
||||
t.Fatal("the 121st hit in a minute should be refused")
|
||||
}
|
||||
if !s.hitAllowed("5.6.7.8") {
|
||||
t.Fatal("another address has its own allowance")
|
||||
}
|
||||
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.RemoteAddr = "10.0.0.1:5555"
|
||||
r.Header.Set("X-Forwarded-For", "6.6.6.6, 9.9.9.9")
|
||||
if ip := s.realIP(r); ip != "10.0.0.1" {
|
||||
t.Fatalf("without TrustProxy the connection address is used, got %s", ip)
|
||||
}
|
||||
s.opts.TrustProxy = true
|
||||
if ip := s.realIP(r); ip != "9.9.9.9" {
|
||||
t.Fatalf("the proxy's entry is the last one, got %s", ip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Handler returns the HTTP handler for the whole app.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// pages
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { servePage(w, indexHTML) })
|
||||
s.statusRoutes(mux)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
|
||||
mux.HandleFunc("GET /metrics", s.metrics)
|
||||
mux.HandleFunc("GET /script.js", s.serveTracker)
|
||||
mux.HandleFunc("POST /api/send", s.collect)
|
||||
mux.HandleFunc("OPTIONS /api/send", s.collect)
|
||||
mux.HandleFunc("GET /api/v1/hb/{token}", s.heartbeat)
|
||||
mux.HandleFunc("POST /api/v1/hb/{token}", s.heartbeat)
|
||||
|
||||
// session
|
||||
mux.HandleFunc("GET /api/v1/state", s.getState)
|
||||
mux.HandleFunc("POST /api/v1/setup", s.setup)
|
||||
mux.HandleFunc("POST /api/v1/login", s.apiLogin)
|
||||
mux.HandleFunc("POST /api/v1/logout", func(w http.ResponseWriter, r *http.Request) { s.logout(w, r); writeJSON(w, 200, ok) })
|
||||
|
||||
// mobile companion
|
||||
mux.HandleFunc("POST /api/v1/mobile/login", s.mobileLogin)
|
||||
mux.HandleFunc("GET /api/v1/push/key", s.auth(false, s.pushKeyHTTP))
|
||||
mux.HandleFunc("GET /api/v1/push/subscriptions", s.auth(false, s.listPushSubs))
|
||||
mux.HandleFunc("POST /api/v1/push/subscriptions", s.auth(false, s.savePushSub))
|
||||
mux.HandleFunc("DELETE /api/v1/push/subscriptions/{id}", s.auth(false, s.deletePushSub))
|
||||
mux.HandleFunc("POST /api/v1/push/test", s.auth(false, s.testPush))
|
||||
mux.HandleFunc("GET /api/v1/hammerhead/edges", s.auth(false, s.hammerheadEdges))
|
||||
|
||||
// topics, ntfy-style
|
||||
mux.HandleFunc("POST /{topic}", s.auth(false, s.publishHTTP))
|
||||
mux.HandleFunc("PUT /{topic}", s.auth(false, s.publishHTTP))
|
||||
mux.HandleFunc("GET /{topic}/sse", s.auth(false, s.subscribeHTTP))
|
||||
mux.HandleFunc("GET /{topic}/json", s.auth(false, s.subscribeHTTP))
|
||||
mux.HandleFunc("GET /api/v1/topics/{topic}/messages", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
|
||||
writeJSON(w, 200, s.MessagesSince(r.PathValue("topic"), since, 200))
|
||||
}))
|
||||
|
||||
// people
|
||||
mux.HandleFunc("GET /api/v1/users", s.auth(false, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.listUsers()) }))
|
||||
mux.HandleFunc("POST /api/v1/users", s.auth(true, s.createUserHTTP))
|
||||
mux.HandleFunc("DELETE /api/v1/users/{id}", s.auth(true, s.deleteUser))
|
||||
mux.HandleFunc("POST /api/v1/users/{id}/password", s.auth(false, s.passwordHTTP))
|
||||
mux.HandleFunc("GET /api/v1/apikeys", s.auth(false, s.listKeys))
|
||||
mux.HandleFunc("POST /api/v1/apikeys", s.auth(false, s.createKeyHTTP))
|
||||
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", s.auth(false, s.deleteKey))
|
||||
|
||||
// configuration documents
|
||||
s.crud(mux, "channels", "channel", true, func() any { return &Channel{} }, func(v any, id string) error {
|
||||
ch := v.(*Channel)
|
||||
ch.ID = id
|
||||
if ch.Target == redactedTarget { // an edit that left the hidden secret alone keeps the stored one
|
||||
old, err := getOf[Channel](s.st, "channel", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch.Target = s.st.open(old.Target)
|
||||
}
|
||||
if err := validateChannel(ch); err != nil {
|
||||
return err
|
||||
}
|
||||
ch.Target = s.st.seal(ch.Target)
|
||||
return nil
|
||||
}, redactChannel)
|
||||
s.crud(mux, "monitors", "monitor", true, func() any { return &Monitor{} }, func(v any, id string) error {
|
||||
m := v.(*Monitor)
|
||||
m.ID = id
|
||||
return validateMonitor(m)
|
||||
}, nil)
|
||||
s.crud(mux, "schedules", "schedule", true, func() any { return &Schedule{} }, func(v any, id string) error {
|
||||
sc := v.(*Schedule)
|
||||
sc.ID = id
|
||||
if strings.TrimSpace(sc.Name) == "" {
|
||||
return errors.New("give the schedule a name")
|
||||
}
|
||||
if sc.RotationHours < 1 {
|
||||
sc.RotationHours = 24 * 7
|
||||
}
|
||||
if sc.Start == 0 {
|
||||
sc.Start = s.now().Unix()
|
||||
}
|
||||
for _, o := range sc.Overrides {
|
||||
if o.To <= o.From {
|
||||
return errors.New("an override must end after it starts")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
s.crud(mux, "policies", "policy", true, func() any { return &Policy{} }, func(v any, id string) error {
|
||||
p := v.(*Policy)
|
||||
p.ID = id
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
return errors.New("give the policy a name")
|
||||
}
|
||||
if len(p.Steps) == 0 {
|
||||
return errors.New("a policy needs at least one step")
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
s.crud(mux, "websites", "website", true, func() any { return &Website{} }, prepareWebsite, nil)
|
||||
mux.HandleFunc("GET /api/v1/websites/{id}/stats", s.auth(false, s.websiteStatsHandler))
|
||||
mux.HandleFunc("GET /api/v1/website-summary", s.auth(false, s.websiteSummary))
|
||||
mux.HandleFunc("GET /api/v1/system/update", s.auth(true, s.updateStatus))
|
||||
mux.HandleFunc("POST /api/v1/system/update", s.auth(true, s.updateApply))
|
||||
mux.HandleFunc("POST /api/v1/channels/{id}/test", s.auth(true, s.testChannel))
|
||||
mux.HandleFunc("GET /api/v1/oncall", s.auth(false, s.onCallNow))
|
||||
|
||||
// monitors
|
||||
mux.HandleFunc("GET /api/v1/monitors/{id}/checks", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, s.recentChecks(r.PathValue("id"), 100))
|
||||
}))
|
||||
mux.HandleFunc("GET /api/v1/monitor-states", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
out := map[string]MonitorState{}
|
||||
mons, _ := listOf[Monitor](s.st, "monitor")
|
||||
for _, m := range mons {
|
||||
out[m.ID] = s.monitorState(m.ID)
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}))
|
||||
|
||||
// alerts
|
||||
mux.HandleFunc("GET /api/v1/alerts", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
writeJSON(w, 200, s.listAlerts(r.URL.Query().Get("status"), limit))
|
||||
}))
|
||||
mux.HandleFunc("POST /api/v1/alerts", s.auth(false, s.inboundAlert))
|
||||
mux.HandleFunc("POST /api/v1/integrations/alertmanager", s.auth(false, s.alertmanager))
|
||||
mux.HandleFunc("GET /api/v1/alerts/{id}", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := s.getAlert(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
a.Timeline = s.st.events("alert", a.ID)
|
||||
writeJSON(w, 200, a)
|
||||
}))
|
||||
mux.HandleFunc("POST /api/v1/alerts/{id}/ack", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail2(w, s.AckAlert(r.PathValue("id"), userFrom(r.Context()).Name))
|
||||
}))
|
||||
mux.HandleFunc("POST /api/v1/alerts/{id}/escalate", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail2(w, s.EscalateAlert(r.PathValue("id"), userFrom(r.Context()).Name))
|
||||
}))
|
||||
mux.HandleFunc("POST /api/v1/alerts/{id}/resolve", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail2(w, s.ResolveAlert(r.PathValue("id"), userFrom(r.Context()).Name))
|
||||
}))
|
||||
mux.HandleFunc("POST /api/v1/alerts/{id}/snooze", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Minutes int `json:"minutes"`
|
||||
}
|
||||
decode(r, &in)
|
||||
if in.Minutes < 1 || in.Minutes > 24*60 {
|
||||
in.Minutes = 30
|
||||
}
|
||||
fail2(w, s.SnoozeAlert(r.PathValue("id"), userFrom(r.Context()).Name, time.Duration(in.Minutes)*time.Minute))
|
||||
}))
|
||||
|
||||
// incidents
|
||||
mux.HandleFunc("GET /api/v1/incidents", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := listOf[Incident](s.st, "incident")
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
sortIncidents(list) // ids are random, so order by time
|
||||
writeJSON(w, 200, list)
|
||||
}))
|
||||
mux.HandleFunc("POST /api/v1/incidents", s.auth(false, s.declareHTTP))
|
||||
mux.HandleFunc("GET /api/v1/incidents/{id}", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
in, err := getOf[Incident](s.st, "incident", r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
in.Timeline = s.st.events("incident", in.ID)
|
||||
writeJSON(w, 200, in)
|
||||
}))
|
||||
mux.HandleFunc("PATCH /api/v1/incidents/{id}", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
var p IncidentPatch
|
||||
if !decode(r, &p) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
in, err := s.UpdateIncident(r.PathValue("id"), userFrom(r.Context()).Name, p)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, in)
|
||||
}))
|
||||
mux.HandleFunc("POST /api/v1/incidents/{id}/comments", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
decode(r, &in)
|
||||
fail2(w, s.CommentIncident(r.PathValue("id"), userFrom(r.Context()).Name, in.Text))
|
||||
}))
|
||||
mux.HandleFunc("POST /api/v1/incidents/{id}/postmortem-template", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
in, err := getOf[Incident](s.st, "incident", r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"template": s.postmortemTemplate(in)})
|
||||
}))
|
||||
mux.HandleFunc("DELETE /api/v1/incidents/{id}", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail2(w, s.st.del("incident", r.PathValue("id")))
|
||||
}))
|
||||
|
||||
return s.statusHost(s.appRoutes(secureHeaders(mux)))
|
||||
}
|
||||
|
||||
var ok = map[string]bool{"ok": true}
|
||||
|
||||
func servePage(w http.ResponseWriter, page []byte) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(page)
|
||||
}
|
||||
|
||||
func secureHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
img := "'self' data:"
|
||||
if strings.HasPrefix(r.URL.Path, "/status") {
|
||||
img += " https:" // the status page can show a logo hosted elsewhere
|
||||
}
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; img-src "+img)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// bad answers 400. Messages are capitalized here, in one place, so errors read as sentences.
|
||||
func bad(w http.ResponseWriter, msg string) {
|
||||
if msg != "" {
|
||||
r, n := utf8.DecodeRuneInString(msg)
|
||||
msg = string(unicode.ToUpper(r)) + msg[n:]
|
||||
}
|
||||
writeJSON(w, 400, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func fail(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, errNotFound) {
|
||||
writeJSON(w, 404, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
bad(w, err.Error())
|
||||
}
|
||||
|
||||
// fail2 answers with an error, or with ok when err is nil.
|
||||
func fail2(w http.ResponseWriter, err error) {
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, ok)
|
||||
}
|
||||
|
||||
func decode(r *http.Request, v any) bool {
|
||||
return json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)).Decode(v) == nil
|
||||
}
|
||||
|
||||
// auth wraps a handler: it needs a signed-in user (admin if adminOnly). Requests
|
||||
// that change things with a cookie must be JSON, which a cross-site form can't send.
|
||||
func (s *Server) auth(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.authenticate(r)
|
||||
if u == nil {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="sandbar"`)
|
||||
writeJSON(w, 401, map[string]string{"error": "sign in or send an API key"})
|
||||
return
|
||||
}
|
||||
if adminOnly && u.Role != "admin" {
|
||||
writeJSON(w, 403, map[string]string{"error": "administrators only"})
|
||||
return
|
||||
}
|
||||
_, noCookie := r.Cookie(sessionCookie)
|
||||
if noCookie == nil && r.Method != "GET" && strings.HasPrefix(r.URL.Path, "/api/") &&
|
||||
!strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
bad(w, "send application/json")
|
||||
return
|
||||
}
|
||||
h(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, u)))
|
||||
}
|
||||
}
|
||||
|
||||
// crud registers list/create/update/delete for a kind of configuration document.
|
||||
func (s *Server) crud(mux *http.ServeMux, path, kind string, adminWrite bool, newItem func() any, prepare func(v any, id string) error, redact func(any) any) {
|
||||
base := "/api/v1/" + path
|
||||
read := func(v any) any {
|
||||
if redact != nil {
|
||||
return redact(v)
|
||||
}
|
||||
return v
|
||||
}
|
||||
mux.HandleFunc("GET "+base, s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
var out []any
|
||||
s.st.list(kind, func(raw []byte) error {
|
||||
v := newItem()
|
||||
if err := json.Unmarshal(raw, v); err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, read(v))
|
||||
return nil
|
||||
})
|
||||
if out == nil {
|
||||
out = []any{}
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}))
|
||||
save := func(w http.ResponseWriter, r *http.Request, id string) {
|
||||
v := newItem()
|
||||
if !decode(r, v) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
if err := prepare(v, id); err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.st.put(kind, id, v); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, read(v))
|
||||
}
|
||||
mux.HandleFunc("POST "+base, s.auth(adminWrite, func(w http.ResponseWriter, r *http.Request) { save(w, r, newID()) }))
|
||||
mux.HandleFunc("PUT "+base+"/{id}", s.auth(adminWrite, func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.st.get(kind, id, newItem()); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
// A channel's secret target is never sent to the browser; an unchanged form sends the placeholder.
|
||||
save(w, r, id)
|
||||
}))
|
||||
mux.HandleFunc("DELETE "+base+"/{id}", s.auth(adminWrite, func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if kind == "monitor" {
|
||||
s.deleteMonitorData(id)
|
||||
}
|
||||
if kind == "website" {
|
||||
s.deleteWebsiteData(id)
|
||||
}
|
||||
fail2(w, s.st.del(kind, id))
|
||||
}))
|
||||
}
|
||||
|
||||
func redactChannel(v any) any {
|
||||
c := *(v.(*Channel))
|
||||
c.Target = redactedTarget // webhook URLs and addresses are secrets; re-enter to change
|
||||
return c
|
||||
}
|
||||
|
||||
const redactedTarget = "••••••••"
|
||||
|
||||
// ---------- session ----------
|
||||
|
||||
func (s *Server) getState(w http.ResponseWriter, r *http.Request) {
|
||||
out := map[string]any{"needsSetup": s.countUsers() == 0, "user": nil, "url": s.opts.URL}
|
||||
if u := s.authenticate(r); u != nil {
|
||||
out["user"] = u
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Name, Email, Password string }
|
||||
if !decode(r, &in) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
if s.countUsers() > 0 {
|
||||
writeJSON(w, 403, map[string]string{"error": "already set up"})
|
||||
return
|
||||
}
|
||||
if _, err := s.createUser(in.Name, in.Email, in.Password, "admin", ""); err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
u, err := s.login(w, r, in.Email, in.Password)
|
||||
if err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, u)
|
||||
}
|
||||
|
||||
func (s *Server) apiLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Email, Password string }
|
||||
if !decode(r, &in) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
u, err := s.login(w, r, in.Email, in.Password)
|
||||
if err != nil {
|
||||
code := 401
|
||||
if errors.Is(err, errThrottled) {
|
||||
code = 429
|
||||
}
|
||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, u)
|
||||
}
|
||||
|
||||
// ---------- people ----------
|
||||
|
||||
func (s *Server) createUserHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Name, Email, Password, Role, Phone string }
|
||||
if !decode(r, &in) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
if in.Role == "" {
|
||||
in.Role = "member"
|
||||
}
|
||||
u, err := s.createUser(in.Name, in.Email, in.Password, in.Role, in.Phone)
|
||||
if err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, u)
|
||||
}
|
||||
|
||||
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == userFrom(r.Context()).ID {
|
||||
bad(w, "you can't delete yourself")
|
||||
return
|
||||
}
|
||||
res, err := s.st.db.Exec(`DELETE FROM users WHERE id=?`, id)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
fail(w, errNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, ok)
|
||||
}
|
||||
|
||||
func (s *Server) passwordHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
me := userFrom(r.Context())
|
||||
id := r.PathValue("id")
|
||||
if id != me.ID && me.Role != "admin" {
|
||||
writeJSON(w, 403, map[string]string{"error": "you can only change your own password"})
|
||||
return
|
||||
}
|
||||
var in struct{ Password string }
|
||||
if !decode(r, &in) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
fail2(w, s.setPassword(id, in.Password))
|
||||
}
|
||||
|
||||
func (s *Server) listKeys(w http.ResponseWriter, r *http.Request) {
|
||||
out := []map[string]any{}
|
||||
rows, err := s.st.db.Query(`SELECT id,name,created FROM apikeys WHERE user_id=? ORDER BY created`, userFrom(r.Context()).ID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, name string
|
||||
var c int64
|
||||
if rows.Scan(&id, &name, &c) == nil {
|
||||
out = append(out, map[string]any{"id": id, "name": name, "created": c})
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) createKeyHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Name string }
|
||||
decode(r, &in)
|
||||
id, tok, err := s.createAPIKey(userFrom(r.Context()).ID, in.Name)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"id": id, "token": tok}) // shown once
|
||||
}
|
||||
|
||||
func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) {
|
||||
s.st.db.Exec(`DELETE FROM apikeys WHERE id=? AND user_id=?`, r.PathValue("id"), userFrom(r.Context()).ID)
|
||||
writeJSON(w, 200, ok)
|
||||
}
|
||||
|
||||
// ---------- topics ----------
|
||||
|
||||
func (s *Server) publishHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
topic := r.PathValue("topic")
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
if err != nil {
|
||||
bad(w, "message too large (limit 64 KB)")
|
||||
return
|
||||
}
|
||||
h := func(names ...string) string {
|
||||
for _, n := range names {
|
||||
if v := r.Header.Get(n); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := r.URL.Query().Get(strings.ToLower(n)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
m, err := s.Publish(topic, h("Title", "X-Title", "t"), string(body), parsePriority(h("Priority", "X-Priority", "p")), parseTags(h("Tags", "X-Tags", "ta")))
|
||||
if err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
m.Event = "message"
|
||||
writeJSON(w, 200, m)
|
||||
}
|
||||
|
||||
// subscribeHTTP streams a topic as server-sent events (/sse) or JSON lines (/json).
|
||||
// ?since= replays stored messages first: "all", unix seconds, a duration such as 10m, or the id
|
||||
// of the last message the client saw. ?poll=1 sends only that replay and then ends, which is
|
||||
// how the ntfy apps catch up.
|
||||
func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
topic := r.PathValue("topic")
|
||||
if !validTopic(topic) {
|
||||
bad(w, "invalid topic")
|
||||
return
|
||||
}
|
||||
fl, canFlush := w.(http.Flusher)
|
||||
if !canFlush {
|
||||
http.Error(w, "streaming not supported", 500)
|
||||
return
|
||||
}
|
||||
sse := strings.HasSuffix(r.URL.Path, "/sse")
|
||||
if sse {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
bw := bufio.NewWriter(w)
|
||||
event := func(kind string) {
|
||||
b, _ := json.Marshal(map[string]any{"id": newID(), "time": s.now().Unix(), "event": kind, "topic": topic})
|
||||
fmt.Fprintf(bw, "%s\n", b)
|
||||
bw.Flush()
|
||||
fl.Flush()
|
||||
}
|
||||
write := func(m Message) bool {
|
||||
m.Event = "message"
|
||||
b, _ := json.Marshal(m)
|
||||
var err error
|
||||
if sse {
|
||||
_, err = fmt.Fprintf(bw, "id: %s\ndata: %s\n\n", m.ID, b)
|
||||
} else {
|
||||
_, err = fmt.Fprintf(bw, "%s\n", b)
|
||||
}
|
||||
bw.Flush()
|
||||
fl.Flush()
|
||||
return err == nil
|
||||
}
|
||||
// Subscribe before replaying so nothing published in between is missed; duplicates are skipped by id.
|
||||
ch, unsub := s.hub.subscribe(topic)
|
||||
defer unsub()
|
||||
seen := map[string]bool{}
|
||||
if q := r.URL.Query().Get("since"); q != "" {
|
||||
for _, m := range s.replaySince(topic, q) {
|
||||
seen[m.ID] = true
|
||||
if !write(m) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.URL.Query().Get("poll") == "1" {
|
||||
return
|
||||
}
|
||||
if sse {
|
||||
fmt.Fprint(bw, ": connected\n\n")
|
||||
bw.Flush()
|
||||
fl.Flush()
|
||||
} else {
|
||||
event("open")
|
||||
}
|
||||
keepalive := time.NewTicker(30 * time.Second)
|
||||
defer keepalive.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-s.stop:
|
||||
return
|
||||
case m := <-ch:
|
||||
if seen[m.ID] {
|
||||
continue
|
||||
}
|
||||
if !write(m) {
|
||||
return
|
||||
}
|
||||
case <-keepalive.C:
|
||||
if sse {
|
||||
fmt.Fprint(bw, ": keepalive\n\n")
|
||||
bw.Flush()
|
||||
fl.Flush()
|
||||
} else {
|
||||
event("keepalive")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replaySince returns the stored messages a subscriber should see first for a ?since= value.
|
||||
func (s *Server) replaySince(topic, q string) []Message {
|
||||
if q == "all" {
|
||||
return s.MessagesSince(topic, 0, 500)
|
||||
}
|
||||
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
|
||||
return s.MessagesSince(topic, n, 500)
|
||||
}
|
||||
if d, err := time.ParseDuration(q); err == nil {
|
||||
return s.MessagesSince(topic, s.now().Add(-d).Unix(), 500)
|
||||
}
|
||||
// Otherwise it is the id of the last message the client has: send what came after it.
|
||||
var at int64
|
||||
if s.st.db.QueryRow(`SELECT time FROM messages WHERE topic=? AND id=?`, topic, q).Scan(&at) != nil {
|
||||
return nil
|
||||
}
|
||||
all := s.MessagesSince(topic, at, 500)
|
||||
for i, m := range all {
|
||||
if m.ID == q {
|
||||
return all[i+1:]
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ---------- alerts in ----------
|
||||
|
||||
func (s *Server) inboundAlert(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Key string `json:"key"`
|
||||
Summary string `json:"summary"`
|
||||
Detail string `json:"detail"`
|
||||
Severity string `json:"severity"`
|
||||
Policy string `json:"policy"`
|
||||
Status string `json:"status"` // firing (default) or resolved
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if !decode(r, &in) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
if in.Key == "" {
|
||||
in.Key = in.Summary
|
||||
}
|
||||
if in.Key == "" {
|
||||
bad(w, "send a key or a summary")
|
||||
return
|
||||
}
|
||||
if in.Source == "" {
|
||||
in.Source = "api"
|
||||
}
|
||||
in.Key = "ext:" + in.Key
|
||||
if in.Status == "resolved" {
|
||||
s.ResolveAlertKey(in.Key, in.Source)
|
||||
writeJSON(w, 200, ok)
|
||||
return
|
||||
}
|
||||
if in.Summary == "" {
|
||||
in.Summary = in.Key
|
||||
}
|
||||
if in.Policy != "" {
|
||||
if _, err := getOf[Policy](s.st, "policy", in.Policy); err != nil {
|
||||
bad(w, "unknown escalation policy")
|
||||
return
|
||||
}
|
||||
}
|
||||
a, isNew, err := s.RaiseAlert(in.Key, in.Source, in.Summary, in.Detail, in.Severity, in.Policy)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"id": a.ID, "new": isNew})
|
||||
}
|
||||
|
||||
// alertmanager accepts Prometheus Alertmanager's webhook (also Grafana's, which uses the same shape).
|
||||
// ?policy=<id> sets the escalation policy.
|
||||
func (s *Server) alertmanager(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Alerts []struct {
|
||||
Status string `json:"status"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
} `json:"alerts"`
|
||||
}
|
||||
if !decode(r, &in) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
policy := r.URL.Query().Get("policy")
|
||||
if policy != "" {
|
||||
if _, err := getOf[Policy](s.st, "policy", policy); err != nil {
|
||||
bad(w, "unknown escalation policy")
|
||||
return
|
||||
}
|
||||
}
|
||||
n := 0
|
||||
for _, a := range in.Alerts {
|
||||
key := a.Fingerprint
|
||||
if key == "" {
|
||||
key = a.Labels["alertname"] + "/" + a.Labels["instance"]
|
||||
}
|
||||
key = "am:" + key
|
||||
if a.Status == "resolved" {
|
||||
s.ResolveAlertKey(key, "alertmanager")
|
||||
continue
|
||||
}
|
||||
sev := map[string]string{"critical": "critical", "warning": "warning", "info": "info"}[a.Labels["severity"]]
|
||||
summary := firstNonEmpty(a.Annotations["summary"], a.Labels["alertname"], key)
|
||||
if _, _, err := s.RaiseAlert(key, "alertmanager", summary, a.Annotations["description"], sev, policy); err == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]int{"processed": n})
|
||||
}
|
||||
|
||||
// ---------- misc ----------
|
||||
|
||||
func (s *Server) declareHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Title string `json:"title"`
|
||||
Severity string `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
Lead string `json:"lead"`
|
||||
AlertIDs []string `json:"alertIds"`
|
||||
// Public puts the incident on the status page, naming the affected components, as it is declared.
|
||||
Public bool `json:"public"`
|
||||
Components []string `json:"components"`
|
||||
}
|
||||
if !decode(r, &in) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
me := userFrom(r.Context())
|
||||
if in.Lead == "" {
|
||||
in.Lead = me.ID
|
||||
}
|
||||
inc, err := s.DeclareIncident(in.Title, in.Severity, in.Summary, in.Lead, me.Name, in.AlertIDs)
|
||||
if err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Public {
|
||||
pub, comps := true, in.Components
|
||||
if inc, err = s.UpdateIncident(inc.ID, me.Name, IncidentPatch{Public: &pub, Components: &comps}); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, inc)
|
||||
}
|
||||
|
||||
func (s *Server) testChannel(w http.ResponseWriter, r *http.Request) {
|
||||
ch, err := getOf[Channel](s.st, "channel", r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.sendChannel(*ch, Note{Status: "test", Kind: "alert", Title: "test", Time: s.now().Unix()}); err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, ok)
|
||||
}
|
||||
|
||||
func (s *Server) onCallNow(w http.ResponseWriter, r *http.Request) {
|
||||
type row struct {
|
||||
Schedule string `json:"schedule"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
User string `json:"user"`
|
||||
Next string `json:"next"` // who is on call after the current handover
|
||||
}
|
||||
out := []row{}
|
||||
scs, _ := listOf[Schedule](s.st, "schedule")
|
||||
now := s.now()
|
||||
for _, sc := range scs {
|
||||
sc := sc
|
||||
uid := sc.OnCall(now)
|
||||
nx := sc.OnCall(now.Add(time.Duration(sc.RotationHours) * time.Hour))
|
||||
out = append(out, row{sc.Name, sc.ID, uid, s.userName(uid), s.userName(nx)})
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.Heartbeat(r.PathValue("token")) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
io.WriteString(w, "ok")
|
||||
}
|
||||
|
||||
func sortIncidents(list []Incident) {
|
||||
for i := 1; i < len(list); i++ { // newest first; lists are short
|
||||
for j := i; j > 0 && list[j].Created > list[j-1].Created; j-- {
|
||||
list[j], list[j-1] = list[j-1], list[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) metrics(w http.ResponseWriter, r *http.Request) {
|
||||
if s.opts.MetricsToken == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if h := r.Header.Get("Authorization"); h != "Bearer "+s.opts.MetricsToken {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`)
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# TYPE sandbar_monitor_up gauge\n")
|
||||
mons, _ := listOf[Monitor](s.st, "monitor")
|
||||
for _, m := range mons {
|
||||
v := 0
|
||||
if s.monitorState(m.ID).Status == "up" {
|
||||
v = 1
|
||||
}
|
||||
fmt.Fprintf(&b, "sandbar_monitor_up{monitor=%q} %d\n", m.Name, v)
|
||||
}
|
||||
var firing, acked, open int
|
||||
s.st.db.QueryRow(`SELECT COUNT(*) FROM alerts WHERE status='firing'`).Scan(&firing)
|
||||
s.st.db.QueryRow(`SELECT COUNT(*) FROM alerts WHERE status='acked'`).Scan(&acked)
|
||||
incs, _ := listOf[Incident](s.st, "incident")
|
||||
for _, in := range incs {
|
||||
if in.Status != "resolved" {
|
||||
open++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "# TYPE sandbar_alerts gauge\nsandbar_alerts{status=\"firing\"} %d\nsandbar_alerts{status=\"acked\"} %d\n", firing, acked)
|
||||
fmt.Fprintf(&b, "# TYPE sandbar_incidents_open gauge\nsandbar_incidents_open %d\n", open)
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
io.WriteString(w, b.String())
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // admin or member
|
||||
Phone string `json:"phone"`
|
||||
Created int64 `json:"created"`
|
||||
Topic string `json:"topic"` // the personal topic their phone subscribes to
|
||||
hash string
|
||||
}
|
||||
|
||||
const (
|
||||
sessionCookie = "sandbar_session"
|
||||
sessionTTL = 14 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
func (s *Server) countUsers() int {
|
||||
var n int
|
||||
s.st.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
func scanUser(r scanner) (*User, error) {
|
||||
var u User
|
||||
err := r.Scan(&u.ID, &u.Name, &u.Email, &u.hash, &u.Role, &u.Phone, &u.Created)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
u.Topic = userTopic(u.ID)
|
||||
return &u, err
|
||||
}
|
||||
|
||||
const userCols = `id,name,email,hash,role,phone,created`
|
||||
|
||||
func (s *Server) userByID(id string) (*User, error) {
|
||||
return scanUser(s.st.db.QueryRow(`SELECT `+userCols+` FROM users WHERE id=?`, id))
|
||||
}
|
||||
|
||||
func (s *Server) userByEmail(email string) (*User, error) {
|
||||
return scanUser(s.st.db.QueryRow(`SELECT `+userCols+` FROM users WHERE email=?`, strings.TrimSpace(email)))
|
||||
}
|
||||
|
||||
func (s *Server) listUsers() []User {
|
||||
out := []User{}
|
||||
rows, err := s.st.db.Query(`SELECT ` + userCols + ` FROM users ORDER BY name`)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
if u, err := scanUser(rows); err == nil {
|
||||
out = append(out, *u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validPassword(p string) error {
|
||||
if len(p) < 10 {
|
||||
return errors.New("use a password of at least 10 characters")
|
||||
}
|
||||
if len(p) > 72 {
|
||||
return errors.New("passwords are limited to 72 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createUser(name, email, password, role, phone string) (*User, error) {
|
||||
name, email = strings.TrimSpace(name), strings.TrimSpace(email)
|
||||
if name == "" || !strings.Contains(email, "@") || strings.ContainsAny(email, " \r\n<>") {
|
||||
return nil, errors.New("a user needs a name and an email address")
|
||||
}
|
||||
if role != "admin" && role != "member" {
|
||||
return nil, errors.New("role must be admin or member")
|
||||
}
|
||||
if err := validPassword(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := &User{ID: newID(), Name: name, Email: email, Role: role, Phone: strings.TrimSpace(phone), Created: s.now().Unix()}
|
||||
u.Topic = userTopic(u.ID)
|
||||
_, err = s.st.db.Exec(`INSERT INTO users(`+userCols+`) VALUES(?,?,?,?,?,?,?)`, u.ID, u.Name, u.Email, string(h), u.Role, u.Phone, u.Created)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return nil, errors.New("that email address is already registered")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Server) setPassword(id, password string) error {
|
||||
if err := validPassword(password); err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.st.db.Exec(`UPDATE users SET hash=? WHERE id=?`, string(h), id)
|
||||
if err == nil {
|
||||
s.st.db.Exec(`DELETE FROM sessions WHERE user_id=?`, id) // a password change signs everyone out
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// throttled limits password guessing per client address: 10 tries per 10 minutes.
|
||||
func (s *Server) throttled(ip string) bool {
|
||||
s.loginMu.Lock()
|
||||
defer s.loginMu.Unlock()
|
||||
cut := s.now().Add(-10 * time.Minute)
|
||||
var keep []time.Time
|
||||
for _, t := range s.loginTry[ip] {
|
||||
if t.After(cut) {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
s.loginTry[ip] = keep
|
||||
return len(keep) >= 10
|
||||
}
|
||||
|
||||
func (s *Server) noteFailure(ip string) {
|
||||
s.loginMu.Lock()
|
||||
s.loginTry[ip] = append(s.loginTry[ip], s.now())
|
||||
s.loginMu.Unlock()
|
||||
}
|
||||
|
||||
var errThrottled = errors.New("too many attempts, try again in a few minutes")
|
||||
|
||||
// login checks credentials and starts a session.
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password string) (*User, error) {
|
||||
u, err := s.checkLogin(r, email, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok := newToken()
|
||||
exp := s.now().Add(sessionTTL)
|
||||
if _, err := s.st.db.Exec(`INSERT INTO sessions(hash,user_id,expires) VALUES(?,?,?)`, hashToken(tok), u.ID, exp.Unix()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: tok, Path: "/", Expires: exp, HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode, Secure: strings.HasPrefix(s.opts.URL, "https://")})
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// checkLogin verifies an email and password, counting failures against the client's address.
|
||||
func (s *Server) checkLogin(r *http.Request, email, password string) (*User, error) {
|
||||
ip := s.realIP(r)
|
||||
if s.throttled(ip) {
|
||||
return nil, errThrottled
|
||||
}
|
||||
u, err := s.userByEmail(email)
|
||||
hash := "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5BWYKTaYBwxwEGLMzRVr4qhCXWM9S" // so a wrong email costs the same time as a wrong password
|
||||
if err == nil {
|
||||
hash = u.hash
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil || err != nil {
|
||||
s.noteFailure(ip)
|
||||
return nil, errors.New("wrong email or password")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
s.st.db.Exec(`DELETE FROM sessions WHERE hash=?`, hashToken(c.Value))
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
||||
}
|
||||
|
||||
// authenticate finds who is calling: a session cookie, or an API key given as a
|
||||
// bearer token or as the password of HTTP basic auth (ntfy clients do the latter).
|
||||
func (s *Server) authenticate(r *http.Request) *User {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
var uid string
|
||||
var exp int64
|
||||
if s.st.db.QueryRow(`SELECT user_id,expires FROM sessions WHERE hash=?`, hashToken(c.Value)).Scan(&uid, &exp) == nil && exp > s.now().Unix() {
|
||||
if u, err := s.userByID(uid); err == nil {
|
||||
return u
|
||||
}
|
||||
}
|
||||
}
|
||||
key := ""
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
key = strings.TrimSpace(h[7:])
|
||||
} else if _, pw, ok := r.BasicAuth(); ok {
|
||||
key = pw
|
||||
}
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
var uid string
|
||||
if s.st.db.QueryRow(`SELECT user_id FROM apikeys WHERE hash=?`, hashToken(key)).Scan(&uid) == nil {
|
||||
if u, err := s.userByID(uid); err == nil {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func userFrom(ctx context.Context) *User {
|
||||
u, _ := ctx.Value(ctxKey{}).(*User)
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *Server) createAPIKey(userID, name string) (id, token string, err error) {
|
||||
token = "sb_" + newToken()
|
||||
id = newID()
|
||||
_, err = s.st.db.Exec(`INSERT INTO apikeys(id,name,hash,user_id,created) VALUES(?,?,?,?,?)`, id, strings.TrimSpace(name), hashToken(token), userID, s.now().Unix())
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Every check lands in a per-day count that outlives the raw checks, and the status page
|
||||
// gets one [checks, failed] pair per day, oldest first and ending today.
|
||||
func TestUptimeBars(t *testing.T) {
|
||||
s, clk, _ := newTestServer(t)
|
||||
m := &Monitor{ID: "m", Name: "Site", Kind: "http", Target: "http://x", Enabled: true, Public: true, Retries: 1}
|
||||
s.st.put("monitor", m.ID, m)
|
||||
|
||||
// Two days ago: 4 checks, 1 failed. Yesterday: nothing. Today: 2 checks, both fine.
|
||||
clk.Advance(-48 * time.Hour)
|
||||
for i := 0; i < 4; i++ {
|
||||
var err error
|
||||
if i == 3 {
|
||||
err = errors.New("timeout")
|
||||
}
|
||||
s.recordResult(m, s.monitorState(m.ID), clk.Now(), 10*time.Millisecond, err)
|
||||
}
|
||||
clk.Advance(48 * time.Hour)
|
||||
s.recordResult(m, s.monitorState(m.ID), clk.Now(), 10*time.Millisecond, nil)
|
||||
s.recordResult(m, s.monitorState(m.ID), clk.Now(), 10*time.Millisecond, nil)
|
||||
|
||||
days := s.dailyUptime(m.ID, 5, clk.Now())
|
||||
want := [][2]int{{0, 0}, {0, 0}, {4, 1}, {0, 0}, {2, 0}}
|
||||
for i := range want {
|
||||
if days[i] != want[i] {
|
||||
t.Fatalf("days = %v, want %v", days, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The rollup survives pruning the raw checks.
|
||||
s.st.prune(clk.Now().AddDate(0, 0, 60), 30, 1, 365)
|
||||
if got := s.dailyUptime(m.ID, 5, clk.Now()); got[2] != [2]int{4, 1} {
|
||||
t.Fatalf("rollup lost after pruning: %v", got)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
defer ts.Close()
|
||||
resp, err := http.Get(ts.URL + "/status.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
Today string
|
||||
Monitors []struct {
|
||||
Uptime float64
|
||||
Days [][2]int
|
||||
}
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
if out.Today != "2026-01-05" || len(out.Monitors) != 1 || len(out.Monitors[0].Days) != uptimeDays {
|
||||
t.Fatalf("status.json: %+v", out)
|
||||
}
|
||||
m0 := out.Monitors[0]
|
||||
if last := m0.Days[uptimeDays-1]; last != [2]int{2, 0} {
|
||||
t.Fatalf("today's bar: %v", last)
|
||||
}
|
||||
if want := 100 * 5.0 / 6.0; m0.Uptime < want-0.01 || m0.Uptime > want+0.01 {
|
||||
t.Fatalf("uptime %.2f, want %.2f", m0.Uptime, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A database from before the daily table gets it built from the checks it has.
|
||||
func TestDailyBackfill(t *testing.T) {
|
||||
s, clk, _ := newTestServer(t)
|
||||
now := clk.Now().Unix()
|
||||
s.st.db.Exec(`INSERT INTO checks(monitor_id,time,ok,ms,detail) VALUES('m',?,1,5,''),('m',?,0,5,'x')`, now, now+1)
|
||||
s.st.db.Exec(`DELETE FROM daily`)
|
||||
var have int
|
||||
s.st.db.QueryRow(`SELECT count(*) FROM daily`).Scan(&have)
|
||||
if have != 0 {
|
||||
t.Fatal("setup")
|
||||
}
|
||||
s.st.backfillDaily()
|
||||
if got := s.dailyUptime("m", 1, clk.Now()); got[0] != [2]int{2, 1} {
|
||||
t.Fatalf("backfill: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Incidents are the human side: something is wrong and people are working on it.
|
||||
// An incident has a severity, a status that moves forward, a lead and a comms
|
||||
// owner, a timeline of everything that happened, and a post-mortem. Every change
|
||||
// is announced on the "incidents" topic and the broadcast channels.
|
||||
|
||||
type Incident struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Severity string `json:"severity"` // sev1 (worst) to sev3
|
||||
Status string `json:"status"` // investigating, identified, monitoring, resolved
|
||||
Lead string `json:"lead"` // user id
|
||||
Comms string `json:"comms"` // user id
|
||||
Summary string `json:"summary"`
|
||||
Postmortem string `json:"postmortem"`
|
||||
Public bool `json:"public"` // shown on the status page
|
||||
Components []string `json:"components"` // monitor ids the status page says are affected
|
||||
Created int64 `json:"created"`
|
||||
Resolved int64 `json:"resolved"`
|
||||
AlertIDs []string `json:"alertIds"`
|
||||
Timeline []Event `json:"timeline,omitempty"`
|
||||
}
|
||||
|
||||
var incidentStatuses = []string{"investigating", "identified", "monitoring", "resolved"}
|
||||
|
||||
func validStatus(s string) bool {
|
||||
for _, v := range incidentStatuses {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validSeverity(s string) bool { return s == "sev1" || s == "sev2" || s == "sev3" }
|
||||
|
||||
func (s *Server) userName(id string) string {
|
||||
if u, err := s.userByID(id); err == nil {
|
||||
return u.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) announceIncident(in *Incident, status, text string) {
|
||||
s.broadcast(Note{Status: status, Kind: "incident", Title: in.Title, Message: fmt.Sprintf("[%s] %s: %s", strings.ToUpper(in.Severity), in.Status, text),
|
||||
Severity: map[string]string{"sev1": "critical", "sev2": "warning"}[in.Severity], Link: s.link("/#incidents"), Time: s.now().Unix()}, "incidents")
|
||||
}
|
||||
|
||||
// DeclareIncident opens an incident, optionally linking the alerts that caused it.
|
||||
func (s *Server) DeclareIncident(title, severity, summary, lead, actor string, alertIDs []string) (*Incident, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return nil, errors.New("give the incident a title")
|
||||
}
|
||||
if severity == "" {
|
||||
severity = "sev2"
|
||||
}
|
||||
if !validSeverity(severity) {
|
||||
return nil, errors.New("severity must be sev1, sev2 or sev3")
|
||||
}
|
||||
now := s.now()
|
||||
in := &Incident{ID: newID(), Title: title, Severity: severity, Status: "investigating", Lead: lead, Summary: summary, Created: now.Unix(), AlertIDs: []string{}, Components: []string{}}
|
||||
for _, id := range alertIDs {
|
||||
if a, err := s.getAlert(id); err == nil {
|
||||
in.AlertIDs = append(in.AlertIDs, a.ID)
|
||||
s.st.db.Exec(`UPDATE alerts SET incident_id=? WHERE id=?`, in.ID, a.ID)
|
||||
s.st.addEvent("alert", a.ID, "incident", actor, "linked to incident "+title, now)
|
||||
}
|
||||
}
|
||||
if err := s.st.put("incident", in.ID, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.st.addEvent("incident", in.ID, "declared", actor, fmt.Sprintf("declared %s: %s", severity, title), now)
|
||||
if lead != "" {
|
||||
s.st.addEvent("incident", in.ID, "role", actor, "lead: "+s.userName(lead), now)
|
||||
}
|
||||
s.announceIncident(in, "declared", firstNonEmpty(summary, "incident declared"))
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(v ...string) string {
|
||||
for _, s := range v {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IncidentPatch holds the fields an update may change; nil means unchanged.
|
||||
type IncidentPatch struct {
|
||||
Title *string `json:"title"`
|
||||
Severity *string `json:"severity"`
|
||||
Status *string `json:"status"`
|
||||
Lead *string `json:"lead"`
|
||||
Comms *string `json:"comms"`
|
||||
Summary *string `json:"summary"`
|
||||
Postmortem *string `json:"postmortem"`
|
||||
Public *bool `json:"public"`
|
||||
Components *[]string `json:"components"`
|
||||
Note string `json:"note"` // text for the update announcement and timeline
|
||||
}
|
||||
|
||||
func (s *Server) UpdateIncident(id, actor string, p IncidentPatch) (*Incident, error) {
|
||||
in, err := getOf[Incident](s.st, "incident", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.now()
|
||||
prevStatus := in.Status
|
||||
var changes []string
|
||||
if p.Severity != nil && *p.Severity != in.Severity {
|
||||
if !validSeverity(*p.Severity) {
|
||||
return nil, errors.New("severity must be sev1, sev2 or sev3")
|
||||
}
|
||||
changes = append(changes, "severity "+in.Severity+" to "+*p.Severity)
|
||||
in.Severity = *p.Severity
|
||||
}
|
||||
if p.Status != nil && *p.Status != in.Status {
|
||||
if !validStatus(*p.Status) {
|
||||
return nil, errors.New("status must be investigating, identified, monitoring or resolved")
|
||||
}
|
||||
changes = append(changes, "status "+in.Status+" to "+*p.Status)
|
||||
in.Status = *p.Status
|
||||
if in.Status == "resolved" {
|
||||
in.Resolved = now.Unix()
|
||||
} else {
|
||||
in.Resolved = 0
|
||||
}
|
||||
}
|
||||
if p.Lead != nil && *p.Lead != in.Lead {
|
||||
in.Lead = *p.Lead
|
||||
changes = append(changes, "lead: "+s.userName(in.Lead))
|
||||
}
|
||||
if p.Comms != nil && *p.Comms != in.Comms {
|
||||
in.Comms = *p.Comms
|
||||
changes = append(changes, "comms: "+s.userName(in.Comms))
|
||||
}
|
||||
if p.Title != nil && strings.TrimSpace(*p.Title) != "" && *p.Title != in.Title {
|
||||
in.Title = strings.TrimSpace(*p.Title)
|
||||
changes = append(changes, "renamed")
|
||||
}
|
||||
if p.Summary != nil {
|
||||
in.Summary = *p.Summary
|
||||
}
|
||||
if p.Postmortem != nil {
|
||||
in.Postmortem = *p.Postmortem
|
||||
}
|
||||
wasPublic, statusChanged := in.Public, in.Status != prevStatus
|
||||
if p.Public != nil {
|
||||
in.Public = *p.Public
|
||||
}
|
||||
if p.Components != nil {
|
||||
in.Components = cleanIDs(*p.Components)
|
||||
}
|
||||
if err := s.st.put("incident", in.ID, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.postToStatusPage(in, wasPublic, statusChanged, p.Note, now)
|
||||
if len(changes) > 0 || p.Note != "" {
|
||||
text := strings.Join(changes, "; ")
|
||||
if p.Note != "" {
|
||||
text = strings.TrimSpace(text + ": " + p.Note)
|
||||
}
|
||||
s.st.addEvent("incident", in.ID, "update", actor, text, now)
|
||||
kind := "update"
|
||||
if in.Status == "resolved" {
|
||||
kind = "resolved"
|
||||
}
|
||||
s.announceIncident(in, kind, text)
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func (s *Server) CommentIncident(id, actor, text string) error {
|
||||
if _, err := getOf[Incident](s.st, "incident", id); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return errors.New("write something")
|
||||
}
|
||||
s.st.addEvent("incident", id, "comment", actor, text, s.now())
|
||||
return nil
|
||||
}
|
||||
|
||||
// postmortemTemplate is a starting point, filled with what the timeline already knows.
|
||||
func (s *Server) postmortemTemplate(in *Incident) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Post-mortem: %s\n\n", in.Title)
|
||||
fmt.Fprintf(&b, "Severity: %s\nStarted: %s\n", in.Severity, time.Unix(in.Created, 0).UTC().Format(time.RFC1123))
|
||||
if in.Resolved > 0 {
|
||||
fmt.Fprintf(&b, "Resolved: %s (%s)\n", time.Unix(in.Resolved, 0).UTC().Format(time.RFC1123), time.Duration(in.Resolved-in.Created)*time.Second)
|
||||
}
|
||||
b.WriteString("\n## Summary\n\n\n## Impact\n\n\n## Root cause\n\n\n## Timeline\n\n")
|
||||
for _, e := range s.st.events("incident", in.ID) {
|
||||
fmt.Fprintf(&b, "- %s %s\n", time.Unix(e.Time, 0).UTC().Format("15:04:05"), e.Text)
|
||||
}
|
||||
b.WriteString("\n## What went well\n\n\n## What went badly\n\n\n## Action items\n\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The mobile companion is a PWA that lives in its own repository (remora). Sandbar can
|
||||
// serve it at /app/ (AppDir), or the app can be hosted elsewhere and allowed to call the API
|
||||
// (AppOrigin). Either way it signs in with mobileLogin, which returns an API key instead of a cookie.
|
||||
|
||||
// appRoutes serves the companion app and answers cross-origin calls from AppOrigin. It runs
|
||||
// before the router because /app/... would otherwise clash with the ntfy-style /{topic}/... routes.
|
||||
func (s *Server) appRoutes(next http.Handler) http.Handler {
|
||||
var files http.Handler
|
||||
if s.opts.AppDir != "" {
|
||||
files = http.StripPrefix("/app/", http.FileServer(http.Dir(s.opts.AppDir)))
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if o := r.Header.Get("Origin"); o != "" && s.opts.AppOrigin != "" && o == s.opts.AppOrigin && strings.HasPrefix(r.URL.Path, "/api/v1/") {
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", o)
|
||||
h.Set("Vary", "Origin")
|
||||
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
h.Set("Access-Control-Max-Age", "600")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
}
|
||||
}
|
||||
if files != nil && (r.URL.Path == "/app" || strings.HasPrefix(r.URL.Path, "/app/")) {
|
||||
if r.URL.Path == "/app" {
|
||||
http.Redirect(w, r, "/app/", http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
if path.Base(r.URL.Path) == "sw.js" || r.URL.Path == "/app/" {
|
||||
w.Header().Set("Cache-Control", "no-cache") // a new service worker must be noticed at once
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
// The app may be pointed at a Sandbar on another address, so connect-src is open; scripts stay local.
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src *; manifest-src 'self'; worker-src 'self'")
|
||||
files.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// mobileLogin signs a phone in. It returns a new API key named after the device, which the app
|
||||
// sends as a bearer token, so no cookie crosses origins and signing out of one phone (deleting
|
||||
// its key) leaves the others alone.
|
||||
func (s *Server) mobileLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Email, Password, Device string
|
||||
}
|
||||
if !decode(r, &in) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
u, err := s.checkLogin(r, in.Email, in.Password)
|
||||
if err != nil {
|
||||
code := 401
|
||||
if errors.Is(err, errThrottled) {
|
||||
code = 429
|
||||
}
|
||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
device := truncate(strings.TrimSpace(in.Device), 60)
|
||||
if device == "" {
|
||||
device = "phone"
|
||||
}
|
||||
id, tok, err := s.createAPIKey(u.ID, "Mobile: "+device)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"token": tok, "keyId": id, "user": u})
|
||||
}
|
||||
|
||||
// hammerheadEdges shows the edges and tunnel state of a linked Hammerhead, read-only. Hammerhead
|
||||
// lists edges only for signed-in people, so Sandbar signs in with the configured account and keeps
|
||||
// the session. Edge tokens and tunnel secrets are dropped before anything reaches the phone.
|
||||
func (s *Server) hammerheadEdges(w http.ResponseWriter, r *http.Request) {
|
||||
if s.opts.HammerheadURL == "" {
|
||||
writeJSON(w, 200, map[string]any{"configured": false, "edges": []any{}})
|
||||
return
|
||||
}
|
||||
type edge struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Online bool `json:"online"`
|
||||
IPs []string `json:"ips"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
var edges []edge
|
||||
for attempt := 0; ; attempt++ {
|
||||
code, body, err := s.hammerheadGet("/api/edges", attempt > 0)
|
||||
if err != nil {
|
||||
writeJSON(w, 502, map[string]string{"error": "Could not reach Hammerhead: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if code == 401 && attempt == 0 {
|
||||
continue // the session expired: sign in again once
|
||||
}
|
||||
if code != 200 {
|
||||
writeJSON(w, 502, map[string]string{"error": "Hammerhead answered " + http.StatusText(code) + ". Check SANDBAR_HAMMERHEAD_EMAIL and SANDBAR_HAMMERHEAD_PASSWORD, and that the account has no two-factor sign-in."})
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(body, &edges); err != nil {
|
||||
writeJSON(w, 502, map[string]string{"error": "Hammerhead sent something unexpected"})
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
if edges == nil {
|
||||
edges = []edge{}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"configured": true, "edges": edges})
|
||||
}
|
||||
|
||||
// hammerheadGet reads one path from Hammerhead, signing in first when there is no session (or relogin is set).
|
||||
func (s *Server) hammerheadGet(p string, relogin bool) (int, []byte, error) {
|
||||
s.hhMu.Lock()
|
||||
defer s.hhMu.Unlock()
|
||||
base := strings.TrimRight(s.opts.HammerheadURL, "/")
|
||||
if s.hhClient == nil || relogin {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
c := *s.httpClient(true) // an admin chose this address, and Hammerhead is often on a private network
|
||||
c.Jar = jar
|
||||
c.Timeout = 10 * time.Second
|
||||
body, _ := json.Marshal(map[string]string{"email": s.opts.HammerheadEmail, "password": s.opts.HammerheadPassword})
|
||||
resp, err := c.Post(base+"/api/login", "application/json", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return resp.StatusCode, nil, nil
|
||||
}
|
||||
s.hhClient = &c
|
||||
}
|
||||
resp, err := s.hhClient.Get(base + p)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return resp.StatusCode, b, err
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/netguard"
|
||||
)
|
||||
|
||||
// Monitors check that things are up. A check runs every interval; the monitor
|
||||
// goes down after Retries failures in a row (so a blip is ignored) and raises an
|
||||
// alert, and recovers on the first success. Heartbeat monitors work the other
|
||||
// way round: your cron job or service calls /hb/<token>, and the monitor goes
|
||||
// down when the calls stop.
|
||||
|
||||
type Monitor struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // http, tcp, dns, tls, heartbeat
|
||||
Target string `json:"target"` // URL, host:port, hostname, or host[:port] for tls
|
||||
IntervalS int `json:"intervalS"`
|
||||
TimeoutS int `json:"timeoutS"`
|
||||
Retries int `json:"retries"` // failures in a row before it is down (default 2)
|
||||
Expect string `json:"expect"` // http: accepted status, e.g. "200-299" (default)
|
||||
Keyword string `json:"keyword"` // http: text the body must contain
|
||||
WarnDays int `json:"warnDays"` // tls: fail when the certificate expires within this many days (default 14)
|
||||
GraceS int `json:"graceS"` // heartbeat: extra time allowed past the interval
|
||||
Token string `json:"token"` // heartbeat: the secret in the ping URL
|
||||
PolicyID string `json:"policyId"` // escalation policy for the alert it raises
|
||||
Severity string `json:"severity"` // alert severity (default critical)
|
||||
Public bool `json:"public"` // shown on the status page
|
||||
Group string `json:"group"` // status page component group id; empty for none
|
||||
PublicName string `json:"publicName"` // name on the status page; empty to use Name
|
||||
AllowPrivate bool `json:"allowPrivate"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// MonitorState is what we know about a monitor right now.
|
||||
type MonitorState struct {
|
||||
MonitorID string `json:"monitorId"`
|
||||
Status string `json:"status"` // pending, up, down
|
||||
Since int64 `json:"since"`
|
||||
LastCheck int64 `json:"lastCheck"`
|
||||
LastMS int `json:"lastMs"`
|
||||
Fails int `json:"fails"`
|
||||
LastPing int64 `json:"lastPing"`
|
||||
Detail string `json:"detail"`
|
||||
Uptime30 float64 `json:"uptime30"` // percent of checks that passed in the retention window; -1 if none
|
||||
}
|
||||
|
||||
// interval is how often the monitor is checked. A heartbeat is evaluated often so
|
||||
// a missed one is noticed soon after its deadline, not up to a whole interval later.
|
||||
func (m *Monitor) interval() time.Duration {
|
||||
if m.Kind == "heartbeat" {
|
||||
return 15 * time.Second
|
||||
}
|
||||
if m.IntervalS < 10 {
|
||||
return time.Minute
|
||||
}
|
||||
return time.Duration(m.IntervalS) * time.Second
|
||||
}
|
||||
|
||||
func (m *Monitor) timeout() time.Duration {
|
||||
if m.TimeoutS <= 0 || m.TimeoutS > 120 {
|
||||
return 10 * time.Second
|
||||
}
|
||||
return time.Duration(m.TimeoutS) * time.Second
|
||||
}
|
||||
|
||||
func (m *Monitor) retries() int {
|
||||
if m.Retries < 1 {
|
||||
return 2
|
||||
}
|
||||
return m.Retries
|
||||
}
|
||||
|
||||
func validateMonitor(m *Monitor) error {
|
||||
m.Name, m.Target = strings.TrimSpace(m.Name), strings.TrimSpace(m.Target)
|
||||
if m.Name == "" {
|
||||
return errors.New("give the monitor a name")
|
||||
}
|
||||
switch m.Kind {
|
||||
case "http":
|
||||
u, err := url.Parse(m.Target)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return errors.New("an http monitor needs an http:// or https:// URL")
|
||||
}
|
||||
case "tcp":
|
||||
if _, _, err := net.SplitHostPort(m.Target); err != nil {
|
||||
return errors.New("a tcp monitor needs host:port")
|
||||
}
|
||||
case "dns", "tls":
|
||||
if m.Target == "" || strings.ContainsAny(m.Target, " /\\") {
|
||||
return errors.New("enter a hostname")
|
||||
}
|
||||
case "heartbeat":
|
||||
if m.IntervalS < 10 {
|
||||
return errors.New("a heartbeat interval is at least 10 seconds")
|
||||
}
|
||||
if m.Token == "" {
|
||||
m.Token = newToken()
|
||||
}
|
||||
default:
|
||||
return errors.New("kind must be http, tcp, dns, tls or heartbeat")
|
||||
}
|
||||
if m.Severity == "" {
|
||||
m.Severity = "critical"
|
||||
}
|
||||
if _, _, err := parseRange(m.Expect); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseRange reads "200-299", "204" or "" (= 200-299).
|
||||
func parseRange(s string) (lo, hi int, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 200, 299, nil
|
||||
}
|
||||
a, b, found := strings.Cut(s, "-")
|
||||
if lo, err = strconv.Atoi(strings.TrimSpace(a)); err != nil {
|
||||
return 0, 0, errors.New(`expected status must look like "200-299" or "204"`)
|
||||
}
|
||||
hi = lo
|
||||
if found {
|
||||
if hi, err = strconv.Atoi(strings.TrimSpace(b)); err != nil || hi < lo {
|
||||
return 0, 0, errors.New(`expected status must look like "200-299" or "204"`)
|
||||
}
|
||||
}
|
||||
return lo, hi, nil
|
||||
}
|
||||
|
||||
// ---------- the checks ----------
|
||||
|
||||
// runCheck performs one probe and returns how long it took and an error if it failed.
|
||||
func (s *Server) runCheck(ctx context.Context, m *Monitor) (time.Duration, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, m.timeout())
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
var err error
|
||||
switch m.Kind {
|
||||
case "http":
|
||||
err = s.checkHTTP(ctx, m)
|
||||
case "tcp":
|
||||
var c net.Conn
|
||||
if c, err = netguard.DialContext(m.AllowPrivate)(ctx, "tcp", m.Target); err == nil {
|
||||
c.Close()
|
||||
}
|
||||
case "dns":
|
||||
var addrs []string
|
||||
if addrs, err = net.DefaultResolver.LookupHost(ctx, m.Target); err == nil && len(addrs) == 0 {
|
||||
err = errors.New("no addresses")
|
||||
}
|
||||
case "tls":
|
||||
err = checkTLS(ctx, m, s.now())
|
||||
}
|
||||
return time.Since(start), err
|
||||
}
|
||||
|
||||
func (s *Server) checkHTTP(ctx context.Context, m *Monitor) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", m.Target, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Sandbar-Monitor/1")
|
||||
c := *s.httpClient(m.AllowPrivate)
|
||||
c.Timeout = 0 // the context carries the deadline
|
||||
c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
lo, hi, _ := parseRange(m.Expect)
|
||||
if resp.StatusCode < lo || resp.StatusCode > hi {
|
||||
return fmt.Errorf("status %d, expected %d-%d", resp.StatusCode, lo, hi)
|
||||
}
|
||||
if m.Keyword != "" {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if !strings.Contains(string(body), m.Keyword) {
|
||||
return fmt.Errorf("the page doesn't contain %q", m.Keyword)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkTLS(ctx context.Context, m *Monitor, now time.Time) error {
|
||||
host, port, err := net.SplitHostPort(m.Target)
|
||||
if err != nil {
|
||||
host, port = m.Target, "443"
|
||||
}
|
||||
d := tls.Dialer{Config: &tls.Config{ServerName: host}}
|
||||
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
|
||||
if len(certs) == 0 {
|
||||
return errors.New("no certificate")
|
||||
}
|
||||
warn := m.WarnDays
|
||||
if warn <= 0 {
|
||||
warn = 14
|
||||
}
|
||||
left := certs[0].NotAfter.Sub(now)
|
||||
if left < time.Duration(warn)*24*time.Hour {
|
||||
return fmt.Errorf("the certificate expires in %d days (%s)", int(left.Hours()/24), certs[0].NotAfter.Format("2006-01-02"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- scheduling and state ----------
|
||||
|
||||
func (s *Server) monitorState(id string) MonitorState {
|
||||
st := MonitorState{MonitorID: id, Status: "pending", Uptime30: -1}
|
||||
s.st.db.QueryRow(`SELECT status,since,last_check,last_ms,fails,last_ping,detail FROM mstate WHERE monitor_id=?`, id).
|
||||
Scan(&st.Status, &st.Since, &st.LastCheck, &st.LastMS, &st.Fails, &st.LastPing, &st.Detail)
|
||||
var total, ok sql.NullInt64
|
||||
s.st.db.QueryRow(`SELECT COUNT(*), SUM(ok) FROM checks WHERE monitor_id=?`, id).Scan(&total, &ok)
|
||||
if total.Int64 > 0 {
|
||||
st.Uptime30 = float64(ok.Int64) * 100 / float64(total.Int64)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// uptimeDays is how many days of bars the status page shows.
|
||||
const uptimeDays = 90
|
||||
|
||||
// dailyUptime returns [checks, failed] for each of the last n UTC days, oldest first, so the
|
||||
// last entry is today. A day with no checks is [0, 0].
|
||||
func (s *Server) dailyUptime(monitorID string, n int, now time.Time) [][2]int {
|
||||
out := make([][2]int, n)
|
||||
today := now.UTC().Truncate(24 * time.Hour)
|
||||
rows, err := s.st.db.Query(`SELECT day,checks,fails FROM daily WHERE monitor_id=? AND day>=?`,
|
||||
monitorID, today.AddDate(0, 0, -(n-1)).Format("2006-01-02"))
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var day string
|
||||
var c, f int
|
||||
if rows.Scan(&day, &c, &f) != nil {
|
||||
continue
|
||||
}
|
||||
d, err := time.Parse("2006-01-02", day)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if i := n - 1 - int(today.Sub(d).Hours()/24); i >= 0 && i < n {
|
||||
out[i] = [2]int{c, f}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) saveState(st MonitorState) {
|
||||
s.st.db.Exec(`INSERT INTO mstate(monitor_id,status,since,last_check,last_ms,fails,last_ping,detail) VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(monitor_id) DO UPDATE SET status=excluded.status, since=excluded.since, last_check=excluded.last_check,
|
||||
last_ms=excluded.last_ms, fails=excluded.fails, last_ping=excluded.last_ping, detail=excluded.detail`,
|
||||
st.MonitorID, st.Status, st.Since, st.LastCheck, st.LastMS, st.Fails, st.LastPing, st.Detail)
|
||||
}
|
||||
|
||||
// runDueMonitors starts a check for every enabled monitor whose interval has elapsed.
|
||||
func (s *Server) runDueMonitors(now time.Time) {
|
||||
mons, err := listOf[Monitor](s.st, "monitor")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for i := range mons {
|
||||
m := mons[i]
|
||||
if !m.Enabled {
|
||||
continue
|
||||
}
|
||||
st := s.monitorState(m.ID)
|
||||
if now.Sub(time.Unix(st.LastCheck, 0)) < m.interval() {
|
||||
continue
|
||||
}
|
||||
s.mu.Lock()
|
||||
busy := s.running[m.ID]
|
||||
s.running[m.ID] = true
|
||||
s.mu.Unlock()
|
||||
if busy {
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.running, m.ID)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
s.checkMonitor(&m, now)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// checkMonitor runs one probe (or evaluates a heartbeat) and moves state forward.
|
||||
func (s *Server) checkMonitor(m *Monitor, now time.Time) {
|
||||
st := s.monitorState(m.ID)
|
||||
var dur time.Duration
|
||||
var err error
|
||||
if m.Kind == "heartbeat" {
|
||||
if st.LastPing == 0 {
|
||||
// Never pinged: give it a full interval from when we first looked.
|
||||
if st.LastCheck == 0 {
|
||||
st.LastCheck, st.Status = now.Unix(), "pending"
|
||||
st.Since = now.Unix()
|
||||
s.saveState(st)
|
||||
return
|
||||
}
|
||||
if now.Unix()-st.Since < int64(m.IntervalS+m.GraceS) {
|
||||
st.LastCheck = now.Unix() // still within the first interval: nothing to judge yet
|
||||
s.saveState(st)
|
||||
return
|
||||
}
|
||||
err = errors.New("no heartbeat received yet")
|
||||
} else if now.Unix()-st.LastPing > int64(m.IntervalS+m.GraceS) {
|
||||
err = fmt.Errorf("no heartbeat for %s", now.Sub(time.Unix(st.LastPing, 0)).Round(time.Second))
|
||||
}
|
||||
} else {
|
||||
dur, err = s.runCheck(context.Background(), m)
|
||||
}
|
||||
s.recordResult(m, st, now, dur, err)
|
||||
}
|
||||
|
||||
func (s *Server) recordResult(m *Monitor, st MonitorState, now time.Time, dur time.Duration, err error) {
|
||||
ok := err == nil
|
||||
okInt := 0
|
||||
if ok {
|
||||
okInt = 1
|
||||
}
|
||||
s.st.db.Exec(`INSERT INTO checks(monitor_id,time,ok,ms,detail) VALUES(?,?,?,?,?)`, m.ID, now.Unix(), okInt, dur.Milliseconds(), errText(err))
|
||||
s.st.db.Exec(`INSERT INTO daily(monitor_id,day,checks,fails) VALUES(?,?,1,?)
|
||||
ON CONFLICT(monitor_id,day) DO UPDATE SET checks=checks+1, fails=fails+excluded.fails`, m.ID, now.UTC().Format("2006-01-02"), 1-okInt)
|
||||
st.LastCheck, st.LastMS, st.Detail = now.Unix(), int(dur.Milliseconds()), errText(err)
|
||||
key := "monitor:" + m.ID
|
||||
if ok {
|
||||
wasDown := st.Status == "down"
|
||||
if st.Status != "up" {
|
||||
st.Status, st.Since = "up", now.Unix()
|
||||
}
|
||||
st.Fails = 0
|
||||
s.saveState(st)
|
||||
if wasDown {
|
||||
s.ResolveAlertKey(key, "monitor")
|
||||
}
|
||||
return
|
||||
}
|
||||
st.Fails++
|
||||
if st.Fails >= m.retries() && st.Status != "down" {
|
||||
st.Status, st.Since = "down", now.Unix()
|
||||
}
|
||||
s.saveState(st)
|
||||
if st.Status == "down" {
|
||||
s.RaiseAlert(key, "monitor", m.Name+" is down", m.Name+" ("+m.Kind+" "+m.Target+"): "+st.Detail, m.Severity, m.PolicyID)
|
||||
}
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// Heartbeat records a ping for the monitor with this token. It returns false if there is none.
|
||||
func (s *Server) Heartbeat(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
mons, _ := listOf[Monitor](s.st, "monitor")
|
||||
for i := range mons {
|
||||
m := mons[i]
|
||||
if m.Kind == "heartbeat" && m.Token == token && m.Enabled {
|
||||
st := s.monitorState(m.ID)
|
||||
st.LastPing = s.now().Unix()
|
||||
if st.Since == 0 {
|
||||
st.Since = st.LastPing
|
||||
}
|
||||
s.saveState(st)
|
||||
// A ping on a down monitor recovers it at once rather than at the next check.
|
||||
if st.Status == "down" {
|
||||
s.recordResult(&m, st, s.now(), 0, nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// deleteMonitorData drops what we keep about a monitor and closes its alert.
|
||||
func (s *Server) deleteMonitorData(id string) {
|
||||
s.st.db.Exec(`DELETE FROM mstate WHERE monitor_id=?`, id)
|
||||
s.st.db.Exec(`DELETE FROM checks WHERE monitor_id=?`, id)
|
||||
s.st.db.Exec(`DELETE FROM daily WHERE monitor_id=?`, id)
|
||||
s.ResolveAlertKey("monitor:"+id, "monitor deleted")
|
||||
}
|
||||
|
||||
func (s *Server) recentChecks(id string, limit int) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
rows, err := s.st.db.Query(`SELECT time,ok,ms,detail FROM checks WHERE monitor_id=? ORDER BY time DESC LIMIT ?`, id, limit)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var t, ok, ms int64
|
||||
var d string
|
||||
if rows.Scan(&t, &ok, &ms, &d) == nil {
|
||||
out = append(out, map[string]any{"time": t, "ok": ok == 1, "ms": ms, "detail": d})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/netguard"
|
||||
)
|
||||
|
||||
// Notify is the ntfy-shaped part: anything can publish a message to a topic with
|
||||
// an HTTP request, and anything can subscribe to it (SSE, newline-delimited JSON,
|
||||
// or the web UI). Alerts and incidents publish here too, so a phone with the ntfy
|
||||
// app subscribed to "alerts" is a working pager with no other setup.
|
||||
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
Time int64 `json:"time"`
|
||||
Event string `json:"event"` // always "message"; the ntfy apps ignore lines without it
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Alert string `json:"alert,omitempty"` // id of the on-call alert this is about; only on live delivery and push, not stored
|
||||
}
|
||||
|
||||
var topicRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
// reservedTopics are first path segments that belong to Sandbar itself.
|
||||
var reservedTopics = map[string]bool{"api": true, "status": true, "hb": true, "metrics": true, "static": true, "healthz": true}
|
||||
|
||||
func validTopic(t string) bool { return topicRe.MatchString(t) && !reservedTopics[t] }
|
||||
|
||||
// hub fans published messages out to live subscribers.
|
||||
type hub struct {
|
||||
mu sync.Mutex
|
||||
subs map[string]map[chan Message]bool
|
||||
}
|
||||
|
||||
func newHub() *hub { return &hub{subs: map[string]map[chan Message]bool{}} }
|
||||
|
||||
func (h *hub) subscribe(topic string) (chan Message, func()) {
|
||||
ch := make(chan Message, 64)
|
||||
h.mu.Lock()
|
||||
if h.subs[topic] == nil {
|
||||
h.subs[topic] = map[chan Message]bool{}
|
||||
}
|
||||
h.subs[topic][ch] = true
|
||||
h.mu.Unlock()
|
||||
return ch, func() {
|
||||
h.mu.Lock()
|
||||
delete(h.subs[topic], ch)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *hub) publish(m Message) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for ch := range h.subs[m.Topic] {
|
||||
select {
|
||||
case ch <- m:
|
||||
default: // a stalled subscriber drops messages instead of blocking publishers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parsePriority(s string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "5", "max", "urgent":
|
||||
return 5
|
||||
case "4", "high":
|
||||
return 4
|
||||
case "2", "low":
|
||||
return 2
|
||||
case "1", "min":
|
||||
return 1
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func parseTags(s string) []string {
|
||||
var out []string
|
||||
for _, t := range strings.Split(s, ",") {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Publish stores a message and delivers it to live subscribers.
|
||||
func (s *Server) Publish(topic, title, body string, prio int, tags []string) (Message, error) {
|
||||
return s.publish(topic, title, body, prio, tags, "", "")
|
||||
}
|
||||
|
||||
func (s *Server) publish(topic, title, body string, prio int, tags []string, alertID, status string) (Message, error) {
|
||||
if !validTopic(topic) {
|
||||
return Message{}, errors.New("a topic is 1-64 letters, digits, - or _")
|
||||
}
|
||||
m := Message{ID: newID(), Topic: topic, Time: s.now().Unix(), Title: title, Message: body, Priority: prio, Tags: tags}
|
||||
tj, _ := json.Marshal(tags)
|
||||
if _, err := s.st.db.Exec(`INSERT INTO messages(id,topic,time,title,body,priority,tags) VALUES(?,?,?,?,?,?,?)`,
|
||||
m.ID, m.Topic, m.Time, m.Title, m.Message, m.Priority, string(tj)); err != nil {
|
||||
return m, err
|
||||
}
|
||||
m.Alert = alertID
|
||||
s.hub.publish(m)
|
||||
s.pushMessage(m, status)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// MessagesSince returns stored messages for a topic after the given unix time, oldest first.
|
||||
func (s *Server) MessagesSince(topic string, since int64, limit int) []Message {
|
||||
out := []Message{}
|
||||
rows, err := s.st.db.Query(`SELECT id,time,title,body,priority,tags FROM messages WHERE topic=? AND time>=? ORDER BY time, rowid LIMIT ?`, topic, since, limit)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
m := Message{Topic: topic}
|
||||
var tj string
|
||||
if rows.Scan(&m.ID, &m.Time, &m.Title, &m.Message, &m.Priority, &tj) == nil {
|
||||
json.Unmarshal([]byte(tj), &m.Tags)
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- outputs: email and webhooks ----------
|
||||
|
||||
// Channel is a place broadcast notifications go: an email address list or a webhook.
|
||||
type Channel struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // "email" or "webhook"
|
||||
Target string `json:"target"` // addresses, or a URL (secret: URLs often carry tokens)
|
||||
Format string `json:"format"` // webhook body: json, slack, discord or ntfy
|
||||
AllowPrivate bool `json:"allowPrivate"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Note is what every notification is built from.
|
||||
type Note struct {
|
||||
Status string `json:"status"` // firing, resolved, acked, test, declared, update
|
||||
Kind string `json:"kind"` // alert, monitor, incident
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Link string `json:"link,omitempty"`
|
||||
Time int64 `json:"time"`
|
||||
Alert string `json:"alert,omitempty"` // the alert's id, for alerts
|
||||
}
|
||||
|
||||
func (n Note) subject() string {
|
||||
switch n.Status {
|
||||
case "resolved":
|
||||
return "[Sandbar] Resolved: " + n.Title
|
||||
case "test":
|
||||
return "[Sandbar] Test notification"
|
||||
case "acked":
|
||||
return "[Sandbar] Acknowledged: " + n.Title
|
||||
}
|
||||
return "[Sandbar] " + strings.ToUpper(n.Kind[:1]) + n.Kind[1:] + ": " + n.Title
|
||||
}
|
||||
|
||||
func (n Note) text() string {
|
||||
if n.Status == "test" {
|
||||
return "This is a test from Sandbar. If you can read this, the channel works."
|
||||
}
|
||||
t := n.Message
|
||||
if n.Link != "" {
|
||||
t += "\n" + n.Link
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (n Note) priority() int {
|
||||
switch {
|
||||
case n.Status == "resolved" || n.Status == "acked" || n.Status == "test":
|
||||
return 3
|
||||
case n.Severity == "critical":
|
||||
return 5
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
func (n Note) tags() []string {
|
||||
switch n.Status {
|
||||
case "resolved":
|
||||
return []string{"white_check_mark"}
|
||||
case "acked":
|
||||
return []string{"eyes"}
|
||||
case "test":
|
||||
return []string{"test_tube"}
|
||||
}
|
||||
return []string{"rotating_light"}
|
||||
}
|
||||
|
||||
func webhookBody(format string, n Note) (body []byte, ctype string, hdr map[string]string) {
|
||||
text := n.subject() + "\n" + n.text()
|
||||
switch format {
|
||||
case "slack":
|
||||
body, _ = json.Marshal(map[string]string{"text": text})
|
||||
case "discord":
|
||||
body, _ = json.Marshal(map[string]string{"content": text})
|
||||
case "ntfy":
|
||||
return []byte(n.text()), "text/plain", map[string]string{
|
||||
"Title": n.subject(), "Priority": strconv.Itoa(n.priority()), "Tags": strings.Join(n.tags(), ","),
|
||||
}
|
||||
default:
|
||||
body, _ = json.Marshal(n)
|
||||
}
|
||||
return body, "application/json", nil
|
||||
}
|
||||
|
||||
func (s *Server) sendChannel(ch Channel, n Note) error {
|
||||
switch ch.Kind {
|
||||
case "email":
|
||||
var errs []error
|
||||
for _, to := range strings.Split(s.st.open(ch.Target), ",") {
|
||||
if to = strings.TrimSpace(to); to != "" {
|
||||
if err := s.Mail(to, n.subject(), n.text()); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
case "webhook":
|
||||
body, ctype, hdr := webhookBody(ch.Format, n)
|
||||
req, err := http.NewRequest("POST", s.st.open(ch.Target), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", ctype)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := s.httpClient(ch.AllowPrivate).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("the webhook answered %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("unknown channel kind")
|
||||
}
|
||||
|
||||
func (s *Server) httpClient(allowPrivate bool) *http.Client {
|
||||
if s.opts.HTTPClient != nil {
|
||||
return s.opts.HTTPClient
|
||||
}
|
||||
c := netguard.Client(allowPrivate)
|
||||
c.Timeout = 15 * time.Second
|
||||
return c
|
||||
}
|
||||
|
||||
// Mail sends one plain-text email through the configured SMTP server.
|
||||
func (s *Server) Mail(to, subject, body string) error {
|
||||
if s.opts.Mailer != nil {
|
||||
return s.opts.Mailer(to, subject, body)
|
||||
}
|
||||
c := s.opts.SMTP
|
||||
if c.Host == "" {
|
||||
return errors.New("no SMTP server configured (SANDBAR_SMTP_HOST)")
|
||||
}
|
||||
if strings.ContainsAny(to+subject, "\r\n") {
|
||||
return errors.New("invalid header")
|
||||
}
|
||||
port := c.Port
|
||||
if port == "" {
|
||||
port = "587"
|
||||
}
|
||||
msg := "From: " + c.From + "\r\nTo: " + to + "\r\nSubject: " + subject +
|
||||
"\r\nDate: " + s.now().Format(time.RFC1123Z) + "\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n" + body + "\r\n"
|
||||
var auth smtp.Auth
|
||||
if c.User != "" {
|
||||
auth = smtp.PlainAuth("", c.User, c.Password, c.Host)
|
||||
}
|
||||
return smtp.SendMail(c.Host+":"+port, auth, c.From, []string{to}, []byte(msg))
|
||||
}
|
||||
|
||||
// broadcast sends a note to the topic named by the source and every enabled channel.
|
||||
func (s *Server) broadcast(n Note, topics ...string) {
|
||||
for _, t := range topics {
|
||||
if _, err := s.publish(t, n.subject(), n.text(), n.priority(), n.tags(), n.Alert, n.Status); err != nil {
|
||||
log.Printf("publish %s: %v", t, err)
|
||||
}
|
||||
}
|
||||
chans, _ := listOf[Channel](s.st, "channel")
|
||||
for _, ch := range chans {
|
||||
if !ch.Enabled {
|
||||
continue
|
||||
}
|
||||
ch := ch
|
||||
go func() {
|
||||
if err := s.sendChannel(ch, n); err != nil {
|
||||
log.Printf("channel %q: %v", ch.Name, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// validateChannel normalises ch.
|
||||
func validateChannel(ch *Channel) error {
|
||||
ch.Name = strings.TrimSpace(ch.Name)
|
||||
ch.Target = strings.TrimSpace(ch.Target)
|
||||
switch ch.Kind {
|
||||
case "email":
|
||||
for _, a := range strings.Split(ch.Target, ",") {
|
||||
if a = strings.TrimSpace(a); !strings.Contains(a, "@") || strings.ContainsAny(a, " \r\n<>") {
|
||||
return errors.New("enter one or more email addresses, separated by commas")
|
||||
}
|
||||
}
|
||||
ch.Format, ch.AllowPrivate = "", false
|
||||
case "webhook":
|
||||
u, err := url.Parse(ch.Target)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return errors.New("the webhook must be an http:// or https:// URL")
|
||||
}
|
||||
switch ch.Format {
|
||||
case "json", "slack", "discord", "ntfy":
|
||||
case "":
|
||||
ch.Format = "json"
|
||||
default:
|
||||
return errors.New("format must be json, slack, discord or ntfy")
|
||||
}
|
||||
default:
|
||||
return errors.New("a channel is an email or a webhook")
|
||||
}
|
||||
if ch.Name == "" {
|
||||
ch.Name = ch.Kind
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// On-call: who is responsible right now, and what happens to an alert nobody has
|
||||
// picked up. An alert follows its escalation policy, a list of steps, each of
|
||||
// which waits a delay and then notifies people and/or whoever is on call for a
|
||||
// schedule. Acknowledging or resolving the alert stops the walk. Alerts are kept
|
||||
// in the database with the time the next step is due, so a restart neither loses
|
||||
// nor repeats them.
|
||||
|
||||
type Override struct {
|
||||
UserID string `json:"userId"`
|
||||
From int64 `json:"from"`
|
||||
To int64 `json:"to"`
|
||||
}
|
||||
|
||||
// Schedule rotates through Members, handing over every RotationHours from Start.
|
||||
type Schedule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Members []string `json:"members"` // user ids, in rotation order
|
||||
RotationHours int `json:"rotationHours"`
|
||||
Start int64 `json:"start"`
|
||||
Overrides []Override `json:"overrides"`
|
||||
}
|
||||
|
||||
// OnCall returns the user id on call at t, or "".
|
||||
func (sc *Schedule) OnCall(t time.Time) string {
|
||||
for i := len(sc.Overrides) - 1; i >= 0; i-- {
|
||||
o := sc.Overrides[i]
|
||||
if t.Unix() >= o.From && t.Unix() < o.To {
|
||||
return o.UserID
|
||||
}
|
||||
}
|
||||
if len(sc.Members) == 0 {
|
||||
return ""
|
||||
}
|
||||
rot := int64(sc.RotationHours) * 3600
|
||||
if rot <= 0 {
|
||||
return sc.Members[0]
|
||||
}
|
||||
elapsed := t.Unix() - sc.Start
|
||||
if elapsed < 0 {
|
||||
return sc.Members[0]
|
||||
}
|
||||
return sc.Members[(elapsed/rot)%int64(len(sc.Members))]
|
||||
}
|
||||
|
||||
type Step struct {
|
||||
DelayMin int `json:"delayMin"` // wait this long after the previous step (0 = at once)
|
||||
Users []string `json:"users"`
|
||||
Schedules []string `json:"schedules"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Steps []Step `json:"steps"`
|
||||
RepeatMin int `json:"repeatMin"` // after the last step, start over this many minutes later (0 = never)
|
||||
}
|
||||
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Source string `json:"source"`
|
||||
Summary string `json:"summary"`
|
||||
Detail string `json:"detail"`
|
||||
Severity string `json:"severity"` // critical, warning, info
|
||||
Status string `json:"status"` // firing, acked, resolved
|
||||
PolicyID string `json:"policyId"`
|
||||
Step int `json:"step"`
|
||||
NextAt int64 `json:"nextAt"`
|
||||
Created int64 `json:"created"`
|
||||
Updated int64 `json:"updated"`
|
||||
Count int `json:"count"`
|
||||
AckedBy string `json:"ackedBy"`
|
||||
AckedAt int64 `json:"ackedAt"`
|
||||
SnoozeUntil int64 `json:"snoozeUntil"`
|
||||
ResolvedAt int64 `json:"resolvedAt"`
|
||||
IncidentID string `json:"incidentId"`
|
||||
Timeline []Event `json:"timeline,omitempty"`
|
||||
}
|
||||
|
||||
const alertCols = `id,key,source,summary,detail,severity,status,policy_id,step,next_at,created,updated,count,acked_by,acked_at,snooze_until,resolved_at,incident_id`
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scanAlert(r scanner) (*Alert, error) {
|
||||
var a Alert
|
||||
err := r.Scan(&a.ID, &a.Key, &a.Source, &a.Summary, &a.Detail, &a.Severity, &a.Status, &a.PolicyID, &a.Step, &a.NextAt,
|
||||
&a.Created, &a.Updated, &a.Count, &a.AckedBy, &a.AckedAt, &a.SnoozeUntil, &a.ResolvedAt, &a.IncidentID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
return &a, err
|
||||
}
|
||||
|
||||
func (s *Server) getAlert(id string) (*Alert, error) {
|
||||
return scanAlert(s.st.db.QueryRow(`SELECT `+alertCols+` FROM alerts WHERE id=?`, id))
|
||||
}
|
||||
|
||||
func (s *Server) listAlerts(status string, limit int) []Alert {
|
||||
q := `SELECT ` + alertCols + ` FROM alerts`
|
||||
var args []any
|
||||
switch status {
|
||||
case "open":
|
||||
q += ` WHERE status IN ('firing','acked')`
|
||||
case "firing", "acked", "resolved":
|
||||
q += ` WHERE status=?`
|
||||
args = append(args, status)
|
||||
}
|
||||
q += ` ORDER BY created DESC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
out := []Alert{}
|
||||
rows, err := s.st.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
if a, err := scanAlert(rows); err == nil {
|
||||
out = append(out, *a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RaiseAlert opens an alert for key, or bumps the open one with the same key
|
||||
// (deduplication). It returns the alert and whether it is new.
|
||||
func (s *Server) RaiseAlert(key, source, summary, detail, severity, policyID string) (*Alert, bool, error) {
|
||||
now := s.now()
|
||||
if severity == "" {
|
||||
severity = "warning"
|
||||
}
|
||||
existing, err := scanAlert(s.st.db.QueryRow(`SELECT `+alertCols+` FROM alerts WHERE key=? AND status IN ('firing','acked') ORDER BY created DESC LIMIT 1`, key))
|
||||
if err == nil {
|
||||
existing.Count++
|
||||
existing.Updated = now.Unix()
|
||||
existing.Summary, existing.Detail = summary, detail
|
||||
_, err := s.st.db.Exec(`UPDATE alerts SET count=?, updated=?, summary=?, detail=? WHERE id=?`, existing.Count, existing.Updated, summary, detail, existing.ID)
|
||||
return existing, false, err
|
||||
}
|
||||
if !errors.Is(err, errNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
a := &Alert{ID: newID(), Key: key, Source: source, Summary: summary, Detail: detail, Severity: severity, Status: "firing",
|
||||
PolicyID: policyID, NextAt: now.Unix(), Created: now.Unix(), Updated: now.Unix(), Count: 1}
|
||||
if policyID == "" {
|
||||
a.NextAt = 0 // nothing to escalate: it is only broadcast
|
||||
}
|
||||
_, err = s.st.db.Exec(`INSERT INTO alerts(`+alertCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
a.ID, a.Key, a.Source, a.Summary, a.Detail, a.Severity, a.Status, a.PolicyID, a.Step, a.NextAt, a.Created, a.Updated, a.Count,
|
||||
a.AckedBy, a.AckedAt, a.SnoozeUntil, a.ResolvedAt, a.IncidentID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
s.st.addEvent("alert", a.ID, "created", source, summary, now)
|
||||
s.broadcast(Note{Status: "firing", Kind: "alert", Title: summary, Message: s.alertText(a), Severity: severity, Link: s.link("/#alerts"), Time: now.Unix(), Alert: a.ID}, "alerts")
|
||||
return a, true, nil
|
||||
}
|
||||
|
||||
func (s *Server) alertText(a *Alert) string {
|
||||
t := strings.ToUpper(a.Severity) + ": " + a.Summary
|
||||
if a.Detail != "" {
|
||||
t += "\n" + a.Detail
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// ResolveAlertKey resolves the open alert for key, if any.
|
||||
func (s *Server) ResolveAlertKey(key, actor string) {
|
||||
var id string
|
||||
if s.st.db.QueryRow(`SELECT id FROM alerts WHERE key=? AND status IN ('firing','acked') LIMIT 1`, key).Scan(&id) == nil {
|
||||
s.ResolveAlert(id, actor)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ResolveAlert(id, actor string) error {
|
||||
a, err := s.getAlert(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Status == "resolved" {
|
||||
return nil
|
||||
}
|
||||
now := s.now()
|
||||
if _, err := s.st.db.Exec(`UPDATE alerts SET status='resolved', next_at=0, resolved_at=?, updated=? WHERE id=?`, now.Unix(), now.Unix(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.st.addEvent("alert", id, "resolved", actor, "resolved", now)
|
||||
s.broadcast(Note{Status: "resolved", Kind: "alert", Title: a.Summary, Message: "Resolved: " + a.Summary, Severity: a.Severity, Link: s.link("/#alerts"), Time: now.Unix(), Alert: a.ID}, "alerts")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) AckAlert(id, actor string) error {
|
||||
a, err := s.getAlert(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Status != "firing" {
|
||||
return errors.New("only a firing alert can be acknowledged")
|
||||
}
|
||||
now := s.now()
|
||||
if _, err := s.st.db.Exec(`UPDATE alerts SET status='acked', next_at=0, acked_by=?, acked_at=?, updated=? WHERE id=?`, actor, now.Unix(), now.Unix(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.st.addEvent("alert", id, "acked", actor, "acknowledged", now)
|
||||
s.broadcast(Note{Status: "acked", Kind: "alert", Title: a.Summary, Message: "Acknowledged by " + actor + ": " + a.Summary, Time: now.Unix(), Alert: a.ID}, "alerts")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SnoozeAlert pauses escalation for d. When it ends the walk resumes at the step it was on.
|
||||
func (s *Server) SnoozeAlert(id, actor string, d time.Duration) error {
|
||||
a, err := s.getAlert(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Status != "firing" {
|
||||
return errors.New("only a firing alert can be snoozed")
|
||||
}
|
||||
until := s.now().Add(d).Unix()
|
||||
if _, err := s.st.db.Exec(`UPDATE alerts SET next_at=?, snooze_until=? WHERE id=?`, until, until, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.st.addEvent("alert", id, "snoozed", actor, "snoozed for "+d.String(), s.now())
|
||||
return nil
|
||||
}
|
||||
|
||||
// EscalateAlert moves a firing alert to its next step now instead of waiting for the delay.
|
||||
func (s *Server) EscalateAlert(id, actor string) error {
|
||||
a, err := s.getAlert(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Status != "firing" {
|
||||
return errors.New("only a firing alert can be escalated")
|
||||
}
|
||||
if a.PolicyID == "" {
|
||||
return errors.New("this alert has no escalation policy")
|
||||
}
|
||||
s.st.addEvent("alert", id, "note", actor, "escalated by hand", s.now())
|
||||
s.runStep(id, s.now())
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- escalation ----------
|
||||
|
||||
// escalate runs every alert step that has come due.
|
||||
func (s *Server) escalate(now time.Time) {
|
||||
rows, err := s.st.db.Query(`SELECT id FROM alerts WHERE status='firing' AND next_at>0 AND next_at<=?`, now.Unix())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, id := range ids {
|
||||
s.runStep(id, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) runStep(id string, now time.Time) {
|
||||
a, err := s.getAlert(id)
|
||||
if err != nil || a.Status != "firing" {
|
||||
return
|
||||
}
|
||||
pol, err := getOf[Policy](s.st, "policy", a.PolicyID)
|
||||
if err != nil || len(pol.Steps) == 0 {
|
||||
s.st.db.Exec(`UPDATE alerts SET next_at=0 WHERE id=?`, id) // policy deleted: stop trying
|
||||
s.st.addEvent("alert", id, "note", "sandbar", "its escalation policy is gone or empty", now)
|
||||
return
|
||||
}
|
||||
if a.Step >= len(pol.Steps) {
|
||||
a.Step = 0
|
||||
}
|
||||
step := pol.Steps[a.Step]
|
||||
var people []string
|
||||
seen := map[string]bool{}
|
||||
add := func(uid string) {
|
||||
if uid != "" && !seen[uid] {
|
||||
seen[uid] = true
|
||||
people = append(people, uid)
|
||||
}
|
||||
}
|
||||
for _, u := range step.Users {
|
||||
add(u)
|
||||
}
|
||||
for _, scID := range step.Schedules {
|
||||
if sc, err := getOf[Schedule](s.st, "schedule", scID); err == nil {
|
||||
add(sc.OnCall(now))
|
||||
}
|
||||
}
|
||||
var names []string
|
||||
for _, uid := range people {
|
||||
if u, err := s.userByID(uid); err == nil {
|
||||
s.notifyUser(u, Note{Status: "firing", Kind: "alert", Title: a.Summary, Message: s.alertText(a), Severity: a.Severity,
|
||||
Link: s.link("/#alerts"), Time: now.Unix(), Alert: a.ID})
|
||||
names = append(names, u.Name)
|
||||
}
|
||||
}
|
||||
text := fmt.Sprintf("step %d: notified %s", a.Step+1, strings.Join(names, ", "))
|
||||
if len(names) == 0 {
|
||||
text = fmt.Sprintf("step %d: nobody to notify (empty schedule or removed users)", a.Step+1)
|
||||
}
|
||||
s.st.addEvent("alert", id, "escalated", "sandbar", text, now)
|
||||
|
||||
next := a.Step + 1
|
||||
var nextAt int64
|
||||
switch {
|
||||
case next < len(pol.Steps):
|
||||
nextAt = now.Add(time.Duration(pol.Steps[next].DelayMin) * time.Minute).Unix()
|
||||
case pol.RepeatMin > 0:
|
||||
next = 0
|
||||
nextAt = now.Add(time.Duration(pol.RepeatMin) * time.Minute).Unix()
|
||||
}
|
||||
s.st.db.Exec(`UPDATE alerts SET step=?, next_at=?, updated=? WHERE id=?`, next, nextAt, now.Unix(), id)
|
||||
}
|
||||
|
||||
// notifyUser reaches one person: a message on their personal topic (u-<id>, which
|
||||
// their phone subscribes to) and an email if the server can send mail.
|
||||
func (s *Server) notifyUser(u *User, n Note) {
|
||||
s.publish(userTopic(u.ID), n.subject(), n.text(), n.priority(), n.tags(), n.Alert, n.Status)
|
||||
if u.Email != "" && (s.opts.SMTP.Host != "" || s.opts.Mailer != nil) {
|
||||
go func() {
|
||||
if err := s.Mail(u.Email, n.subject(), n.text()); err != nil {
|
||||
s.st.addEvent("alert", "", "error", "sandbar", "email to "+u.Email+": "+err.Error(), s.now())
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func userTopic(id string) string { return "u-" + id }
|
||||
@@ -0,0 +1,437 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Web Push lets the mobile companion (a PWA) get a notification while it is closed. Each
|
||||
// device registers a push subscription and the topics it wants; Publish then sends one
|
||||
// encrypted push per matching device. It is implemented here with the standard library only:
|
||||
// VAPID (RFC 8292) identifies Sandbar to the push service, and the payload is encrypted with
|
||||
// aes128gcm (RFC 8291) so the push service never sees the message.
|
||||
|
||||
const (
|
||||
vapidKind = "vapid"
|
||||
pushRecord = 4096
|
||||
pushMaxBody = 1200 // characters of message body sent in a push; the app fetches the rest
|
||||
pushTTL = 4 * 60 * 60
|
||||
maxPushSubs = 20 // per person
|
||||
pushSendWait = 10 * time.Second
|
||||
)
|
||||
|
||||
// PushSub is one device registered for push.
|
||||
type PushSub struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"-"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
P256dh string `json:"-"`
|
||||
Auth string `json:"-"`
|
||||
Topics []string `json:"topics"`
|
||||
Device string `json:"device"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
// vapidKey returns the server's VAPID key pair, creating it on first use. The private key
|
||||
// is stored sealed with the master key like channel secrets.
|
||||
func (s *Server) vapidKey() (*ecdsa.PrivateKey, string, error) {
|
||||
s.pushMu.Lock()
|
||||
defer s.pushMu.Unlock()
|
||||
if s.vapid != nil {
|
||||
return s.vapid, s.vapidPub, nil
|
||||
}
|
||||
var doc struct {
|
||||
Priv string `json:"priv"`
|
||||
}
|
||||
var k *ecdsa.PrivateKey
|
||||
if err := s.st.get(vapidKind, "key", &doc); err == nil {
|
||||
if raw, err := base64.RawURLEncoding.DecodeString(s.st.open(doc.Priv)); err == nil {
|
||||
if ek, err := ecdh.P256().NewPrivateKey(raw); err == nil {
|
||||
k = ecdhToECDSA(ek)
|
||||
}
|
||||
}
|
||||
}
|
||||
if k == nil {
|
||||
ek, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
doc.Priv = s.st.seal(base64.RawURLEncoding.EncodeToString(ek.Bytes()))
|
||||
if err := s.st.put(vapidKind, "key", doc); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
k = ecdhToECDSA(ek)
|
||||
}
|
||||
s.vapid = k
|
||||
pub, _ := k.PublicKey.ECDH()
|
||||
s.vapidPub = base64.RawURLEncoding.EncodeToString(pub.Bytes())
|
||||
return s.vapid, s.vapidPub, nil
|
||||
}
|
||||
|
||||
func ecdhToECDSA(k *ecdh.PrivateKey) *ecdsa.PrivateKey {
|
||||
pub := k.PublicKey().Bytes()
|
||||
x, y := elliptic.Unmarshal(elliptic.P256(), pub)
|
||||
d := new(big.Int).SetBytes(k.Bytes())
|
||||
return &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, D: d}
|
||||
}
|
||||
|
||||
func (s *Server) vapidSubject() string {
|
||||
if s.opts.VapidSubject != "" {
|
||||
return s.opts.VapidSubject
|
||||
}
|
||||
if strings.HasPrefix(s.opts.URL, "https://") {
|
||||
return s.opts.URL
|
||||
}
|
||||
return "mailto:sandbar@localhost"
|
||||
}
|
||||
|
||||
// vapidAuth builds the Authorization header for a push endpoint.
|
||||
func (s *Server) vapidAuth(endpoint string) (string, error) {
|
||||
k, pub, err := s.vapidKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hdr := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"ES256"}`))
|
||||
claims, _ := json.Marshal(map[string]any{"aud": u.Scheme + "://" + u.Host, "exp": s.now().Add(12 * time.Hour).Unix(), "sub": s.vapidSubject()})
|
||||
signing := hdr + "." + base64.RawURLEncoding.EncodeToString(claims)
|
||||
h := sha256.Sum256([]byte(signing))
|
||||
r, sg, err := ecdsa.Sign(rand.Reader, k, h[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
sg.FillBytes(sig[32:])
|
||||
return "vapid t=" + signing + "." + base64.RawURLEncoding.EncodeToString(sig) + ", k=" + pub, nil
|
||||
}
|
||||
|
||||
// encryptPush encrypts payload for a subscription (RFC 8291, aes128gcm, one record).
|
||||
func encryptPush(payload []byte, p256dh, authSecret string) ([]byte, error) {
|
||||
uaRaw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(p256dh, "="))
|
||||
if err != nil {
|
||||
return nil, errors.New("bad p256dh key")
|
||||
}
|
||||
auth, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(authSecret, "="))
|
||||
if err != nil || len(auth) != 16 {
|
||||
return nil, errors.New("bad auth secret")
|
||||
}
|
||||
uaPub, err := ecdh.P256().NewPublicKey(uaRaw)
|
||||
if err != nil {
|
||||
return nil, errors.New("bad p256dh key")
|
||||
}
|
||||
if len(payload) > pushRecord-17-1 {
|
||||
return nil, errors.New("payload too large")
|
||||
}
|
||||
asKey, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := asKey.ECDH(uaPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
asPub := asKey.PublicKey().Bytes()
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prkKey, err := hkdf.Extract(sha256.New, secret, auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyInfo := append(append([]byte("WebPush: info\x00"), uaRaw...), asPub...)
|
||||
ikm, err := hkdf.Expand(sha256.New, prkKey, string(keyInfo), 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prk, err := hkdf.Extract(sha256.New, ikm, salt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cek, err := hkdf.Expand(sha256.New, prk, "Content-Encoding: aes128gcm\x00", 16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce, err := hkdf.Expand(sha256.New, prk, "Content-Encoding: nonce\x00", 12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blk, err := aes.NewCipher([]byte(cek))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(blk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ct := gcm.Seal(nil, []byte(nonce), append(append([]byte{}, payload...), 2), nil)
|
||||
var out bytes.Buffer
|
||||
out.Write(salt)
|
||||
out.Write([]byte{0, 0, byte(pushRecord >> 8), byte(pushRecord & 0xff), byte(len(asPub))})
|
||||
out.Write(asPub)
|
||||
out.Write(ct)
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// pushPayload is what the service worker receives.
|
||||
type pushPayload struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Time int64 `json:"time"`
|
||||
Alert string `json:"alert,omitempty"` // set on a firing on-call alert so the app can offer Acknowledge
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
return string(r[:n]) + "..."
|
||||
}
|
||||
|
||||
// sendPush delivers one payload to one subscription. A gone subscription is removed.
|
||||
func (s *Server) sendPush(sub PushSub, payload []byte, urgency string) error {
|
||||
body, err := encryptPush(payload, sub.P256dh, sub.Auth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authz, err := s.vapidAuth(sub.Endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest("POST", sub.Endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Encoding", "aes128gcm")
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("TTL", strconv.Itoa(pushTTL))
|
||||
req.Header.Set("Urgency", urgency)
|
||||
req.Header.Set("Authorization", authz)
|
||||
c := *s.httpClient(s.opts.PushAllowPrivate)
|
||||
c.Timeout = pushSendWait
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode == 404 || resp.StatusCode == 410 {
|
||||
s.st.db.Exec(`DELETE FROM push_subs WHERE id=?`, sub.ID)
|
||||
return fmt.Errorf("subscription gone (%d), removed", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("push service answered %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func urgencyFor(prio int) string {
|
||||
switch {
|
||||
case prio >= 5:
|
||||
return "high"
|
||||
case prio == 4:
|
||||
return "normal"
|
||||
case prio == 2:
|
||||
return "low"
|
||||
case prio <= 1:
|
||||
return "very-low"
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
|
||||
// pushMessage sends a published message to every device subscribed to its topic.
|
||||
func (s *Server) pushMessage(m Message, status string) {
|
||||
subs := s.pushSubsForTopic(m.Topic)
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
p, _ := json.Marshal(pushPayload{ID: m.ID, Topic: m.Topic, Title: m.Title, Message: truncate(m.Message, pushMaxBody), Priority: m.Priority,
|
||||
Tags: m.Tags, Time: m.Time, Alert: m.Alert, Status: status})
|
||||
for _, sub := range subs {
|
||||
sub := sub
|
||||
go func() {
|
||||
if err := s.sendPush(sub, p, urgencyFor(m.Priority)); err != nil {
|
||||
log.Printf("push to %s: %v", sub.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
const pushCols = `id,user_id,endpoint,p256dh,auth,topics,device,created`
|
||||
|
||||
func scanSub(r scanner) (PushSub, error) {
|
||||
var p PushSub
|
||||
var tj string
|
||||
err := r.Scan(&p.ID, &p.UserID, &p.Endpoint, &p.P256dh, &p.Auth, &tj, &p.Device, &p.Created)
|
||||
json.Unmarshal([]byte(tj), &p.Topics)
|
||||
if p.Topics == nil {
|
||||
p.Topics = []string{}
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (s *Server) pushSubs(where string, args ...any) []PushSub {
|
||||
out := []PushSub{}
|
||||
rows, err := s.st.db.Query(`SELECT `+pushCols+` FROM push_subs `+where, args...)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
if p, err := scanSub(rows); err == nil {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) pushSubsForTopic(topic string) []PushSub {
|
||||
var out []PushSub
|
||||
for _, p := range s.pushSubs(`ORDER BY created`) { // topics live in a JSON column; there are few devices
|
||||
for _, t := range p.Topics {
|
||||
if t == topic {
|
||||
out = append(out, p)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- HTTP ----------
|
||||
|
||||
func (s *Server) pushKeyHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
_, pub, err := s.vapidKey()
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"publicKey": pub})
|
||||
}
|
||||
|
||||
func (s *Server) listPushSubs(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, s.pushSubs(`WHERE user_id=? ORDER BY created`, userFrom(r.Context()).ID))
|
||||
}
|
||||
|
||||
func (s *Server) savePushSub(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys struct {
|
||||
P256dh string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
} `json:"keys"`
|
||||
Topics []string `json:"topics"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
if !decode(r, &in) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
u := userFrom(r.Context())
|
||||
ep, err := url.Parse(in.Endpoint)
|
||||
if err != nil || (ep.Scheme != "https" && !(s.opts.PushAllowPrivate && ep.Scheme == "http")) || ep.Host == "" || len(in.Endpoint) > 1024 {
|
||||
bad(w, "the push endpoint must be an https address")
|
||||
return
|
||||
}
|
||||
// Prove the keys are usable now rather than when the first alert fails to reach the phone.
|
||||
if _, err := encryptPush([]byte("x"), in.Keys.P256dh, in.Keys.Auth); err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
topics := []string{userTopic(u.ID)} // the personal topic always comes along: it carries on-call pages
|
||||
seen := map[string]bool{topics[0]: true}
|
||||
for _, t := range in.Topics {
|
||||
if t = strings.TrimSpace(t); validTopic(t) && !seen[t] {
|
||||
seen[t] = true
|
||||
topics = append(topics, t)
|
||||
}
|
||||
}
|
||||
if len(topics) > 50 {
|
||||
bad(w, "subscribe to at most 50 topics")
|
||||
return
|
||||
}
|
||||
tj, _ := json.Marshal(topics)
|
||||
device := truncate(strings.TrimSpace(in.Device), 80)
|
||||
var id string
|
||||
err = s.st.db.QueryRow(`SELECT id FROM push_subs WHERE endpoint=?`, in.Endpoint).Scan(&id)
|
||||
switch {
|
||||
case err == nil:
|
||||
_, err = s.st.db.Exec(`UPDATE push_subs SET user_id=?,p256dh=?,auth=?,topics=?,device=? WHERE id=?`, u.ID, in.Keys.P256dh, in.Keys.Auth, string(tj), device, id)
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
var n int
|
||||
s.st.db.QueryRow(`SELECT COUNT(*) FROM push_subs WHERE user_id=?`, u.ID).Scan(&n)
|
||||
if n >= maxPushSubs {
|
||||
bad(w, "this account has too many registered devices, remove one first")
|
||||
return
|
||||
}
|
||||
id = newID()
|
||||
_, err = s.st.db.Exec(`INSERT INTO push_subs(`+pushCols+`) VALUES(?,?,?,?,?,?,?,?)`, id, u.ID, in.Endpoint, in.Keys.P256dh, in.Keys.Auth, string(tj), device, s.now().Unix())
|
||||
}
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
p, err := scanSub(s.st.db.QueryRow(`SELECT `+pushCols+` FROM push_subs WHERE id=?`, id))
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, p)
|
||||
}
|
||||
|
||||
func (s *Server) deletePushSub(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := s.st.db.Exec(`DELETE FROM push_subs WHERE id=? AND user_id=?`, r.PathValue("id"), userFrom(r.Context()).ID)
|
||||
fail2(w, err)
|
||||
}
|
||||
|
||||
// testPush sends a test notification to the caller's own devices and says how each one went.
|
||||
func (s *Server) testPush(w http.ResponseWriter, r *http.Request) {
|
||||
u := userFrom(r.Context())
|
||||
p, _ := json.Marshal(pushPayload{ID: newID(), Topic: userTopic(u.ID), Title: "Sandbar test", Message: "If you can read this, push notifications work on this device.",
|
||||
Priority: 3, Time: s.now().Unix(), Status: "test"})
|
||||
type result struct {
|
||||
ID string `json:"id"`
|
||||
Device string `json:"device"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
out := []result{}
|
||||
for _, sub := range s.pushSubs(`WHERE user_id=?`, u.ID) {
|
||||
res := result{ID: sub.ID, Device: sub.Device}
|
||||
if err := s.sendPush(sub, p, "normal"); err != nil {
|
||||
res.Error = err.Error()
|
||||
}
|
||||
out = append(out, res)
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeDevice plays the browser: it owns the subscription keys and can decrypt what arrives.
|
||||
type fakeDevice struct {
|
||||
priv *ecdh.PrivateKey
|
||||
auth []byte
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
got []pushPayload
|
||||
vapid string
|
||||
status int
|
||||
}
|
||||
|
||||
func newFakeDevice(t *testing.T) *fakeDevice {
|
||||
t.Helper()
|
||||
priv, _ := ecdh.P256().GenerateKey(rand.Reader)
|
||||
d := &fakeDevice{priv: priv, auth: make([]byte, 16), status: 201}
|
||||
rand.Read(d.auth)
|
||||
d.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.vapid = r.Header.Get("Authorization")
|
||||
if d.status != 201 {
|
||||
w.WriteHeader(d.status)
|
||||
return
|
||||
}
|
||||
pt, err := d.decrypt(body)
|
||||
if err != nil {
|
||||
t.Errorf("decrypt: %v", err)
|
||||
w.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
var p pushPayload
|
||||
json.Unmarshal(pt, &p)
|
||||
d.got = append(d.got, p)
|
||||
w.WriteHeader(201)
|
||||
}))
|
||||
t.Cleanup(d.srv.Close)
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *fakeDevice) keys() map[string]string {
|
||||
return map[string]string{
|
||||
"p256dh": base64.RawURLEncoding.EncodeToString(d.priv.PublicKey().Bytes()),
|
||||
"auth": base64.RawURLEncoding.EncodeToString(d.auth),
|
||||
}
|
||||
}
|
||||
|
||||
// decrypt is RFC 8291 from the receiving side, written independently of encryptPush.
|
||||
func (d *fakeDevice) decrypt(b []byte) ([]byte, error) {
|
||||
salt, idlen := b[:16], int(b[20])
|
||||
asPubRaw := b[21 : 21+idlen]
|
||||
ct := b[21+idlen:]
|
||||
asPub, err := ecdh.P256().NewPublicKey(asPubRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, _ := d.priv.ECDH(asPub)
|
||||
prkKey, _ := hkdf.Extract(sha256.New, secret, d.auth)
|
||||
info := append(append([]byte("WebPush: info\x00"), d.priv.PublicKey().Bytes()...), asPubRaw...)
|
||||
ikm, _ := hkdf.Expand(sha256.New, prkKey, string(info), 32)
|
||||
prk, _ := hkdf.Extract(sha256.New, ikm, salt)
|
||||
cek, _ := hkdf.Expand(sha256.New, prk, "Content-Encoding: aes128gcm\x00", 16)
|
||||
nonce, _ := hkdf.Expand(sha256.New, prk, "Content-Encoding: nonce\x00", 12)
|
||||
blk, _ := aes.NewCipher([]byte(cek))
|
||||
gcm, _ := cipher.NewGCM(blk)
|
||||
pt, err := gcm.Open(nil, []byte(nonce), ct, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.TrimRight(pt, "\x00")[:len(bytes.TrimRight(pt, "\x00"))-1], nil // drop the 0x02 delimiter
|
||||
}
|
||||
|
||||
func (d *fakeDevice) payloads() []pushPayload {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return append([]pushPayload(nil), d.got...)
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, what string, ok func() bool) {
|
||||
t.Helper()
|
||||
for i := 0; i < 200; i++ {
|
||||
if ok() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
func pushSetup(t *testing.T) (*Server, http.Handler, *User, string) {
|
||||
t.Helper()
|
||||
s, _, _ := newTestServer(t)
|
||||
s.opts.PushAllowPrivate = true
|
||||
u, err := s.createUser("Ana", "ana@example.com", "correct horse battery", "admin", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, key, _ := s.createAPIKey(u.ID, "test")
|
||||
return s, s.Handler(), u, key
|
||||
}
|
||||
|
||||
func call(h http.Handler, method, path, key string, body any) *httptest.ResponseRecorder {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
r := httptest.NewRequest(method, path, rd)
|
||||
if key != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
if body != nil {
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestPushDeliversEncryptedMessage(t *testing.T) {
|
||||
s, h, u, key := pushSetup(t)
|
||||
dev := newFakeDevice(t)
|
||||
|
||||
if w := call(h, "GET", "/api/v1/push/key", "", nil); w.Code != 401 {
|
||||
t.Fatalf("push key without sign-in: %d", w.Code)
|
||||
}
|
||||
var k struct{ PublicKey string }
|
||||
json.Unmarshal(call(h, "GET", "/api/v1/push/key", key, nil).Body.Bytes(), &k)
|
||||
if raw, err := base64.RawURLEncoding.DecodeString(k.PublicKey); err != nil || len(raw) != 65 {
|
||||
t.Fatalf("bad VAPID key %q", k.PublicKey)
|
||||
}
|
||||
|
||||
w := call(h, "POST", "/api/v1/push/subscriptions", key, map[string]any{
|
||||
"endpoint": dev.srv.URL + "/push/abc", "keys": dev.keys(), "topics": []string{"alerts", "bad topic!"}, "device": "Pixel"})
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("subscribe: %d %s", w.Code, w.Body)
|
||||
}
|
||||
var sub PushSub
|
||||
json.Unmarshal(w.Body.Bytes(), &sub)
|
||||
if len(sub.Topics) != 2 || sub.Topics[0] != userTopic(u.ID) || sub.Topics[1] != "alerts" {
|
||||
t.Fatalf("topics: %v", sub.Topics)
|
||||
}
|
||||
|
||||
s.Publish("alerts", "Disk full", "db1 is at 98%", 5, []string{"warning"})
|
||||
s.Publish("other", "ignored", "not subscribed", 3, nil)
|
||||
waitFor(t, "push", func() bool { return len(dev.payloads()) == 1 })
|
||||
p := dev.payloads()[0]
|
||||
if p.Title != "Disk full" || p.Message != "db1 is at 98%" || p.Priority != 5 || p.Topic != "alerts" {
|
||||
t.Fatalf("payload: %+v", p)
|
||||
}
|
||||
|
||||
// The VAPID header must be a valid ES256 JWT for the endpoint's origin, signed by the advertised key.
|
||||
dev.mu.Lock()
|
||||
hdr := dev.vapid
|
||||
dev.mu.Unlock()
|
||||
tok := strings.TrimPrefix(strings.Split(hdr, ", k=")[0], "vapid t=")
|
||||
parts := strings.Split(tok, ".")
|
||||
if len(parts) != 3 || !strings.HasSuffix(hdr, ", k="+k.PublicKey) {
|
||||
t.Fatalf("authorization: %s", hdr)
|
||||
}
|
||||
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
raw, _ := base64.RawURLEncoding.DecodeString(k.PublicKey)
|
||||
pub, _ := ecdh.P256().NewPublicKey(raw)
|
||||
ek := ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(pub.Bytes()[1:33]), Y: new(big.Int).SetBytes(pub.Bytes()[33:])}
|
||||
hh := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if !ecdsa.Verify(&ek, hh[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) {
|
||||
t.Fatal("VAPID signature does not verify")
|
||||
}
|
||||
claims, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if !strings.Contains(string(claims), `"aud":"`+dev.srv.URL+`"`) {
|
||||
t.Fatalf("claims: %s", claims)
|
||||
}
|
||||
|
||||
// The key survives a restart: it is stored, not regenerated.
|
||||
s.vapid = nil
|
||||
if _, pub2, _ := s.vapidKey(); pub2 != k.PublicKey {
|
||||
t.Fatal("VAPID key changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushOnCallAlertCarriesAlertID(t *testing.T) {
|
||||
s, h, u, key := pushSetup(t)
|
||||
dev := newFakeDevice(t)
|
||||
call(h, "POST", "/api/v1/push/subscriptions", key, map[string]any{"endpoint": dev.srv.URL + "/p", "keys": dev.keys(), "topics": []string{"alerts"}})
|
||||
a, _, err := s.RaiseAlert("test:cpu", "test", "CPU high", "load 12", "critical", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, "push", func() bool { return len(dev.payloads()) == 1 })
|
||||
if p := dev.payloads()[0]; p.Alert != a.ID || p.Status != "firing" {
|
||||
t.Fatalf("payload: %+v", p)
|
||||
}
|
||||
if w := call(h, "POST", "/api/v1/alerts/"+a.ID+"/ack", key, map[string]any{}); w.Code != 200 {
|
||||
t.Fatalf("ack: %d %s", w.Code, w.Body)
|
||||
}
|
||||
_ = u
|
||||
}
|
||||
|
||||
func TestPushGoneSubscriptionIsRemoved(t *testing.T) {
|
||||
s, h, _, key := pushSetup(t)
|
||||
dev := newFakeDevice(t)
|
||||
dev.status = 410
|
||||
call(h, "POST", "/api/v1/push/subscriptions", key, map[string]any{"endpoint": dev.srv.URL + "/p", "keys": dev.keys(), "topics": []string{"alerts"}})
|
||||
s.Publish("alerts", "x", "y", 3, nil)
|
||||
waitFor(t, "removal", func() bool { return len(s.pushSubs("")) == 0 })
|
||||
}
|
||||
|
||||
func TestPushSubscriptionRules(t *testing.T) {
|
||||
_, h, _, key := pushSetup(t)
|
||||
dev := newFakeDevice(t)
|
||||
if w := call(h, "POST", "/api/v1/push/subscriptions", key, map[string]any{"endpoint": "ftp://x/y", "keys": dev.keys()}); w.Code != 400 {
|
||||
t.Fatalf("bad endpoint accepted: %d", w.Code)
|
||||
}
|
||||
if w := call(h, "POST", "/api/v1/push/subscriptions", key, map[string]any{"endpoint": dev.srv.URL, "keys": map[string]string{"p256dh": "AAAA", "auth": "AAAA"}}); w.Code != 400 {
|
||||
t.Fatalf("bad keys accepted: %d", w.Code)
|
||||
}
|
||||
body := map[string]any{"endpoint": dev.srv.URL + "/p", "keys": dev.keys(), "topics": []string{"a"}}
|
||||
call(h, "POST", "/api/v1/push/subscriptions", key, body)
|
||||
body["topics"] = []string{"b"}
|
||||
call(h, "POST", "/api/v1/push/subscriptions", key, body) // same endpoint updates in place
|
||||
var list []PushSub
|
||||
json.Unmarshal(call(h, "GET", "/api/v1/push/subscriptions", key, nil).Body.Bytes(), &list)
|
||||
if len(list) != 1 || list[0].Topics[len(list[0].Topics)-1] != "b" {
|
||||
t.Fatalf("list: %+v", list)
|
||||
}
|
||||
if w := call(h, "POST", "/api/v1/push/test", key, map[string]any{}); !strings.Contains(w.Body.String(), "Pixel") && w.Code != 200 {
|
||||
t.Fatalf("test push: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if w := call(h, "DELETE", "/api/v1/push/subscriptions/"+list[0].ID, key, nil); w.Code != 200 {
|
||||
t.Fatalf("delete: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileLoginAndEscalate(t *testing.T) {
|
||||
s, h, _, _ := pushSetup(t)
|
||||
if w := call(h, "POST", "/api/v1/mobile/login", "", map[string]string{"email": "ana@example.com", "password": "nope nope nope"}); w.Code != 401 {
|
||||
t.Fatalf("wrong password: %d", w.Code)
|
||||
}
|
||||
w := call(h, "POST", "/api/v1/mobile/login", "", map[string]string{"email": "ana@example.com", "password": "correct horse battery", "device": "Pixel 8"})
|
||||
var out struct{ Token string }
|
||||
json.Unmarshal(w.Body.Bytes(), &out)
|
||||
if w.Code != 200 || !strings.HasPrefix(out.Token, "sb_") {
|
||||
t.Fatalf("login: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Fatal("mobile login must not set a cookie")
|
||||
}
|
||||
if w := call(h, "GET", "/api/v1/alerts", out.Token, nil); w.Code != 200 {
|
||||
t.Fatalf("token rejected: %d", w.Code)
|
||||
}
|
||||
if err := s.EscalateAlert("missing", "ana"); err == nil {
|
||||
t.Fatal("escalating a missing alert should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppServingAndCORS(t *testing.T) {
|
||||
s, _, _, _ := pushSetup(t)
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(dir+"/index.html", []byte("<h1>app</h1>"), 0o644)
|
||||
s.opts.AppDir = dir
|
||||
s.opts.AppOrigin = "https://app.example.com"
|
||||
h := s.Handler()
|
||||
if w := call(h, "GET", "/app/", "", nil); w.Code != 200 || !strings.Contains(w.Body.String(), "<h1>app</h1>") {
|
||||
t.Fatalf("app: %d %s", w.Code, w.Body)
|
||||
}
|
||||
r := httptest.NewRequest("OPTIONS", "/api/v1/alerts", nil)
|
||||
r.Header.Set("Origin", "https://app.example.com")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != 204 || w.Header().Get("Access-Control-Allow-Origin") != "https://app.example.com" {
|
||||
t.Fatalf("preflight: %d %v", w.Code, w.Header())
|
||||
}
|
||||
r = httptest.NewRequest("OPTIONS", "/api/v1/alerts", nil)
|
||||
r.Header.Set("Origin", "https://evil.example.com")
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatal("foreign origin allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHammerheadEdgesProxy(t *testing.T) {
|
||||
s, h, _, key := pushSetup(t)
|
||||
if w := call(h, "GET", "/api/v1/hammerhead/edges", key, nil); !strings.Contains(w.Body.String(), `"configured":false`) {
|
||||
t.Fatalf("unconfigured: %s", w.Body)
|
||||
}
|
||||
hh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/login":
|
||||
http.SetCookie(w, &http.Cookie{Name: "hh_session", Value: "x", Path: "/"})
|
||||
io.WriteString(w, "{}")
|
||||
case "/api/edges":
|
||||
if _, err := r.Cookie("hh_session"); err != nil {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
io.WriteString(w, `[{"id":"e1","name":"edge-eu","ips":["1.2.3.4"],"online":true,"token":"SECRET","tunnelAddr":"1.2.3.4:51820"}]`)
|
||||
}
|
||||
}))
|
||||
defer hh.Close()
|
||||
s.opts.HammerheadURL, s.opts.HammerheadEmail, s.opts.HammerheadPassword = hh.URL, "a@b.c", "pw"
|
||||
w := call(h, "GET", "/api/v1/hammerhead/edges", key, nil)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "edge-eu") || strings.Contains(w.Body.String(), "SECRET") || strings.Contains(w.Body.String(), "51820") {
|
||||
t.Fatalf("edges: %d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/secretbox"
|
||||
)
|
||||
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.t = c.t.Add(d)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
type mailLog struct {
|
||||
mu sync.Mutex
|
||||
sent []string
|
||||
}
|
||||
|
||||
func (m *mailLog) send(to, subject, body string) error {
|
||||
m.mu.Lock()
|
||||
m.sent = append(m.sent, to)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mailLog) to() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]string(nil), m.sent...)
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, *fakeClock, *mailLog) {
|
||||
t.Helper()
|
||||
box, _ := secretbox.New(secretbox.GenerateKey())
|
||||
st, err := OpenStore(t.TempDir(), box)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
clk := &fakeClock{t: time.Date(2026, 1, 5, 9, 0, 0, 0, time.UTC)}
|
||||
ml := &mailLog{}
|
||||
return New(Options{Now: clk.Now, Mailer: ml.send, URL: "http://sandbar.test"}, st), clk, ml
|
||||
}
|
||||
|
||||
func mustUser(t *testing.T, s *Server, name string) *User {
|
||||
t.Helper()
|
||||
u, err := s.createUser(name, strings.ToLower(name)+"@example.com", "correct-horse-battery", "member", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestScheduleRotationAndOverride(t *testing.T) {
|
||||
start := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
|
||||
sc := &Schedule{Members: []string{"a", "b", "c"}, RotationHours: 24, Start: start.Unix()}
|
||||
for day, want := range []string{"a", "b", "c", "a"} {
|
||||
if got := sc.OnCall(start.Add(time.Duration(day)*24*time.Hour + time.Hour)); got != want {
|
||||
t.Errorf("day %d: on call %q, want %q", day, got, want)
|
||||
}
|
||||
}
|
||||
sc.Overrides = []Override{{UserID: "z", From: start.Add(2 * time.Hour).Unix(), To: start.Add(5 * time.Hour).Unix()}}
|
||||
if got := sc.OnCall(start.Add(3 * time.Hour)); got != "z" {
|
||||
t.Errorf("override: %q", got)
|
||||
}
|
||||
if got := sc.OnCall(start.Add(6 * time.Hour)); got != "a" {
|
||||
t.Errorf("after override: %q", got)
|
||||
}
|
||||
if (&Schedule{}).OnCall(start) != "" {
|
||||
t.Error("empty schedule should have nobody on call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscalationWalksStepsUntilAcked(t *testing.T) {
|
||||
s, clk, mails := newTestServer(t)
|
||||
alice, bob := mustUser(t, s, "Alice"), mustUser(t, s, "Bob")
|
||||
s.st.put("schedule", "sch", &Schedule{ID: "sch", Name: "primary", Members: []string{alice.ID}, RotationHours: 24, Start: clk.Now().Unix()})
|
||||
s.st.put("policy", "pol", &Policy{ID: "pol", Name: "p", Steps: []Step{
|
||||
{Schedules: []string{"sch"}},
|
||||
{DelayMin: 5, Users: []string{bob.ID}},
|
||||
}})
|
||||
|
||||
a, isNew, err := s.RaiseAlert("k1", "test", "db down", "", "critical", "pol")
|
||||
if err != nil || !isNew {
|
||||
t.Fatalf("raise: %v new=%v", err, isNew)
|
||||
}
|
||||
s.escalate(clk.Now())
|
||||
if got := s.MessagesSince(userTopic(alice.ID), 0, 10); len(got) != 1 {
|
||||
t.Fatalf("alice should be paged once, got %d", len(got))
|
||||
}
|
||||
if len(s.MessagesSince(userTopic(bob.ID), 0, 10)) != 0 {
|
||||
t.Fatal("bob must wait for step 2")
|
||||
}
|
||||
|
||||
clk.Advance(4 * time.Minute)
|
||||
s.escalate(clk.Now())
|
||||
if len(s.MessagesSince(userTopic(bob.ID), 0, 10)) != 0 {
|
||||
t.Fatal("step 2 fired early")
|
||||
}
|
||||
clk.Advance(2 * time.Minute)
|
||||
s.escalate(clk.Now())
|
||||
if len(s.MessagesSince(userTopic(bob.ID), 0, 10)) != 1 {
|
||||
t.Fatal("bob should be paged after the delay")
|
||||
}
|
||||
|
||||
// A second alert with the same key is deduplicated, not a new page.
|
||||
b, isNew, _ := s.RaiseAlert("k1", "test", "db down", "still", "critical", "pol")
|
||||
if isNew || b.ID != a.ID || b.Count != 2 {
|
||||
t.Fatalf("dedupe: new=%v same=%v count=%d", isNew, b.ID == a.ID, b.Count)
|
||||
}
|
||||
|
||||
// Ack stops escalation entirely.
|
||||
c, _, _ := s.RaiseAlert("k2", "test", "disk full", "", "warning", "pol")
|
||||
s.escalate(clk.Now())
|
||||
if err := s.AckAlert(c.ID, "Alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Advance(time.Hour)
|
||||
before := len(s.MessagesSince(userTopic(bob.ID), 0, 50))
|
||||
s.escalate(clk.Now())
|
||||
if len(s.MessagesSince(userTopic(bob.ID), 0, 50)) != before {
|
||||
t.Fatal("an acked alert kept escalating")
|
||||
}
|
||||
// Emails go out on a goroutine; give them a moment.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for len(mails.to()) == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if len(mails.to()) == 0 {
|
||||
t.Fatal("expected email notifications to be sent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnoozeResumesAtSameStep(t *testing.T) {
|
||||
s, clk, _ := newTestServer(t)
|
||||
alice := mustUser(t, s, "Alice")
|
||||
s.st.put("policy", "pol", &Policy{ID: "pol", Name: "p", Steps: []Step{{Users: []string{alice.ID}}}})
|
||||
a, _, _ := s.RaiseAlert("k", "test", "x", "", "info", "pol")
|
||||
if err := s.SnoozeAlert(a.ID, "me", 30*time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.escalate(clk.Now())
|
||||
if len(s.MessagesSince(userTopic(alice.ID), 0, 10)) != 0 {
|
||||
t.Fatal("snoozed alert paged")
|
||||
}
|
||||
clk.Advance(31 * time.Minute)
|
||||
s.escalate(clk.Now())
|
||||
if len(s.MessagesSince(userTopic(alice.ID), 0, 10)) != 1 {
|
||||
t.Fatal("alert should page when the snooze ends")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorHTTPDownAndRecover(t *testing.T) {
|
||||
s, clk, _ := newTestServer(t)
|
||||
var mu sync.Mutex
|
||||
status := 200
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
w.WriteHeader(status)
|
||||
io.WriteString(w, "hello world")
|
||||
}))
|
||||
defer backend.Close()
|
||||
m := &Monitor{ID: "m1", Name: "web", Kind: "http", Target: backend.URL, IntervalS: 30, Retries: 2, Enabled: true, AllowPrivate: true, Keyword: "hello"}
|
||||
if err := validateMonitor(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.st.put("monitor", m.ID, m)
|
||||
|
||||
step := func() { s.checkMonitor(m, clk.Now()); clk.Advance(30 * time.Second) }
|
||||
setStatus := func(v int) { mu.Lock(); status = v; mu.Unlock() }
|
||||
|
||||
step()
|
||||
if st := s.monitorState("m1"); st.Status != "up" {
|
||||
t.Fatalf("want up, got %s (%s)", st.Status, st.Detail)
|
||||
}
|
||||
setStatus(500)
|
||||
step()
|
||||
if st := s.monitorState("m1"); st.Status != "up" || st.Fails != 1 {
|
||||
t.Fatalf("one failure must not flip it: %+v", st)
|
||||
}
|
||||
step()
|
||||
if st := s.monitorState("m1"); st.Status != "down" {
|
||||
t.Fatalf("want down after retries, got %+v", st)
|
||||
}
|
||||
if open := s.listAlerts("open", 10); len(open) != 1 || !strings.Contains(open[0].Summary, "web is down") {
|
||||
t.Fatalf("expected one open alert, got %+v", open)
|
||||
}
|
||||
step() // still down: same alert, not a second one
|
||||
if open := s.listAlerts("open", 10); len(open) != 1 {
|
||||
t.Fatalf("alert duplicated: %d", len(open))
|
||||
}
|
||||
setStatus(200)
|
||||
step()
|
||||
if st := s.monitorState("m1"); st.Status != "up" {
|
||||
t.Fatalf("want recovered, got %+v", st)
|
||||
}
|
||||
if open := s.listAlerts("open", 10); len(open) != 0 {
|
||||
t.Fatalf("alert should have resolved: %+v", open)
|
||||
}
|
||||
if up := s.monitorState("m1").Uptime30; up <= 0 || up >= 100 {
|
||||
t.Fatalf("uptime should reflect the outage, got %v", up)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorBlocksPrivateTargetsByDefault(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer backend.Close()
|
||||
m := &Monitor{Name: "x", Kind: "http", Target: backend.URL}
|
||||
if _, err := s.runCheck(context.Background(), m); err == nil {
|
||||
t.Fatal("a loopback target must be refused unless allowPrivate is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatMissed(t *testing.T) {
|
||||
s, clk, _ := newTestServer(t)
|
||||
m := &Monitor{ID: "hb", Name: "backup", Kind: "heartbeat", IntervalS: 60, GraceS: 10, Enabled: true}
|
||||
if err := validateMonitor(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.st.put("monitor", m.ID, m)
|
||||
s.checkMonitor(m, clk.Now()) // first look: starts the clock
|
||||
if s.monitorState("hb").Status != "pending" {
|
||||
t.Fatal("a heartbeat that was never expected yet should be pending")
|
||||
}
|
||||
clk.Advance(30 * time.Second)
|
||||
if !s.Heartbeat(m.Token) {
|
||||
t.Fatal("ping rejected")
|
||||
}
|
||||
s.checkMonitor(m, clk.Now())
|
||||
if s.monitorState("hb").Status != "up" {
|
||||
t.Fatal("want up after a ping")
|
||||
}
|
||||
clk.Advance(2 * time.Minute)
|
||||
for i := 0; i < 2; i++ {
|
||||
s.checkMonitor(m, clk.Now())
|
||||
clk.Advance(15 * time.Second)
|
||||
}
|
||||
if s.monitorState("hb").Status != "down" {
|
||||
t.Fatalf("missed heartbeat should be down: %+v", s.monitorState("hb"))
|
||||
}
|
||||
if !s.Heartbeat(m.Token) {
|
||||
t.Fatal("ping rejected")
|
||||
}
|
||||
if s.monitorState("hb").Status != "up" || len(s.listAlerts("open", 10)) != 0 {
|
||||
t.Fatal("a ping should recover a down heartbeat immediately")
|
||||
}
|
||||
if s.Heartbeat("nope") {
|
||||
t.Fatal("unknown token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncidentLifecycle(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
u := mustUser(t, s, "Alice")
|
||||
a, _, _ := s.RaiseAlert("k", "t", "api 500s", "", "critical", "")
|
||||
in, err := s.DeclareIncident("API outage", "sev1", "errors everywhere", u.ID, u.Name, []string{a.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.getAlert(a.ID); got.IncidentID != in.ID {
|
||||
t.Fatal("alert not linked")
|
||||
}
|
||||
st := "identified"
|
||||
if _, err := s.UpdateIncident(in.ID, u.Name, IncidentPatch{Status: &st, Note: "bad deploy"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bogus := "exploding"
|
||||
if _, err := s.UpdateIncident(in.ID, u.Name, IncidentPatch{Status: &bogus}); err == nil {
|
||||
t.Fatal("invalid status accepted")
|
||||
}
|
||||
done := "resolved"
|
||||
got, _ := s.UpdateIncident(in.ID, u.Name, IncidentPatch{Status: &done})
|
||||
if got.Resolved == 0 {
|
||||
t.Fatal("resolved time not set")
|
||||
}
|
||||
s.CommentIncident(in.ID, u.Name, "rolled back")
|
||||
ev := s.st.events("incident", in.ID)
|
||||
if len(ev) < 4 {
|
||||
t.Fatalf("timeline too short: %+v", ev)
|
||||
}
|
||||
if tpl := s.postmortemTemplate(got); !strings.Contains(tpl, "rolled back") || !strings.Contains(tpl, "API outage") {
|
||||
t.Fatalf("template missing content:\n%s", tpl)
|
||||
}
|
||||
if n := len(s.MessagesSince("incidents", 0, 10)); n != 3 {
|
||||
t.Fatalf("expected declare + 2 updates on the incidents topic, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelSecretsAreSealedAndRedacted(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
defer ts.Close()
|
||||
c := newAPIClient(t, ts)
|
||||
c.setup()
|
||||
var created map[string]any
|
||||
c.do("POST", "/api/v1/channels", map[string]any{"name": "slack", "kind": "webhook", "target": "https://hooks.example.com/secret-token", "format": "slack", "enabled": true}, &created)
|
||||
var raw string
|
||||
s.st.db.QueryRow(`SELECT body FROM docs WHERE kind='channel'`).Scan(&raw)
|
||||
if strings.Contains(raw, "secret-token") {
|
||||
t.Fatal("webhook URL stored in clear text")
|
||||
}
|
||||
if created["target"] == "https://hooks.example.com/secret-token" {
|
||||
t.Fatal("the API returned the secret")
|
||||
}
|
||||
// Editing without touching the target keeps the stored secret.
|
||||
created["name"] = "slack2"
|
||||
c.do("PUT", "/api/v1/channels/"+created["id"].(string), created, nil)
|
||||
ch, _ := getOf[Channel](s.st, "channel", created["id"].(string))
|
||||
if s.st.open(ch.Target) != "https://hooks.example.com/secret-token" || ch.Name != "slack2" {
|
||||
t.Fatalf("secret lost on edit: %+v", ch)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- HTTP API end to end ----------
|
||||
|
||||
type apiClient struct {
|
||||
t *testing.T
|
||||
ts *httptest.Server
|
||||
cookie *http.Cookie
|
||||
}
|
||||
|
||||
func newAPIClient(t *testing.T, ts *httptest.Server) *apiClient { return &apiClient{t: t, ts: ts} }
|
||||
|
||||
func (c *apiClient) req(method, path string, body any, headers map[string]string) *http.Response {
|
||||
c.t.Helper()
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = strings.NewReader(string(b))
|
||||
}
|
||||
r, _ := http.NewRequest(method, c.ts.URL+path, rd)
|
||||
if body != nil || method != "GET" {
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.cookie != nil {
|
||||
r.AddCookie(c.cookie)
|
||||
}
|
||||
for k, v := range headers {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
c.t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (c *apiClient) do(method, path string, body any, out any) int {
|
||||
c.t.Helper()
|
||||
resp := c.req(method, path, body, nil)
|
||||
defer resp.Body.Close()
|
||||
if out != nil {
|
||||
json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func (c *apiClient) setup() {
|
||||
c.t.Helper()
|
||||
resp := c.req("POST", "/api/v1/setup", map[string]string{"name": "Root", "email": "root@example.com", "password": "correct-horse-battery"}, nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
c.t.Fatalf("setup: %d", resp.StatusCode)
|
||||
}
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == sessionCookie {
|
||||
c.cookie = ck
|
||||
}
|
||||
}
|
||||
if c.cookie == nil {
|
||||
c.t.Fatal("no session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAuthSetupAndTopics(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
defer ts.Close()
|
||||
c := newAPIClient(t, ts)
|
||||
|
||||
if code := c.do("GET", "/api/v1/alerts", nil, nil); code != 401 {
|
||||
t.Fatalf("unauthenticated API call: %d", code)
|
||||
}
|
||||
if code := c.do("POST", "/mytopic", nil, nil); code != 401 {
|
||||
t.Fatalf("unauthenticated publish: %d", code)
|
||||
}
|
||||
c.setup()
|
||||
if code := c.do("POST", "/api/v1/setup", map[string]string{"name": "x", "email": "x@x.com", "password": "correct-horse-battery"}, nil); code != 403 {
|
||||
t.Fatalf("second setup must be refused, got %d", code)
|
||||
}
|
||||
|
||||
var key map[string]string
|
||||
c.do("POST", "/api/v1/apikeys", map[string]string{"name": "ci"}, &key)
|
||||
if !strings.HasPrefix(key["token"], "sb_") {
|
||||
t.Fatalf("api key: %v", key)
|
||||
}
|
||||
bearer := map[string]string{"Authorization": "Bearer " + key["token"]}
|
||||
|
||||
// Subscribe with the API key, then publish ntfy-style with headers.
|
||||
sub := make(chan string, 4)
|
||||
go func() {
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/alerts-room/sse", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key["token"])
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
for sc.Scan() {
|
||||
if strings.HasPrefix(sc.Text(), "data: ") {
|
||||
sub <- sc.Text()[6:]
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
r, _ := http.NewRequest("POST", ts.URL+"/alerts-room", strings.NewReader("disk is full"))
|
||||
r.Header.Set("Authorization", "Bearer "+key["token"])
|
||||
r.Header.Set("Title", "Disk")
|
||||
r.Header.Set("Priority", "urgent")
|
||||
r.Header.Set("Tags", "warning,disk")
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
t.Fatalf("publish: %v %v", err, resp)
|
||||
}
|
||||
select {
|
||||
case line := <-sub:
|
||||
var m Message
|
||||
json.Unmarshal([]byte(line), &m)
|
||||
if m.Message != "disk is full" || m.Title != "Disk" || m.Priority != 5 || len(m.Tags) != 2 {
|
||||
t.Fatalf("bad message: %+v", m)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("subscriber got nothing")
|
||||
}
|
||||
|
||||
// The ntfy apps catch up with ?poll=1&since=<last id> and ignore lines without "event".
|
||||
var ids []string
|
||||
for _, body := range []string{"one", "two", "three"} {
|
||||
r, _ := http.NewRequest("POST", ts.URL+"/phone", strings.NewReader(body))
|
||||
r.Header.Set("Authorization", "Bearer "+key["token"])
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
t.Fatalf("publish %s: %v %v", body, err, resp)
|
||||
}
|
||||
var pm Message
|
||||
json.NewDecoder(resp.Body).Decode(&pm)
|
||||
resp.Body.Close()
|
||||
if pm.Event != "message" {
|
||||
t.Fatalf("publish response event: %q", pm.Event)
|
||||
}
|
||||
ids = append(ids, pm.ID)
|
||||
}
|
||||
pollLines := func(query string) []Message {
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/phone/json?poll=1&"+query, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key["token"])
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var got []Message
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
for sc.Scan() {
|
||||
var m Message
|
||||
json.Unmarshal(sc.Bytes(), &m)
|
||||
if m.Event != "message" {
|
||||
t.Fatalf("poll line without event: %s", sc.Text())
|
||||
}
|
||||
got = append(got, m)
|
||||
}
|
||||
return got
|
||||
}
|
||||
if got := pollLines("since=all"); len(got) != 3 {
|
||||
t.Fatalf("poll since=all: %d messages", len(got))
|
||||
}
|
||||
if got := pollLines("since=" + ids[0]); len(got) != 2 || got[0].Message != "two" {
|
||||
t.Fatalf("poll since=<id>: %+v", got)
|
||||
}
|
||||
if got := pollLines("since=" + ids[2]); len(got) != 0 {
|
||||
t.Fatalf("poll since=<last id>: %+v", got)
|
||||
}
|
||||
if got := pollLines("since=10m"); len(got) != 3 {
|
||||
t.Fatalf("poll since=10m: %d messages", len(got))
|
||||
}
|
||||
|
||||
// Reserved topics are refused.
|
||||
for _, reserved := range []string{"/api", "/status"} {
|
||||
r, _ := http.NewRequest("POST", ts.URL+reserved, strings.NewReader("x"))
|
||||
r.Header.Set("Authorization", "Bearer "+key["token"])
|
||||
resp, _ := http.DefaultClient.Do(r)
|
||||
if resp.StatusCode == 200 {
|
||||
t.Errorf("publish to %s should not succeed", reserved)
|
||||
}
|
||||
}
|
||||
|
||||
// Inbound alert -> ack -> resolve over the API with the key.
|
||||
var out map[string]any
|
||||
c.cookie = nil
|
||||
if code := c.do("POST", "/api/v1/alerts", map[string]string{"key": "cpu", "summary": "CPU high"}, nil); code != 401 {
|
||||
t.Fatalf("alert without credentials: %d", code)
|
||||
}
|
||||
resp = c.req("POST", "/api/v1/alerts", map[string]string{"key": "cpu", "summary": "CPU high", "severity": "warning"}, bearer)
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
resp.Body.Close()
|
||||
id, _ := out["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("no alert id: %v", out)
|
||||
}
|
||||
c.req("POST", "/api/v1/alerts/"+id+"/ack", nil, bearer).Body.Close()
|
||||
a, _ := s.getAlert(id)
|
||||
if a.Status != "acked" || a.AckedBy != "Root" {
|
||||
t.Fatalf("ack: %+v", a)
|
||||
}
|
||||
c.req("POST", "/api/v1/alerts", map[string]string{"key": "cpu", "status": "resolved"}, bearer).Body.Close()
|
||||
if a, _ = s.getAlert(id); a.Status != "resolved" {
|
||||
t.Fatalf("resolve via key: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertmanagerWebhook(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
defer ts.Close()
|
||||
c := newAPIClient(t, ts)
|
||||
c.setup()
|
||||
payload := map[string]any{"alerts": []map[string]any{{"status": "firing", "fingerprint": "abc",
|
||||
"labels": map[string]string{"alertname": "HighLatency", "severity": "critical"}, "annotations": map[string]string{"summary": "p99 > 2s"}}}}
|
||||
c.do("POST", "/api/v1/integrations/alertmanager", payload, nil)
|
||||
open := s.listAlerts("open", 10)
|
||||
if len(open) != 1 || open[0].Summary != "p99 > 2s" || open[0].Severity != "critical" {
|
||||
t.Fatalf("got %+v", open)
|
||||
}
|
||||
payload["alerts"].([]map[string]any)[0]["status"] = "resolved"
|
||||
c.do("POST", "/api/v1/integrations/alertmanager", payload, nil)
|
||||
if len(s.listAlerts("open", 10)) != 0 {
|
||||
t.Fatal("not resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusPageOnlyShowsPublic(t *testing.T) {
|
||||
s, clk, _ := newTestServer(t)
|
||||
s.st.put("monitor", "a", &Monitor{ID: "a", Name: "Public site", Kind: "http", Target: "http://x", Enabled: true, Public: true})
|
||||
s.st.put("monitor", "b", &Monitor{ID: "b", Name: "Internal DB", Kind: "tcp", Target: "x:1", Enabled: true})
|
||||
s.saveState(MonitorState{MonitorID: "a", Status: "up", Since: clk.Now().Unix()})
|
||||
s.st.put("incident", "i1", &Incident{ID: "i1", Title: "Public incident", Severity: "sev2", Status: "investigating", Public: true, Created: clk.Now().Unix()})
|
||||
s.st.put("incident", "i2", &Incident{ID: "i2", Title: "Private incident", Severity: "sev1", Status: "investigating", Created: clk.Now().Unix()})
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
defer ts.Close()
|
||||
resp, _ := http.Get(ts.URL + "/status.json")
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
body := string(b)
|
||||
if !strings.Contains(body, "Public site") || strings.Contains(body, "Internal DB") {
|
||||
t.Fatalf("monitor visibility wrong: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Public incident") || strings.Contains(body, "Private incident") {
|
||||
t.Fatalf("incident visibility wrong: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"overall":"degraded"`) {
|
||||
t.Fatalf("overall: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginThrottle(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
mustUser(t, s, "Alice")
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
defer ts.Close()
|
||||
c := newAPIClient(t, ts)
|
||||
var last int
|
||||
for i := 0; i < 12; i++ {
|
||||
last = c.do("POST", "/api/v1/login", map[string]string{"email": "alice@example.com", "password": "wrong-password-x"}, nil)
|
||||
}
|
||||
if last != 429 {
|
||||
t.Fatalf("expected throttling after repeated failures, got %d", last)
|
||||
}
|
||||
// Even the right password is refused while throttled.
|
||||
if code := c.do("POST", "/api/v1/login", map[string]string{"email": "alice@example.com", "password": "correct-horse-battery"}, nil); code != 429 {
|
||||
t.Fatalf("throttle should apply to the right password too, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
_ "embed"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed web/index.html
|
||||
var indexHTML []byte
|
||||
|
||||
//go:embed web/status.html
|
||||
var statusHTML []byte
|
||||
|
||||
type SMTPConfig struct{ Host, Port, User, Password, From string }
|
||||
|
||||
// Options configures a Server. Zero values are sensible defaults.
|
||||
type Options struct {
|
||||
DataDir string
|
||||
URL string // public URL, used for links in notifications
|
||||
MasterKey string
|
||||
MetricsToken string
|
||||
SMTP SMTPConfig
|
||||
MessageDays int // how long topic messages are kept (default 30)
|
||||
CheckDays int // how long monitor results are kept (default 30)
|
||||
HitDays int // how long website analytics are kept (default 365)
|
||||
TrustProxy bool // take the client address from X-Forwarded-For, for use behind a reverse proxy
|
||||
|
||||
// Mobile companion (see push.go and mobile.go).
|
||||
AppDir string // folder with the companion PWA, served at /app/
|
||||
AppOrigin string // origin allowed to call the API from another site, such as https://sandbar-app.example.com
|
||||
VapidSubject string // contact sent to push services: a mailto: or https: address (default: URL, if https)
|
||||
PushAllowPrivate bool // allow push endpoints on private addresses and plain http, for development only
|
||||
|
||||
// Hammerhead link: when set, GET /api/v1/hammerhead/edges shows its edge and tunnel status.
|
||||
HammerheadURL string
|
||||
HammerheadEmail string
|
||||
HammerheadPassword string
|
||||
|
||||
// Self-update (see update.go). Version is the running release, such as v0.2.0; anything else
|
||||
// ("dev") never updates. Restart is called after a new binary is installed and should close
|
||||
// what the process holds open before re-executing it.
|
||||
Version string
|
||||
AutoUpdate bool
|
||||
UpdateServer string // default https://git.veillax.com
|
||||
UpdateRepo string // default Veillax/sandbar
|
||||
Restart func(exe string) error
|
||||
|
||||
// Tests replace these.
|
||||
Now func() time.Time
|
||||
HTTPClient *http.Client
|
||||
Mailer func(to, subject, body string) error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
opts Options
|
||||
st *Store
|
||||
hub *hub
|
||||
|
||||
// monitor runtime state
|
||||
mu sync.Mutex
|
||||
running map[string]bool // monitor ids with a check in flight
|
||||
|
||||
loginMu sync.Mutex
|
||||
loginTry map[string][]time.Time
|
||||
|
||||
upd updateState
|
||||
|
||||
pushMu sync.Mutex // the VAPID key pair, loaded or created on first use
|
||||
vapid *ecdsa.PrivateKey
|
||||
vapidPub string
|
||||
|
||||
hhMu sync.Mutex
|
||||
hhClient *http.Client
|
||||
|
||||
anaMu sync.Mutex // website analytics: the visitor salt and a per-address rate limit
|
||||
anaSecret string
|
||||
anaTry map[string][]time.Time
|
||||
|
||||
statusMu sync.Mutex // the status page settings cache and the signing secret
|
||||
statusCfg *StatusSettings
|
||||
statusKey []byte
|
||||
subMu sync.Mutex // per-address limit on public subscribe requests
|
||||
subTry map[string][]time.Time
|
||||
notifyWG sync.WaitGroup // status page notifications in flight
|
||||
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func New(opts Options, st *Store) *Server {
|
||||
if opts.MessageDays <= 0 {
|
||||
opts.MessageDays = 30
|
||||
}
|
||||
if opts.CheckDays <= 0 {
|
||||
opts.CheckDays = 30
|
||||
}
|
||||
if opts.HitDays <= 0 {
|
||||
opts.HitDays = 365
|
||||
}
|
||||
return &Server{opts: opts, st: st, hub: newHub(), running: map[string]bool{}, loginTry: map[string][]time.Time{}, anaTry: map[string][]time.Time{}, subTry: map[string][]time.Time{}, stop: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (s *Server) now() time.Time {
|
||||
if s.opts.Now != nil {
|
||||
return s.opts.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Start runs the background workers: monitor scheduler, escalation, pruning.
|
||||
func (s *Server) Start() {
|
||||
s.every(time.Second, func() { s.runDueMonitors(s.now()) })
|
||||
s.every(5*time.Second, func() { s.escalate(s.now()) })
|
||||
s.every(30*time.Second, func() { s.runMaintenance(s.now()) })
|
||||
s.every(time.Hour, func() { s.st.prune(s.now(), s.opts.MessageDays, s.opts.CheckDays, s.opts.HitDays) })
|
||||
}
|
||||
|
||||
func (s *Server) Close() {
|
||||
close(s.stop)
|
||||
s.wg.Wait()
|
||||
s.notifyWG.Wait()
|
||||
}
|
||||
|
||||
func (s *Server) every(d time.Duration, f func()) {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
t := time.NewTicker(d)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("worker panic: %v", r)
|
||||
}
|
||||
}()
|
||||
f()
|
||||
}()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) link(path string) string {
|
||||
if s.opts.URL == "" {
|
||||
return ""
|
||||
}
|
||||
return s.opts.URL + path
|
||||
}
|
||||
|
||||
// BootstrapAdmin creates the first administrator from the environment when there are no users yet.
|
||||
func (s *Server) BootstrapAdmin(email, password string) {
|
||||
if email == "" || password == "" || s.countUsers() > 0 {
|
||||
return
|
||||
}
|
||||
if _, err := s.createUser("Admin", email, password, "admin", ""); err != nil {
|
||||
log.Printf("SANDBAR_ADMIN_EMAIL: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The public status page. Monitors marked public are its components, arranged in groups. Incidents
|
||||
// marked public are posted on it, with the updates written for customers. Scheduled maintenance
|
||||
// has its own window. People follow all of it by RSS, email or webhook (see statussub.go), and the
|
||||
// page can be served from its own domain.
|
||||
|
||||
// StatusSettings is the page's branding and where it lives. One document, kind "statuspage".
|
||||
type StatusSettings struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Domain string `json:"domain"` // serve the page from this host name as well, such as status.example.com
|
||||
LogoURL string `json:"logoUrl"` // https address of an image
|
||||
Accent string `json:"accent"` // #rrggbb, for buttons and links; white text must stay readable on it
|
||||
SupportURL string `json:"supportUrl"` // "Contact us" link: http(s) or mailto
|
||||
Footer string `json:"footer"`
|
||||
}
|
||||
|
||||
// StatusGroup is a heading that components sit under.
|
||||
type StatusGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
// Maintenance is a planned window. Its components show as under maintenance while it runs.
|
||||
type Maintenance struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
Components []string `json:"components"` // monitor ids
|
||||
Canceled bool `json:"canceled"`
|
||||
Created int64 `json:"created"`
|
||||
StartSent bool `json:"startSent"` // subscribers have been told it began
|
||||
EndSent bool `json:"endSent"` // and that it is over
|
||||
}
|
||||
|
||||
func (m *Maintenance) state(now time.Time) string {
|
||||
switch t := now.Unix(); {
|
||||
case m.Canceled:
|
||||
return "canceled"
|
||||
case t < m.Start:
|
||||
return "scheduled"
|
||||
case t < m.End:
|
||||
return "in_progress"
|
||||
}
|
||||
return "completed"
|
||||
}
|
||||
|
||||
var (
|
||||
hostRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$`)
|
||||
accentRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
)
|
||||
|
||||
// contrastWithWhite is the WCAG contrast ratio of a #rrggbb color against white.
|
||||
func contrastWithWhite(hex string) float64 {
|
||||
v, _ := strconv.ParseUint(hex[1:], 16, 32)
|
||||
lin := func(c uint64) float64 {
|
||||
f := float64(c) / 255
|
||||
if f <= 0.03928 {
|
||||
return f / 12.92
|
||||
}
|
||||
return math.Pow((f+0.055)/1.055, 2.4)
|
||||
}
|
||||
l := 0.2126*lin(v>>16&255) + 0.7152*lin(v>>8&255) + 0.0722*lin(v&255)
|
||||
return 1.05 / (l + 0.05)
|
||||
}
|
||||
|
||||
func validateStatusSettings(c *StatusSettings, ownURL string) error {
|
||||
c.Title, c.Description, c.Footer = strings.TrimSpace(c.Title), strings.TrimSpace(c.Description), strings.TrimSpace(c.Footer)
|
||||
c.Domain, c.LogoURL, c.SupportURL, c.Accent = strings.ToLower(strings.TrimSpace(c.Domain)), strings.TrimSpace(c.LogoURL), strings.TrimSpace(c.SupportURL), strings.TrimSpace(c.Accent)
|
||||
if c.Title == "" {
|
||||
c.Title = "Status"
|
||||
}
|
||||
if len(c.Title) > 80 || len(c.Description) > 300 || len(c.Footer) > 300 {
|
||||
return errors.New("the title can be 80 characters, the description and footer 300")
|
||||
}
|
||||
if c.Domain != "" {
|
||||
if !hostRe.MatchString(c.Domain) {
|
||||
return errors.New("the domain must be a host name such as status.example.com, with no https:// or path")
|
||||
}
|
||||
if u, err := url.Parse(ownURL); err == nil && strings.EqualFold(u.Hostname(), c.Domain) {
|
||||
return errors.New("that is Sandbar's own address; use a separate host name for the status page")
|
||||
}
|
||||
}
|
||||
if c.LogoURL != "" {
|
||||
if u, err := url.Parse(c.LogoURL); err != nil || u.Scheme != "https" || u.Host == "" || len(c.LogoURL) > 500 {
|
||||
return errors.New("the logo must be an https:// address")
|
||||
}
|
||||
}
|
||||
if c.SupportURL != "" {
|
||||
u, err := url.Parse(c.SupportURL)
|
||||
if err != nil || len(c.SupportURL) > 500 || !(((u.Scheme == "http" || u.Scheme == "https") && u.Host != "") || (u.Scheme == "mailto" && u.Opaque != "")) {
|
||||
return errors.New("the support link must be an http(s):// address or a mailto: link")
|
||||
}
|
||||
}
|
||||
if c.Accent != "" {
|
||||
if !accentRe.MatchString(c.Accent) {
|
||||
return errors.New("the accent color must look like #1f5c5c")
|
||||
}
|
||||
c.Accent = strings.ToLower(c.Accent)
|
||||
if r := contrastWithWhite(c.Accent); r < 4.5 {
|
||||
return fmt.Errorf("white text is hard to read on that color (contrast %.1f to 1, needs 4.5 to 1); pick a darker one", r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) statusSettings() StatusSettings {
|
||||
s.statusMu.Lock()
|
||||
defer s.statusMu.Unlock()
|
||||
if s.statusCfg == nil {
|
||||
c, err := getOf[StatusSettings](s.st, "statuspage", "main")
|
||||
if err != nil {
|
||||
c = &StatusSettings{}
|
||||
}
|
||||
if c.Title == "" {
|
||||
c.Title = "Status"
|
||||
}
|
||||
s.statusCfg = c
|
||||
}
|
||||
return *s.statusCfg
|
||||
}
|
||||
|
||||
func (s *Server) saveStatusSettings(c StatusSettings) error {
|
||||
if err := s.st.put("statuspage", "main", c); err != nil {
|
||||
return err
|
||||
}
|
||||
s.statusMu.Lock()
|
||||
s.statusCfg = nil
|
||||
s.statusMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- where the page lives ----------
|
||||
|
||||
type statusHostKey struct{}
|
||||
|
||||
// statusHost serves the status page, and nothing else, to requests for the custom domain: the
|
||||
// path is moved under /status, so "/" is the page, "/history" its history, and the admin
|
||||
// interface and API are not reachable from there.
|
||||
func (s *Server) statusHost(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
d := s.statusSettings().Domain
|
||||
if d == "" || !strings.EqualFold(requestHost(r, s.opts.TrustProxy), d) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
r2 := r.WithContext(context.WithValue(r.Context(), statusHostKey{}, true))
|
||||
r2.URL = new(url.URL)
|
||||
*r2.URL = *r.URL
|
||||
switch p := r.URL.Path; {
|
||||
case p == "/status.json" || strings.HasPrefix(p, "/status/"):
|
||||
case p == "/" || p == "/status":
|
||||
r2.URL.Path = "/status"
|
||||
default:
|
||||
r2.URL.Path = "/status" + p
|
||||
}
|
||||
next.ServeHTTP(w, r2)
|
||||
})
|
||||
}
|
||||
|
||||
func requestHost(r *http.Request, trustProxy bool) string {
|
||||
h := r.Host
|
||||
if trustProxy {
|
||||
if f := r.Header.Get("X-Forwarded-Host"); f != "" {
|
||||
h = strings.TrimSpace(strings.Split(f, ",")[0])
|
||||
}
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(h); err == nil {
|
||||
h = host
|
||||
}
|
||||
return strings.ToLower(h)
|
||||
}
|
||||
|
||||
// pagePrefix is what page links start with: nothing on the custom domain, /status elsewhere.
|
||||
func pagePrefix(r *http.Request) string {
|
||||
if on, _ := r.Context().Value(statusHostKey{}).(bool); on {
|
||||
return ""
|
||||
}
|
||||
return "/status"
|
||||
}
|
||||
|
||||
// absStatusURL is a full address for a page path such as "/history", for emails and feeds.
|
||||
// r may be nil, as in a background notification.
|
||||
func (s *Server) absStatusURL(r *http.Request, path string) string {
|
||||
if d := s.statusSettings().Domain; d != "" && (r == nil || pagePrefix(r) == "") {
|
||||
return "https://" + d + path
|
||||
}
|
||||
base := s.opts.URL
|
||||
if base == "" && r != nil {
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" && s.opts.TrustProxy {
|
||||
scheme = "https"
|
||||
}
|
||||
base = scheme + "://" + r.Host
|
||||
}
|
||||
return strings.TrimRight(base, "/") + "/status" + path
|
||||
}
|
||||
|
||||
// ---------- the public view ----------
|
||||
|
||||
type pubComponent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group"`
|
||||
Status string `json:"status"` // the monitor: up, down, pending
|
||||
State string `json:"state"` // what the page says: operational, degraded, partial_outage, major_outage, maintenance, pending
|
||||
Uptime float64 `json:"uptime"`
|
||||
Since int64 `json:"since"`
|
||||
Days [][2]int `json:"days"` // [checks, failed] per UTC day, oldest first, ending today
|
||||
}
|
||||
|
||||
type pubPost struct {
|
||||
Time int64 `json:"time"`
|
||||
Status string `json:"status"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type pubIncident struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity"`
|
||||
Created int64 `json:"created"`
|
||||
Resolved int64 `json:"resolved"`
|
||||
Components []string `json:"components"` // names
|
||||
Updates []pubPost `json:"updates"` // newest first
|
||||
}
|
||||
|
||||
type pubMaintenance struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
State string `json:"state"`
|
||||
Components []string `json:"components"` // names
|
||||
}
|
||||
|
||||
// worse ranks component states; a higher number is shown over a lower one.
|
||||
var stateRank = map[string]int{"operational": 0, "pending": 0, "maintenance": 1, "degraded": 2, "partial_outage": 3, "major_outage": 4}
|
||||
|
||||
func (s *Server) publicComponents() (comps []pubComponent, groups []StatusGroup, names map[string]string) {
|
||||
groups, _ = listOf[StatusGroup](s.st, "sgroup")
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
if groups[i].Order != groups[j].Order {
|
||||
return groups[i].Order < groups[j].Order
|
||||
}
|
||||
return strings.ToLower(groups[i].Name) < strings.ToLower(groups[j].Name)
|
||||
})
|
||||
rank := map[string]int{"": -1}
|
||||
for i, g := range groups {
|
||||
rank[g.ID] = i
|
||||
}
|
||||
mons, _ := listOf[Monitor](s.st, "monitor")
|
||||
names = map[string]string{}
|
||||
now := s.now()
|
||||
for _, m := range mons {
|
||||
if !m.Public || !m.Enabled {
|
||||
continue
|
||||
}
|
||||
name := firstNonEmpty(strings.TrimSpace(m.PublicName), m.Name)
|
||||
names[m.ID] = name
|
||||
if _, ok := rank[m.Group]; !ok {
|
||||
m.Group = "" // its group was deleted
|
||||
}
|
||||
st := s.monitorState(m.ID)
|
||||
days := s.dailyUptime(m.ID, uptimeDays, now)
|
||||
var checks, fails int
|
||||
for _, d := range days {
|
||||
checks, fails = checks+d[0], fails+d[1]
|
||||
}
|
||||
uptime := -1.0 // no checks yet
|
||||
if checks > 0 {
|
||||
uptime = float64(checks-fails) * 100 / float64(checks)
|
||||
}
|
||||
state := map[string]string{"up": "operational", "down": "major_outage"}[st.Status]
|
||||
if state == "" {
|
||||
state = "pending"
|
||||
}
|
||||
comps = append(comps, pubComponent{ID: m.ID, Name: name, Group: m.Group, Status: st.Status, State: state, Uptime: uptime, Since: st.Since, Days: days})
|
||||
}
|
||||
sort.SliceStable(comps, func(i, j int) bool {
|
||||
if ri, rj := rank[comps[i].Group], rank[comps[j].Group]; ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return strings.ToLower(comps[i].Name) < strings.ToLower(comps[j].Name)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) publicIncident(in Incident, names map[string]string) pubIncident {
|
||||
out := pubIncident{ID: in.ID, Title: in.Title, Status: in.Status, Severity: in.Severity, Created: in.Created, Resolved: in.Resolved, Components: []string{}, Updates: []pubPost{}}
|
||||
for _, id := range in.Components {
|
||||
if n, ok := names[id]; ok {
|
||||
out.Components = append(out.Components, n)
|
||||
}
|
||||
}
|
||||
for _, e := range s.st.events("incident", in.ID) {
|
||||
if e.Kind == "post" {
|
||||
out.Updates = append(out.Updates, pubPost{Time: e.Time, Status: e.Status, Text: e.Text})
|
||||
}
|
||||
}
|
||||
if len(out.Updates) == 0 { // public, but nothing posted yet: show what we know
|
||||
out.Updates = append(out.Updates, pubPost{Time: in.Created, Status: "investigating", Text: firstNonEmpty(in.Summary, defaultPost("investigating"))})
|
||||
}
|
||||
for i, j := 0, len(out.Updates)-1; i < j; i, j = i+1, j-1 { // newest first
|
||||
out.Updates[i], out.Updates[j] = out.Updates[j], out.Updates[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) publicMaintenance(m Maintenance, names map[string]string, now time.Time) pubMaintenance {
|
||||
out := pubMaintenance{ID: m.ID, Title: m.Title, Message: m.Message, Start: m.Start, End: m.End, State: m.state(now), Components: []string{}}
|
||||
for _, id := range m.Components {
|
||||
if n, ok := names[id]; ok {
|
||||
out.Components = append(out.Components, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) loadMaintenance() []Maintenance {
|
||||
list, _ := listOf[Maintenance](s.st, "maintenance")
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Start > list[j].Start })
|
||||
return list
|
||||
}
|
||||
|
||||
// statusJSON feeds the public status page: only monitors, incidents and maintenance marked public.
|
||||
func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) {
|
||||
now := s.now()
|
||||
cfg := s.statusSettings()
|
||||
comps, groups, names := s.publicComponents()
|
||||
if groups == nil {
|
||||
groups = []StatusGroup{}
|
||||
}
|
||||
if comps == nil {
|
||||
comps = []pubComponent{}
|
||||
}
|
||||
idx := map[string]int{}
|
||||
for i, c := range comps {
|
||||
idx[c.ID] = i
|
||||
}
|
||||
raise := func(id, state string) {
|
||||
if i, ok := idx[id]; ok && stateRank[state] > stateRank[comps[i].State] {
|
||||
comps[i].State = state
|
||||
}
|
||||
}
|
||||
|
||||
out := struct {
|
||||
Settings StatusSettings `json:"settings"`
|
||||
Monitors []pubComponent `json:"monitors"`
|
||||
Groups []StatusGroup `json:"groups"`
|
||||
Incidents []pubIncident `json:"incidents"`
|
||||
Maintenance []pubMaintenance `json:"maintenance"`
|
||||
Overall string `json:"overall"`
|
||||
Today string `json:"today"` // the UTC date of the last bar
|
||||
Subscribe map[string]bool `json:"subscribe"`
|
||||
}{Settings: cfg, Groups: groups, Incidents: []pubIncident{}, Maintenance: []pubMaintenance{}, Today: now.UTC().Format("2006-01-02"),
|
||||
Subscribe: map[string]bool{"email": s.mailEnabled(), "webhook": true, "rss": true}}
|
||||
out.Settings.Domain = "" // not for visitors
|
||||
|
||||
overall := "operational"
|
||||
lift := func(level string) {
|
||||
if level == "outage" || overall == "outage" {
|
||||
overall = "outage"
|
||||
} else if level == "degraded" || overall == "degraded" {
|
||||
overall = "degraded"
|
||||
} else if level == "maintenance" {
|
||||
overall = "maintenance"
|
||||
}
|
||||
}
|
||||
|
||||
incs, _ := listOf[Incident](s.st, "incident")
|
||||
sortIncidents(incs)
|
||||
cut := now.AddDate(0, 0, -14).Unix()
|
||||
for _, in := range incs {
|
||||
if !in.Public || (in.Status == "resolved" && in.Resolved < cut) {
|
||||
continue
|
||||
}
|
||||
out.Incidents = append(out.Incidents, s.publicIncident(in, names))
|
||||
if in.Status == "resolved" {
|
||||
continue
|
||||
}
|
||||
impact := map[string]string{"sev1": "major_outage", "sev2": "partial_outage"}[in.Severity]
|
||||
if impact == "" {
|
||||
impact = "degraded"
|
||||
}
|
||||
for _, id := range in.Components {
|
||||
raise(id, impact)
|
||||
}
|
||||
if impact == "major_outage" && len(in.Components) > 0 {
|
||||
lift("outage")
|
||||
} else {
|
||||
lift("degraded")
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range s.loadMaintenance() {
|
||||
st := m.state(now)
|
||||
if st == "canceled" || st == "completed" {
|
||||
continue
|
||||
}
|
||||
out.Maintenance = append(out.Maintenance, s.publicMaintenance(m, names, now))
|
||||
if st == "in_progress" {
|
||||
for _, id := range m.Components {
|
||||
if i, ok := idx[id]; ok {
|
||||
comps[i].State = "maintenance" // planned work explains a failing check
|
||||
}
|
||||
}
|
||||
lift("maintenance")
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out.Maintenance, func(i, j int) bool { return out.Maintenance[i].Start < out.Maintenance[j].Start })
|
||||
|
||||
for _, c := range comps {
|
||||
switch c.State {
|
||||
case "major_outage":
|
||||
lift("outage")
|
||||
case "partial_outage", "degraded":
|
||||
lift("degraded")
|
||||
}
|
||||
}
|
||||
out.Monitors, out.Overall = comps, overall
|
||||
w.Header().Set("Cache-Control", "max-age=15")
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
type historyEntry struct {
|
||||
Type string `json:"type"` // incident or maintenance
|
||||
Time int64 `json:"time"`
|
||||
Incident *pubIncident `json:"incident,omitempty"`
|
||||
Maintenance *pubMaintenance `json:"maintenance,omitempty"`
|
||||
}
|
||||
|
||||
// historyJSON lists everything that was ever public, newest first, a page at a time.
|
||||
// before is the time of the last entry of the previous page.
|
||||
func (s *Server) historyJSON(w http.ResponseWriter, r *http.Request) {
|
||||
before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
now := s.now()
|
||||
_, _, names := s.publicComponents()
|
||||
var all []historyEntry
|
||||
incs, _ := listOf[Incident](s.st, "incident")
|
||||
for _, in := range incs {
|
||||
if in.Public {
|
||||
p := s.publicIncident(in, names)
|
||||
all = append(all, historyEntry{Type: "incident", Time: in.Created, Incident: &p})
|
||||
}
|
||||
}
|
||||
for _, m := range s.loadMaintenance() {
|
||||
if st := m.state(now); st == "completed" || st == "in_progress" {
|
||||
p := s.publicMaintenance(m, names, now)
|
||||
all = append(all, historyEntry{Type: "maintenance", Time: m.Start, Maintenance: &p})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(all, func(i, j int) bool { return all[i].Time > all[j].Time })
|
||||
if before > 0 {
|
||||
k := sort.Search(len(all), func(i int) bool { return all[i].Time < before })
|
||||
all = all[k:]
|
||||
}
|
||||
next := int64(0)
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
next = all[limit-1].Time
|
||||
}
|
||||
if all == nil {
|
||||
all = []historyEntry{}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "max-age=15")
|
||||
writeJSON(w, 200, map[string]any{"entries": all, "next": next})
|
||||
}
|
||||
|
||||
func (s *Server) incidentJSON(w http.ResponseWriter, r *http.Request) {
|
||||
in, err := getOf[Incident](s.st, "incident", r.PathValue("id"))
|
||||
if err != nil || !in.Public {
|
||||
writeJSON(w, 404, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
_, _, names := s.publicComponents()
|
||||
w.Header().Set("Cache-Control", "max-age=15")
|
||||
writeJSON(w, 200, s.publicIncident(*in, names))
|
||||
}
|
||||
|
||||
// servePage fills the page shell with the title and the prefix its links start with.
|
||||
func (s *Server) serveStatusPage(w http.ResponseWriter, r *http.Request) {
|
||||
page := strings.NewReplacer("__TITLE__", html.EscapeString(s.statusSettings().Title), "__BASE__", pagePrefix(r)).Replace(string(statusHTML))
|
||||
servePage(w, []byte(page))
|
||||
}
|
||||
|
||||
func (s *Server) statusRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /status", s.serveStatusPage)
|
||||
mux.HandleFunc("GET /status/history", s.serveStatusPage)
|
||||
mux.HandleFunc("GET /status/incidents/{id}", s.serveStatusPage)
|
||||
mux.HandleFunc("GET /status.json", s.statusJSON)
|
||||
mux.HandleFunc("GET /status/api/history", s.historyJSON)
|
||||
mux.HandleFunc("GET /status/api/incidents/{id}", s.incidentJSON)
|
||||
mux.HandleFunc("GET /status/feed.rss", s.statusFeed)
|
||||
mux.HandleFunc("POST /status/subscribe", s.followStatusHTTP)
|
||||
mux.HandleFunc("GET /status/confirm", s.confirmSubscription)
|
||||
mux.HandleFunc("GET /status/unsubscribe", s.unsubscribeRedirect)
|
||||
mux.HandleFunc("POST /status/unsubscribe", s.unsubscribeHTTP)
|
||||
|
||||
// administration
|
||||
mux.HandleFunc("GET /api/v1/statuspage", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"settings": s.statusSettings(), "pageUrl": s.absStatusURL(nil, ""), "feedUrl": s.absStatusURL(nil, "/feed.rss"), "email": s.mailEnabled()})
|
||||
}))
|
||||
mux.HandleFunc("PUT /api/v1/statuspage", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
|
||||
var c StatusSettings
|
||||
if !decode(r, &c) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
if err := validateStatusSettings(&c, s.opts.URL); err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.saveStatusSettings(c); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"settings": c, "pageUrl": s.absStatusURL(nil, ""), "feedUrl": s.absStatusURL(nil, "/feed.rss"), "email": s.mailEnabled()})
|
||||
}))
|
||||
s.crud(mux, "status-groups", "sgroup", true, func() any { return &StatusGroup{} }, func(v any, id string) error {
|
||||
g := v.(*StatusGroup)
|
||||
g.ID, g.Name, g.Description = id, strings.TrimSpace(g.Name), strings.TrimSpace(g.Description)
|
||||
if g.Name == "" {
|
||||
return errors.New("give the group a name")
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
s.maintenanceRoutes(mux)
|
||||
mux.HandleFunc("GET /api/v1/status-subscribers", s.auth(true, s.listSubscribers))
|
||||
mux.HandleFunc("DELETE /api/v1/status-subscribers/{id}", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := s.st.db.Exec(`DELETE FROM subscribers WHERE id=?`, r.PathValue("id"))
|
||||
if n, _ := res.RowsAffected(); err == nil && n == 0 {
|
||||
err = errNotFound
|
||||
}
|
||||
fail2(w, err)
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- incident posts ----------
|
||||
|
||||
func defaultPost(status string) string {
|
||||
return map[string]string{
|
||||
"investigating": "We are investigating this issue.",
|
||||
"identified": "The cause has been identified and we are working on a fix.",
|
||||
"monitoring": "A fix has been applied and we are monitoring the results.",
|
||||
"resolved": "This incident has been resolved.",
|
||||
}[status]
|
||||
}
|
||||
|
||||
func cleanIDs(ids []string) []string {
|
||||
out, seen := []string{}, map[string]bool{}
|
||||
for _, id := range ids {
|
||||
if id = strings.TrimSpace(id); id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// postToStatusPage publishes an update of a public incident and tells subscribers. An update is a
|
||||
// change of status or a note; making an incident public posts what is known so far. The note is
|
||||
// the only text customers see from an update: the internal timeline stays internal.
|
||||
func (s *Server) postToStatusPage(in *Incident, wasPublic, statusChanged bool, note string, now time.Time) {
|
||||
if !in.Public {
|
||||
return
|
||||
}
|
||||
note = strings.TrimSpace(note)
|
||||
var text string
|
||||
event := "incident.updated"
|
||||
switch {
|
||||
case !wasPublic:
|
||||
text, event = firstNonEmpty(note, in.Summary, defaultPost(in.Status)), "incident.created"
|
||||
if in.Status == "resolved" {
|
||||
event = "incident.resolved"
|
||||
}
|
||||
case statusChanged || note != "":
|
||||
text = firstNonEmpty(note, defaultPost(in.Status))
|
||||
if statusChanged && in.Status == "resolved" {
|
||||
event = "incident.resolved"
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
s.st.addPost(in.ID, in.Status, text, now)
|
||||
s.notifySubscribers(statusNote{Event: event, Title: in.Title, Status: in.Status, Message: text, URL: s.absStatusURL(nil, "/incidents/"+in.ID), Time: now.Unix()})
|
||||
}
|
||||
|
||||
// ---------- scheduled maintenance ----------
|
||||
|
||||
type maintView struct {
|
||||
Maintenance
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
func (s *Server) validateMaintenance(m *Maintenance, creating bool) error {
|
||||
m.Title, m.Message = strings.TrimSpace(m.Title), strings.TrimSpace(m.Message)
|
||||
if m.Title == "" {
|
||||
return errors.New("give the maintenance a title")
|
||||
}
|
||||
if len(m.Title) > 120 || len(m.Message) > 2000 {
|
||||
return errors.New("the title can be 120 characters and the details 2000")
|
||||
}
|
||||
if m.Start <= 0 || m.End <= m.Start {
|
||||
return errors.New("the maintenance must end after it starts")
|
||||
}
|
||||
if creating && m.End <= s.now().Unix() {
|
||||
return errors.New("the maintenance window is already over")
|
||||
}
|
||||
m.Components = cleanIDs(m.Components)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) maintenanceRoutes(mux *http.ServeMux) {
|
||||
view := func(m Maintenance) maintView { return maintView{m, m.state(s.now())} }
|
||||
mux.HandleFunc("GET /api/v1/maintenance", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
||||
out := []maintView{}
|
||||
for _, m := range s.loadMaintenance() {
|
||||
out = append(out, view(m))
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}))
|
||||
mux.HandleFunc("POST /api/v1/maintenance", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
|
||||
var m Maintenance
|
||||
if !decode(r, &m) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
if err := s.validateMaintenance(&m, true); err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
m.ID, m.Created, m.Canceled, m.StartSent, m.EndSent = newID(), s.now().Unix(), false, false, false
|
||||
if err := s.st.put("maintenance", m.ID, m); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if m.Start > s.now().Unix() {
|
||||
s.announceMaintenance("maintenance.scheduled", m)
|
||||
}
|
||||
writeJSON(w, 200, view(m))
|
||||
}))
|
||||
mux.HandleFunc("PUT /api/v1/maintenance/{id}", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
|
||||
old, err := getOf[Maintenance](s.st, "maintenance", r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
m := *old
|
||||
var in Maintenance
|
||||
if !decode(r, &in) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
m.Title, m.Message, m.Start, m.End, m.Components = in.Title, in.Message, in.Start, in.End, in.Components
|
||||
if err := s.validateMaintenance(&m, false); err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
if m.Canceled || m.state(s.now()) == "completed" && old.EndSent {
|
||||
bad(w, "that maintenance is over; schedule a new one")
|
||||
return
|
||||
}
|
||||
moved := m.Start != old.Start || m.End != old.End
|
||||
if moved && m.Start > s.now().Unix() {
|
||||
m.StartSent, m.EndSent = false, false
|
||||
}
|
||||
if err := s.st.put("maintenance", m.ID, m); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if moved && !m.StartSent {
|
||||
s.announceMaintenance("maintenance.rescheduled", m)
|
||||
}
|
||||
writeJSON(w, 200, view(m))
|
||||
}))
|
||||
mux.HandleFunc("POST /api/v1/maintenance/{id}/cancel", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
|
||||
m, err := getOf[Maintenance](s.st, "maintenance", r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if m.Canceled || m.EndSent {
|
||||
bad(w, "that maintenance is already over")
|
||||
return
|
||||
}
|
||||
m.Canceled = true
|
||||
if err := s.st.put("maintenance", m.ID, m); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.announceMaintenance("maintenance.canceled", *m)
|
||||
writeJSON(w, 200, view(*m))
|
||||
}))
|
||||
mux.HandleFunc("DELETE /api/v1/maintenance/{id}", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail2(w, s.st.del("maintenance", r.PathValue("id")))
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) announceMaintenance(event string, m Maintenance) {
|
||||
when := fmtWindow(m.Start, m.End)
|
||||
msg := map[string]string{
|
||||
"maintenance.scheduled": "Scheduled maintenance: " + when + ".",
|
||||
"maintenance.rescheduled": "The maintenance window has changed: " + when + ".",
|
||||
"maintenance.started": "Maintenance has started and is planned to finish by " + time.Unix(m.End, 0).UTC().Format("15:04 UTC") + ".",
|
||||
"maintenance.completed": "Maintenance is complete.",
|
||||
"maintenance.canceled": "This maintenance has been canceled.",
|
||||
}[event]
|
||||
if m.Message != "" && event != "maintenance.completed" {
|
||||
msg += "\n\n" + m.Message
|
||||
}
|
||||
status := strings.TrimPrefix(event, "maintenance.")
|
||||
s.notifySubscribers(statusNote{Event: event, Title: m.Title, Status: status, Message: msg, URL: s.absStatusURL(nil, ""), Time: s.now().Unix()})
|
||||
}
|
||||
|
||||
func fmtWindow(from, to int64) string {
|
||||
f, t := time.Unix(from, 0).UTC(), time.Unix(to, 0).UTC()
|
||||
if f.YearDay() == t.YearDay() && f.Year() == t.Year() {
|
||||
return f.Format("Jan 2, 2006, 15:04") + " to " + t.Format("15:04 UTC")
|
||||
}
|
||||
return f.Format("Jan 2, 2006, 15:04") + " to " + t.Format("Jan 2, 2006, 15:04 UTC")
|
||||
}
|
||||
|
||||
// runMaintenance tells subscribers when windows begin and end.
|
||||
func (s *Server) runMaintenance(now time.Time) {
|
||||
for _, m := range s.loadMaintenance() {
|
||||
if m.Canceled || m.EndSent {
|
||||
continue
|
||||
}
|
||||
t, changed := now.Unix(), false
|
||||
if !m.StartSent && t >= m.Start {
|
||||
m.StartSent, changed = true, true
|
||||
if t < m.End { // a window missed entirely, such as while Sandbar was down, is not announced
|
||||
s.announceMaintenance("maintenance.started", m)
|
||||
} else {
|
||||
m.EndSent = true
|
||||
}
|
||||
}
|
||||
if t >= m.End && !m.EndSent {
|
||||
m.EndSent, changed = true, true
|
||||
s.announceMaintenance("maintenance.completed", m)
|
||||
}
|
||||
if changed {
|
||||
s.st.put("maintenance", m.ID, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type sentMail struct{ to, subject, body string }
|
||||
|
||||
type mailBox struct {
|
||||
mu sync.Mutex
|
||||
sent []sentMail
|
||||
}
|
||||
|
||||
func (m *mailBox) send(to, subject, body string) error {
|
||||
m.mu.Lock()
|
||||
m.sent = append(m.sent, sentMail{to, subject, body})
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mailBox) all() []sentMail {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]sentMail(nil), m.sent...)
|
||||
}
|
||||
|
||||
func statusTestServer(t *testing.T) (*Server, *fakeClock, *mailBox, *httptest.Server) {
|
||||
t.Helper()
|
||||
s, clk, _ := newTestServer(t)
|
||||
mb := &mailBox{}
|
||||
s.opts.Mailer = mb.send
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return s, clk, mb, ts
|
||||
}
|
||||
|
||||
func getJSON(t *testing.T, url string, v any) int {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if v != nil {
|
||||
json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, url string, body any) (int, map[string]string) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
resp, err := http.Post(url, "application/json", strings.NewReader(string(b)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
|
||||
type statusDoc struct {
|
||||
Settings StatusSettings `json:"settings"`
|
||||
Overall string
|
||||
Groups []StatusGroup
|
||||
Monitors []pubComponent
|
||||
Incidents []pubIncident
|
||||
Maintenance []pubMaintenance
|
||||
Subscribe map[string]bool
|
||||
}
|
||||
|
||||
func TestStatusSettingsValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in StatusSettings
|
||||
ok bool
|
||||
}{
|
||||
{"empty gets a title", StatusSettings{}, true},
|
||||
{"domain", StatusSettings{Domain: "Status.Example.com"}, true},
|
||||
{"domain with scheme", StatusSettings{Domain: "https://status.example.com"}, false},
|
||||
{"domain with path", StatusSettings{Domain: "status.example.com/x"}, false},
|
||||
{"single label", StatusSettings{Domain: "localhost"}, false},
|
||||
{"own address", StatusSettings{Domain: "sandbar.test"}, false},
|
||||
{"http logo", StatusSettings{LogoURL: "http://x.test/a.png"}, false},
|
||||
{"https logo", StatusSettings{LogoURL: "https://x.test/a.png"}, true},
|
||||
{"javascript support link", StatusSettings{SupportURL: "javascript:alert(1)"}, false},
|
||||
{"mailto support link", StatusSettings{SupportURL: "mailto:help@example.com"}, true},
|
||||
{"accent", StatusSettings{Accent: "#1F5C5C"}, true},
|
||||
{"accent too light for white text", StatusSettings{Accent: "#ffee00"}, false},
|
||||
{"accent not a hex color", StatusSettings{Accent: "teal"}, false},
|
||||
{"title too long", StatusSettings{Title: strings.Repeat("x", 81)}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := validateStatusSettings(&c.in, "https://sandbar.test")
|
||||
if (err == nil) != c.ok {
|
||||
t.Errorf("%s: err = %v, want ok=%v", c.name, err, c.ok)
|
||||
}
|
||||
}
|
||||
c := StatusSettings{Domain: "Status.Example.com", Accent: "#1F5C5C"}
|
||||
validateStatusSettings(&c, "")
|
||||
if c.Domain != "status.example.com" || c.Accent != "#1f5c5c" || c.Title != "Status" {
|
||||
t.Fatalf("not normalised: %+v", c)
|
||||
}
|
||||
if r := contrastWithWhite("#000000"); r < 20.9 || r > 21.1 {
|
||||
t.Fatalf("black on white is 21:1, got %.2f", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Components sit in groups, take their state from monitors, incidents and maintenance, and the
|
||||
// banner shows the worst of them.
|
||||
func TestStatusPageComponentsAndOverall(t *testing.T) {
|
||||
s, clk, _, ts := statusTestServer(t)
|
||||
s.st.put("sgroup", "g2", &StatusGroup{ID: "g2", Name: "Apps", Order: 2})
|
||||
s.st.put("sgroup", "g1", &StatusGroup{ID: "g1", Name: "Infrastructure", Order: 1})
|
||||
s.st.put("monitor", "web", &Monitor{ID: "web", Name: "web-1", PublicName: "Website", Kind: "http", Target: "http://x", Enabled: true, Public: true, Group: "g2"})
|
||||
s.st.put("monitor", "db", &Monitor{ID: "db", Name: "Database", Kind: "tcp", Target: "x:1", Enabled: true, Public: true, Group: "g1"})
|
||||
s.st.put("monitor", "old", &Monitor{ID: "old", Name: "Orphan", Kind: "tcp", Target: "x:1", Enabled: true, Public: true, Group: "deleted"})
|
||||
s.st.put("monitor", "hid", &Monitor{ID: "hid", Name: "Hidden", Kind: "tcp", Target: "x:1", Enabled: true, Group: "g1"})
|
||||
for _, id := range []string{"web", "db", "old", "hid"} {
|
||||
s.saveState(MonitorState{MonitorID: id, Status: "up", Since: clk.Now().Unix()})
|
||||
}
|
||||
|
||||
var d statusDoc
|
||||
getJSON(t, ts.URL+"/status.json", &d)
|
||||
if d.Overall != "operational" || len(d.Monitors) != 3 {
|
||||
t.Fatalf("all up: %+v", d)
|
||||
}
|
||||
if got := []string{d.Monitors[0].ID, d.Monitors[1].ID, d.Monitors[2].ID}; got[0] != "old" && got[0] != "db" {
|
||||
t.Fatalf("order %v", got)
|
||||
}
|
||||
// ungrouped (a deleted group counts as none) first, then groups by order; the public name wins
|
||||
if d.Monitors[0].ID != "old" || d.Monitors[0].Group != "" || d.Monitors[1].ID != "db" || d.Monitors[2].Name != "Website" {
|
||||
t.Fatalf("order or names wrong: %+v", d.Monitors)
|
||||
}
|
||||
if len(d.Groups) != 2 || d.Groups[0].ID != "g1" {
|
||||
t.Fatalf("groups: %+v", d.Groups)
|
||||
}
|
||||
|
||||
// A sev2 incident on one component makes only that one partially down.
|
||||
s.st.put("incident", "i1", &Incident{ID: "i1", Title: "Slow pages", Severity: "sev2", Status: "identified", Public: true, Created: clk.Now().Unix(), Components: []string{"web"}})
|
||||
getJSON(t, ts.URL+"/status.json", &d)
|
||||
states := map[string]string{}
|
||||
for _, c := range d.Monitors {
|
||||
states[c.ID] = c.State
|
||||
}
|
||||
if states["web"] != "partial_outage" || states["db"] != "operational" || d.Overall != "degraded" {
|
||||
t.Fatalf("sev2: %v overall %s", states, d.Overall)
|
||||
}
|
||||
if len(d.Incidents) != 1 || len(d.Incidents[0].Components) != 1 || d.Incidents[0].Components[0] != "Website" {
|
||||
t.Fatalf("incident components should be public names: %+v", d.Incidents)
|
||||
}
|
||||
|
||||
// A failing monitor is a major outage.
|
||||
s.saveState(MonitorState{MonitorID: "db", Status: "down", Since: clk.Now().Unix()})
|
||||
getJSON(t, ts.URL+"/status.json", &d)
|
||||
if d.Overall != "outage" {
|
||||
t.Fatalf("overall = %s", d.Overall)
|
||||
}
|
||||
|
||||
// Maintenance in progress explains the failure instead of showing an outage.
|
||||
start := clk.Now().Add(-time.Minute).Unix()
|
||||
s.st.put("maintenance", "m1", &Maintenance{ID: "m1", Title: "Database upgrade", Start: start, End: start + 3600, Components: []string{"db"}, StartSent: true})
|
||||
s.st.put("incident", "i1", &Incident{ID: "i1", Title: "Slow pages", Severity: "sev2", Status: "resolved", Resolved: clk.Now().Unix(), Public: true, Created: clk.Now().Unix()})
|
||||
getJSON(t, ts.URL+"/status.json", &d)
|
||||
for _, c := range d.Monitors {
|
||||
states[c.ID] = c.State
|
||||
}
|
||||
if states["db"] != "maintenance" || d.Overall != "maintenance" || len(d.Maintenance) != 1 || d.Maintenance[0].State != "in_progress" {
|
||||
t.Fatalf("maintenance: %v overall %s %+v", states, d.Overall, d.Maintenance)
|
||||
}
|
||||
|
||||
// Once the window is over, the failure shows again, and the window moves to the history.
|
||||
clk.Advance(2 * time.Hour)
|
||||
getJSON(t, ts.URL+"/status.json", &d)
|
||||
if d.Overall != "outage" || len(d.Maintenance) != 0 {
|
||||
t.Fatalf("after maintenance: %s %+v", d.Overall, d.Maintenance)
|
||||
}
|
||||
}
|
||||
|
||||
// Customers see the posts written for them. Internal comments, names and private incidents stay out.
|
||||
func TestIncidentPostsArePublicAndInternalNotesAreNot(t *testing.T) {
|
||||
s, _, _, ts := statusTestServer(t)
|
||||
u := mustUser(t, s, "Alice")
|
||||
s.st.put("monitor", "web", &Monitor{ID: "web", Name: "Website", Kind: "http", Target: "http://x", Enabled: true, Public: true})
|
||||
|
||||
hidden, _ := s.DeclareIncident("Secret outage", "sev1", "internal only", u.ID, u.Name, nil)
|
||||
inc, _ := s.DeclareIncident("Checkout errors", "sev2", "Some orders fail.", u.ID, u.Name, nil)
|
||||
s.CommentIncident(inc.ID, u.Name, "ALICE-INTERNAL-COMMENT")
|
||||
if code := getJSON(t, ts.URL+"/status/api/incidents/"+inc.ID, nil); code != 404 {
|
||||
t.Fatalf("a private incident is public: %d", code)
|
||||
}
|
||||
|
||||
yes, comps := true, []string{"web"}
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Public: &yes, Components: &comps})
|
||||
ident, mon, done := "identified", "monitoring", "resolved"
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Status: &ident, Note: "A bad deploy is the cause."})
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Status: &mon})
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Note: "Rolled back; watching error rates."})
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Status: &done})
|
||||
|
||||
var pi pubIncident
|
||||
if code := getJSON(t, ts.URL+"/status/api/incidents/"+inc.ID, &pi); code != 200 {
|
||||
t.Fatalf("public incident: %d", code)
|
||||
}
|
||||
var texts, statuses []string
|
||||
for _, p := range pi.Updates {
|
||||
texts, statuses = append(texts, p.Text), append(statuses, p.Status)
|
||||
}
|
||||
// newest first
|
||||
wantStatus := []string{"resolved", "monitoring", "monitoring", "identified", "investigating"}
|
||||
if strings.Join(statuses, ",") != strings.Join(wantStatus, ",") {
|
||||
t.Fatalf("statuses %v, want %v", statuses, wantStatus)
|
||||
}
|
||||
if texts[0] != defaultPost("resolved") || texts[1] != "Rolled back; watching error rates." || texts[3] != "A bad deploy is the cause." || texts[4] != "Some orders fail." {
|
||||
t.Fatalf("texts: %q", texts)
|
||||
}
|
||||
raw, _ := json.Marshal(pi)
|
||||
for _, leak := range []string{"ALICE-INTERNAL-COMMENT", "Alice", "alice@", "Secret outage", "bad deploy is the cause by"} {
|
||||
if strings.Contains(string(raw), leak) {
|
||||
t.Fatalf("public incident leaks %q: %s", leak, raw)
|
||||
}
|
||||
}
|
||||
var h struct {
|
||||
Entries []historyEntry
|
||||
Next int64
|
||||
}
|
||||
getJSON(t, ts.URL+"/status/api/history", &h)
|
||||
if len(h.Entries) != 1 || h.Entries[0].Incident.ID != inc.ID {
|
||||
t.Fatalf("history should hold only the public incident, not %s: %+v", hidden.ID, h.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryPaging(t *testing.T) {
|
||||
s, clk, _, ts := statusTestServer(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
id := string(rune('a' + i))
|
||||
s.st.put("incident", id, &Incident{ID: id, Title: "Incident " + id, Severity: "sev3", Status: "resolved", Public: true, Created: clk.Now().Unix() - int64(i)*100, Resolved: clk.Now().Unix()})
|
||||
}
|
||||
var h struct {
|
||||
Entries []historyEntry
|
||||
Next int64
|
||||
}
|
||||
getJSON(t, ts.URL+"/status/api/history?limit=2", &h)
|
||||
if len(h.Entries) != 2 || h.Entries[0].Incident.ID != "a" || h.Next == 0 {
|
||||
t.Fatalf("page 1: %+v", h)
|
||||
}
|
||||
var seen []string
|
||||
for next := int64(1); next != 0; {
|
||||
getJSON(t, ts.URL+"/status/api/history?limit=2&before="+itoa(h.Next), &h)
|
||||
for _, e := range h.Entries {
|
||||
seen = append(seen, e.Incident.ID)
|
||||
}
|
||||
next = h.Next
|
||||
if len(seen) > 10 {
|
||||
t.Fatal("paging does not end")
|
||||
}
|
||||
}
|
||||
if strings.Join(seen, "") != "cde" {
|
||||
t.Fatalf("later pages %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int64) string { b, _ := json.Marshal(n); return string(b) }
|
||||
|
||||
var linkRe = regexp.MustCompile(`https?://\S+`)
|
||||
|
||||
func TestEmailSubscriptionFlow(t *testing.T) {
|
||||
s, _, mb, ts := statusTestServer(t)
|
||||
u := mustUser(t, s, "Alice")
|
||||
s.saveStatusSettings(StatusSettings{Title: "Acme status"})
|
||||
|
||||
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"email": "not an address"}); code != 400 {
|
||||
t.Fatalf("bad address: %d", code)
|
||||
}
|
||||
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"email": "Bob <bob@example.com>"}); code != 400 {
|
||||
t.Fatalf("display name accepted: %d", code)
|
||||
}
|
||||
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"email": "bob@example.com"}); code != 200 {
|
||||
t.Fatalf("subscribe: %d", code)
|
||||
}
|
||||
mails := mb.all()
|
||||
if len(mails) != 1 || mails[0].to != "bob@example.com" || !strings.Contains(mails[0].subject, "Confirm") {
|
||||
t.Fatalf("confirmation mail: %+v", mails)
|
||||
}
|
||||
confirm := linkRe.FindString(mails[0].body)
|
||||
|
||||
// Not confirmed yet: updates go nowhere.
|
||||
yes := true
|
||||
inc, _ := s.DeclareIncident("API errors", "sev2", "Elevated errors.", u.ID, u.Name, nil)
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Public: &yes})
|
||||
s.notifyWG.Wait()
|
||||
if n := len(mb.all()); n != 1 {
|
||||
t.Fatalf("an unconfirmed address got %d mails", n)
|
||||
}
|
||||
|
||||
// A forged link does nothing; the real one confirms and sends the visitor to the page.
|
||||
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
forged := confirm[:len(confirm)-1] + "z"
|
||||
if resp, _ := noRedirect.Get(strings.Replace(forged, "http://sandbar.test", ts.URL, 1)); resp.Header.Get("Location") != "/status/?notice=invalid" {
|
||||
t.Fatalf("forged link: %v", resp.Header.Get("Location"))
|
||||
}
|
||||
resp, _ := noRedirect.Get(strings.Replace(confirm, "http://sandbar.test", ts.URL, 1))
|
||||
if resp.StatusCode != 303 || resp.Header.Get("Location") != "/status/?notice=confirmed" {
|
||||
t.Fatalf("confirm: %d %s", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
if !strings.HasPrefix(confirm, "http://sandbar.test/status/confirm?token=") {
|
||||
t.Fatalf("confirm link: %s", confirm)
|
||||
}
|
||||
|
||||
// Subscribing again says the same thing and sends nothing more.
|
||||
postJSON(t, ts.URL+"/status/subscribe", map[string]string{"email": "bob@example.com"})
|
||||
if n := len(mb.all()); n != 1 {
|
||||
t.Fatalf("re-subscribing a confirmed address sent mail: %d", n)
|
||||
}
|
||||
|
||||
fixed := "resolved"
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Status: &fixed, Note: "Fixed."})
|
||||
s.notifyWG.Wait()
|
||||
mails = mb.all()
|
||||
if len(mails) != 2 || mails[1].subject != "[Acme status] Resolved: API errors" || !strings.Contains(mails[1].body, "Fixed.") ||
|
||||
!strings.Contains(mails[1].body, "http://sandbar.test/status/incidents/"+inc.ID) {
|
||||
t.Fatalf("update mail: %+v", mails)
|
||||
}
|
||||
|
||||
// The unsubscribe link needs a click on the page; a GET alone changes nothing.
|
||||
unsub := linkRe.FindString(mails[1].body[strings.Index(mails[1].body, "Unsubscribe:"):])
|
||||
resp, _ = noRedirect.Get(strings.Replace(unsub, "http://sandbar.test", ts.URL, 1))
|
||||
if resp.StatusCode != 303 || !strings.HasPrefix(resp.Header.Get("Location"), "/status/?unsubscribe=") {
|
||||
t.Fatalf("unsubscribe redirect: %d %s", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
token, _ := url.Parse(unsub)
|
||||
if code, _ := postJSON(t, ts.URL+"/status/unsubscribe", map[string]string{"token": "x.y"}); code != 400 {
|
||||
t.Fatalf("bad token: %d", code)
|
||||
}
|
||||
if code, _ := postJSON(t, ts.URL+"/status/unsubscribe", map[string]string{"token": token.Query().Get("token")}); code != 200 {
|
||||
t.Fatalf("unsubscribe: %d", code)
|
||||
}
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Note: "More detail."})
|
||||
s.notifyWG.Wait()
|
||||
if n := len(mb.all()); n != 2 {
|
||||
t.Fatalf("an unsubscribed address got mail: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailSubscriptionsNeedMailAndAreRateLimited(t *testing.T) {
|
||||
s, _, _, ts := statusTestServer(t)
|
||||
s.opts.Mailer = nil // no SMTP configured
|
||||
var d statusDoc
|
||||
getJSON(t, ts.URL+"/status.json", &d)
|
||||
if d.Subscribe["email"] {
|
||||
t.Fatal("email offered with no SMTP server")
|
||||
}
|
||||
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"email": "a@example.com"}); code != 400 {
|
||||
t.Fatalf("email accepted without SMTP: %d", code)
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
postJSON(t, ts.URL+"/status/subscribe", map[string]string{"webhook": "nonsense"})
|
||||
}
|
||||
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"webhook": "nonsense"}); code != 429 {
|
||||
t.Fatalf("no rate limit: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookSubscription(t *testing.T) {
|
||||
s, _, _, ts := statusTestServer(t)
|
||||
u := mustUser(t, s, "Alice")
|
||||
var mu sync.Mutex
|
||||
var got []map[string]any
|
||||
status := 200
|
||||
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var m map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&m)
|
||||
mu.Lock()
|
||||
got = append(got, m)
|
||||
w.WriteHeader(status)
|
||||
mu.Unlock()
|
||||
}))
|
||||
defer hook.Close()
|
||||
s.opts.HTTPClient = hook.Client() // tests stand in for the guarded client, which refuses loopback
|
||||
|
||||
status = 500
|
||||
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"webhook": hook.URL}); code != 400 {
|
||||
t.Fatalf("a webhook that fails its test was accepted: %d", code)
|
||||
}
|
||||
status = 200
|
||||
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"webhook": hook.URL}); code != 200 {
|
||||
t.Fatalf("subscribe: %d", code)
|
||||
}
|
||||
yes := true
|
||||
inc, _ := s.DeclareIncident("DNS trouble", "sev1", "Lookups fail.", u.ID, u.Name, nil)
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Public: &yes})
|
||||
s.notifyWG.Wait()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
last := got[len(got)-1]
|
||||
if last["event"] != "incident.created" || last["title"] != "DNS trouble" || last["status"] != "investigating" || last["message"] != "Lookups fail." ||
|
||||
!strings.Contains(last["url"].(string), "/status/incidents/"+inc.ID) || !strings.Contains(last["unsubscribe"].(string), "/status/unsubscribe?token=") {
|
||||
t.Fatalf("webhook payload: %v", last)
|
||||
}
|
||||
if got[len(got)-2]["event"] != "subscription.created" {
|
||||
t.Fatalf("first request should be the test: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceLifecycleNotifiesSubscribers(t *testing.T) {
|
||||
s, clk, mb, _ := statusTestServer(t)
|
||||
s.addSubscriber("email", "bob@example.com", true)
|
||||
start := clk.Now().Add(2 * time.Hour)
|
||||
m := Maintenance{ID: "m1", Title: "Network upgrade", Message: "Brief packet loss.", Start: start.Unix(), End: start.Add(time.Hour).Unix(), Created: clk.Now().Unix()}
|
||||
s.st.put("maintenance", m.ID, m)
|
||||
s.announceMaintenance("maintenance.scheduled", m)
|
||||
s.notifyWG.Wait()
|
||||
|
||||
s.runMaintenance(clk.Now()) // before the window: nothing
|
||||
clk.Advance(2*time.Hour + time.Minute)
|
||||
s.runMaintenance(clk.Now())
|
||||
s.runMaintenance(clk.Now()) // a second pass must not repeat itself
|
||||
clk.Advance(time.Hour)
|
||||
s.runMaintenance(clk.Now())
|
||||
s.notifyWG.Wait()
|
||||
|
||||
var subjects []string
|
||||
for _, ml := range mb.all() {
|
||||
subjects = append(subjects, ml.subject)
|
||||
}
|
||||
want := []string{"[Status] Scheduled maintenance: Network upgrade", "[Status] Maintenance started: Network upgrade", "[Status] Maintenance complete: Network upgrade"}
|
||||
if strings.Join(subjects, "|") != strings.Join(want, "|") {
|
||||
t.Fatalf("mails %q, want %q", subjects, want)
|
||||
}
|
||||
if body := mb.all()[0].body; !strings.Contains(body, "Brief packet loss.") || !strings.Contains(body, "Jan 5, 2026, 11:00 to 12:00 UTC") {
|
||||
t.Fatalf("scheduled mail: %s", body)
|
||||
}
|
||||
|
||||
// A window that passes while Sandbar is stopped is not announced after the fact.
|
||||
s.st.put("maintenance", "m2", &Maintenance{ID: "m2", Title: "Missed", Start: clk.Now().Add(-3 * time.Hour).Unix(), End: clk.Now().Add(-2 * time.Hour).Unix()})
|
||||
before := len(mb.all())
|
||||
s.runMaintenance(clk.Now())
|
||||
s.notifyWG.Wait()
|
||||
if len(mb.all()) != before {
|
||||
t.Fatal("a missed window was announced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceAdminAPI(t *testing.T) {
|
||||
s, clk, _, ts := statusTestServer(t)
|
||||
c := newAPIClient(t, ts)
|
||||
c.setup()
|
||||
_ = s
|
||||
now := clk.Now()
|
||||
body := map[string]any{"title": "Upgrade", "message": "m", "start": now.Add(time.Hour).Unix(), "end": now.Add(2 * time.Hour).Unix(), "components": []string{"a", "a", " "}}
|
||||
var created maintView
|
||||
if code := c.do("POST", "/api/v1/maintenance", body, &created); code != 200 || created.State != "scheduled" || len(created.Components) != 1 {
|
||||
t.Fatalf("create: %d %+v", code, created)
|
||||
}
|
||||
if code := c.do("POST", "/api/v1/maintenance", map[string]any{"title": "x", "start": now.Unix() + 10, "end": now.Unix() + 5}, nil); code != 400 {
|
||||
t.Fatalf("end before start: %d", code)
|
||||
}
|
||||
if code := c.do("POST", "/api/v1/maintenance", map[string]any{"title": "x", "start": now.Unix() - 100, "end": now.Unix() - 50}, nil); code != 400 {
|
||||
t.Fatalf("window already over: %d", code)
|
||||
}
|
||||
body["title"] = "Upgrade, moved"
|
||||
if code := c.do("PUT", "/api/v1/maintenance/"+created.ID, body, &created); code != 200 || created.Title != "Upgrade, moved" {
|
||||
t.Fatalf("update: %d %+v", code, created)
|
||||
}
|
||||
if code := c.do("POST", "/api/v1/maintenance/"+created.ID+"/cancel", nil, &created); code != 200 || created.State != "canceled" {
|
||||
t.Fatalf("cancel: %d %+v", code, created)
|
||||
}
|
||||
if code := c.do("POST", "/api/v1/maintenance/"+created.ID+"/cancel", nil, nil); code != 400 {
|
||||
t.Fatalf("second cancel: %d", code)
|
||||
}
|
||||
var d statusDoc
|
||||
getJSON(t, ts.URL+"/status.json", &d)
|
||||
if len(d.Maintenance) != 0 {
|
||||
t.Fatalf("a canceled window is still listed: %+v", d.Maintenance)
|
||||
}
|
||||
if code := newAPIClient(t, ts).do("POST", "/api/v1/maintenance", body, nil); code != 401 {
|
||||
t.Fatalf("anonymous create: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusSettingsAPIAndBranding(t *testing.T) {
|
||||
_, _, _, ts := statusTestServer(t)
|
||||
c := newAPIClient(t, ts)
|
||||
c.setup()
|
||||
if code := c.do("PUT", "/api/v1/statuspage", StatusSettings{Title: "Acme", Accent: "#ffee00"}, nil); code != 400 {
|
||||
t.Fatalf("low contrast accent: %d", code)
|
||||
}
|
||||
if code := c.do("PUT", "/api/v1/statuspage", StatusSettings{Title: "Acme <Status>", Description: "Live status", Accent: "#0b5394", LogoURL: "https://acme.test/logo.svg", Domain: "status.acme.test"}, nil); code != 200 {
|
||||
t.Fatalf("save: %d", code)
|
||||
}
|
||||
var d statusDoc
|
||||
getJSON(t, ts.URL+"/status.json", &d)
|
||||
if d.Settings.Title != "Acme <Status>" || d.Settings.Accent != "#0b5394" || d.Settings.Domain != "" {
|
||||
t.Fatalf("public settings: %+v", d.Settings)
|
||||
}
|
||||
resp, _ := http.Get(ts.URL + "/status")
|
||||
page, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(page), "<title>Acme <Status></title>") || strings.Contains(string(page), "__") {
|
||||
t.Fatalf("page shell: %s", page[:300])
|
||||
}
|
||||
if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "img-src 'self' data: https:") {
|
||||
t.Fatalf("a logo needs img-src https: on the status page: %s", csp)
|
||||
}
|
||||
resp, _ = http.Get(ts.URL + "/")
|
||||
if csp := resp.Header.Get("Content-Security-Policy"); strings.Contains(csp, "https:") {
|
||||
t.Fatalf("the admin app should not allow remote images: %s", csp)
|
||||
}
|
||||
}
|
||||
|
||||
// The custom domain serves the status page at its root and nothing else.
|
||||
func TestCustomDomain(t *testing.T) {
|
||||
s, _, mb, ts := statusTestServer(t)
|
||||
s.saveStatusSettings(StatusSettings{Title: "Acme status", Domain: "status.acme.test"})
|
||||
s.st.put("monitor", "web", &Monitor{ID: "web", Name: "Website", Kind: "http", Target: "http://x", Enabled: true, Public: true})
|
||||
u := mustUser(t, s, "Alice")
|
||||
yes := true
|
||||
inc, _ := s.DeclareIncident("Outage", "sev2", "Down.", u.ID, u.Name, nil)
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Public: &yes})
|
||||
|
||||
get := func(host, path string) (int, string) {
|
||||
req, _ := http.NewRequest("GET", ts.URL+path, nil)
|
||||
req.Host = host
|
||||
resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
host := "Status.Acme.Test:443"
|
||||
if code, body := get(host, "/"); code != 200 || !strings.Contains(body, "<title>Acme status</title>") || !strings.Contains(body, "const BASE = '';") {
|
||||
t.Fatalf("root on the custom domain: %d %.200s", code, body)
|
||||
}
|
||||
for _, path := range []string{"/history", "/incidents/" + inc.ID} {
|
||||
if code, body := get(host, path); code != 200 || !strings.Contains(body, "<main") {
|
||||
t.Fatalf("%s: %d", path, code)
|
||||
}
|
||||
}
|
||||
if code, body := get(host, "/status.json"); code != 200 || !strings.Contains(body, "Website") {
|
||||
t.Fatalf("status.json: %d", code)
|
||||
}
|
||||
if code, body := get(host, "/feed.rss"); code != 200 || !strings.Contains(body, "<rss") {
|
||||
t.Fatalf("feed: %d", code)
|
||||
}
|
||||
for _, path := range []string{"/api/v1/users", "/healthz", "/metrics", "/alerts/sse", "/script.js", "/statusx"} {
|
||||
if code, _ := get(host, path); code != 404 {
|
||||
t.Fatalf("%s should not exist on the status domain, got %d", path, code)
|
||||
}
|
||||
}
|
||||
// On Sandbar's own address the app is where it was, and the status page stays under /status.
|
||||
if code, body := get("sandbar.test", "/"); code != 200 || !strings.Contains(body, "Sandbar") {
|
||||
t.Fatalf("admin root: %d", code)
|
||||
}
|
||||
if code, body := get("sandbar.test", "/status"); code != 200 || !strings.Contains(body, "const BASE = '/status';") {
|
||||
t.Fatalf("own address: %d", code)
|
||||
}
|
||||
|
||||
// Subscriber links point at the custom domain; the redirect after confirming stays on it.
|
||||
s.opts.URL = "https://sandbar.test"
|
||||
s.addSubscriber("email", "bob@example.com", true)
|
||||
s.notifyWG.Wait()
|
||||
n := len(mb.all())
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Note: "Still working."})
|
||||
s.notifyWG.Wait()
|
||||
mails := mb.all()
|
||||
if len(mails) != n+1 || !strings.Contains(mails[n].body, "https://status.acme.test/incidents/"+inc.ID) || !strings.Contains(mails[n].body, "https://status.acme.test/unsubscribe?token=") {
|
||||
t.Fatalf("links in mail: %+v", mails[n:])
|
||||
}
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/status/subscribe", strings.NewReader(`{"email":"carol@example.com"}`))
|
||||
req.Host = "status.acme.test"
|
||||
http.DefaultClient.Do(req)
|
||||
conf := mb.all()[len(mb.all())-1]
|
||||
if !strings.Contains(conf.body, "https://status.acme.test/confirm?token=") {
|
||||
t.Fatalf("confirm link: %s", conf.body)
|
||||
}
|
||||
req, _ = http.NewRequest("GET", ts.URL+strings.TrimPrefix(linkRe.FindString(conf.body), "https://status.acme.test"), nil)
|
||||
req.Host = "status.acme.test"
|
||||
resp, _ := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
|
||||
if resp.Header.Get("Location") != "/?notice=confirmed" {
|
||||
t.Fatalf("confirming on the custom domain should stay on it, got %s", resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedIsValidRSS(t *testing.T) {
|
||||
s, clk, _, ts := statusTestServer(t)
|
||||
u := mustUser(t, s, "Alice")
|
||||
s.saveStatusSettings(StatusSettings{Title: "Acme status"})
|
||||
yes := true
|
||||
inc, _ := s.DeclareIncident("Login <broken> & slow", "sev2", "We are looking.", u.ID, u.Name, nil)
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Public: &yes})
|
||||
clk.Advance(10 * time.Minute)
|
||||
done := "resolved"
|
||||
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Status: &done, Note: "All good."})
|
||||
hidden, _ := s.DeclareIncident("Private thing", "sev3", "", u.ID, u.Name, nil)
|
||||
_ = hidden
|
||||
s.st.put("maintenance", "m", &Maintenance{ID: "m", Title: "Planned", Start: clk.Now().Add(time.Hour).Unix(), End: clk.Now().Add(2 * time.Hour).Unix(), Created: clk.Now().Unix()})
|
||||
|
||||
resp, err := http.Get(ts.URL + "/status/feed.rss")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/rss+xml") {
|
||||
t.Fatalf("content type %s", ct)
|
||||
}
|
||||
var feed struct {
|
||||
Channel struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Items []struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID string `xml:"guid"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Description string `xml:"description"`
|
||||
} `xml:"item"`
|
||||
} `xml:"channel"`
|
||||
}
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&feed); err != nil {
|
||||
t.Fatalf("not XML: %v", err)
|
||||
}
|
||||
c := feed.Channel
|
||||
if c.Title != "Acme status" || c.Link != "http://sandbar.test/status" || len(c.Items) != 3 {
|
||||
t.Fatalf("channel: %+v", c)
|
||||
}
|
||||
var resolved bool
|
||||
for _, it := range c.Items {
|
||||
if strings.Contains(it.Title, "Private") {
|
||||
t.Fatal("private incident in feed")
|
||||
}
|
||||
if it.Title == "Resolved: Login <broken> & slow" && it.Description == "All good." && it.Link == "http://sandbar.test/status/incidents/"+inc.ID {
|
||||
resolved = true
|
||||
}
|
||||
if _, err := time.Parse(time.RFC1123Z, it.PubDate); err != nil {
|
||||
t.Fatalf("pubDate %q: %v", it.PubDate, err)
|
||||
}
|
||||
}
|
||||
if !resolved {
|
||||
t.Fatalf("resolved item missing: %+v", c.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// Declaring with public set goes live and tells subscribers in one step; existing databases with
|
||||
// events from before posts existed still load.
|
||||
func TestDeclarePublicAndLegacyEvents(t *testing.T) {
|
||||
s, _, mb, ts := statusTestServer(t)
|
||||
c := newAPIClient(t, ts)
|
||||
c.setup()
|
||||
s.addSubscriber("email", "bob@example.com", true)
|
||||
var inc Incident
|
||||
if code := c.do("POST", "/api/v1/incidents", map[string]any{"title": "Slow", "severity": "sev3", "summary": "Pages are slow.", "public": true, "components": []string{"x"}}, &inc); code != 200 || !inc.Public {
|
||||
t.Fatalf("declare: %d %+v", code, inc)
|
||||
}
|
||||
s.notifyWG.Wait()
|
||||
if m := mb.all(); len(m) != 1 || !strings.Contains(m[0].subject, "New incident: Slow") {
|
||||
t.Fatalf("mails: %+v", m)
|
||||
}
|
||||
// Old rows: an events table without the status column is upgraded in place and keeps its rows.
|
||||
s.st.db.Exec(`DROP TABLE events`)
|
||||
s.st.db.Exec(`CREATE TABLE events (id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, ref TEXT NOT NULL, time INTEGER NOT NULL, kind TEXT NOT NULL, actor TEXT NOT NULL, text TEXT NOT NULL)`)
|
||||
s.st.db.Exec(`INSERT INTO events(scope,ref,time,kind,actor,text) VALUES('incident','x',1,'update','a','t')`)
|
||||
s.st.db.Exec(`ALTER TABLE events ADD COLUMN status TEXT NOT NULL DEFAULT ''`)
|
||||
if ev := s.st.events("incident", "x"); len(ev) != 1 || ev[0].Status != "" {
|
||||
t.Fatalf("events: %+v", ev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForgetsStaleUnconfirmedSubscribers(t *testing.T) {
|
||||
s, clk, _, _ := statusTestServer(t)
|
||||
old, _, _ := s.addSubscriber("email", "old@example.com", false)
|
||||
s.addSubscriber("email", "kept@example.com", true)
|
||||
s.st.db.Exec(`UPDATE subscribers SET created=? WHERE id=?`, clk.Now().AddDate(0, 0, -8).Unix(), old.ID)
|
||||
s.st.db.Exec(`UPDATE subscribers SET created=? WHERE kind='email' AND confirmed=1`, clk.Now().AddDate(0, 0, -300).Unix())
|
||||
s.st.prune(clk.Now(), 30, 30, 365)
|
||||
subs := s.confirmedSubscribers()
|
||||
var n int
|
||||
s.st.db.QueryRow(`SELECT COUNT(*) FROM subscribers`).Scan(&n)
|
||||
if n != 1 || len(subs) != 1 || subs[0].Target != "kept@example.com" {
|
||||
t.Fatalf("after prune: %d rows, confirmed %+v", n, subs)
|
||||
}
|
||||
// Addresses are sealed at rest.
|
||||
var raw string
|
||||
s.st.db.QueryRow(`SELECT target FROM subscribers`).Scan(&raw)
|
||||
if strings.Contains(raw, "kept@") {
|
||||
t.Fatalf("address stored in the clear: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Following the status page: email (confirmed by a link), webhook (confirmed by answering a test
|
||||
// request) and RSS. Every update to a public incident, and every maintenance window, goes to all
|
||||
// confirmed subscribers.
|
||||
|
||||
// statusNote is one update, as sent to subscribers.
|
||||
type statusNote struct {
|
||||
Event string `json:"event"` // incident.created, incident.updated, incident.resolved, maintenance.scheduled, .rescheduled, .started, .completed, .canceled
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
URL string `json:"url"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
func (n statusNote) subject(page string) string {
|
||||
label := map[string]string{
|
||||
"incident.created": "New incident", "incident.updated": "Incident update", "incident.resolved": "Resolved",
|
||||
"maintenance.scheduled": "Scheduled maintenance", "maintenance.rescheduled": "Maintenance rescheduled",
|
||||
"maintenance.started": "Maintenance started", "maintenance.completed": "Maintenance complete", "maintenance.canceled": "Maintenance canceled",
|
||||
}[n.Event]
|
||||
return "[" + page + "] " + label + ": " + n.Title
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // email or webhook
|
||||
Target string `json:"target"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
func (s *Server) mailEnabled() bool { return s.opts.Mailer != nil || s.opts.SMTP.Host != "" }
|
||||
|
||||
// ---------- links that act for a subscriber ----------
|
||||
|
||||
// statusSecret signs confirm and unsubscribe links, so none needs a stored token.
|
||||
func (s *Server) statusSecret() []byte {
|
||||
s.statusMu.Lock()
|
||||
defer s.statusMu.Unlock()
|
||||
if s.statusKey == nil {
|
||||
var v struct{ Value string }
|
||||
if s.st.get("secret", "status", &v) != nil || v.Value == "" {
|
||||
v.Value = newToken()
|
||||
s.st.put("secret", "status", v)
|
||||
}
|
||||
s.statusKey = []byte(v.Value)
|
||||
}
|
||||
return s.statusKey
|
||||
}
|
||||
|
||||
func (s *Server) subToken(purpose, id string) string {
|
||||
m := hmac.New(sha256.New, s.statusSecret())
|
||||
m.Write([]byte(purpose + "." + id))
|
||||
return id + "." + hex.EncodeToString(m.Sum(nil))[:32]
|
||||
}
|
||||
|
||||
func (s *Server) parseSubToken(purpose, tok string) (string, bool) {
|
||||
id, _, ok := strings.Cut(tok, ".")
|
||||
return id, ok && hmac.Equal([]byte(tok), []byte(s.subToken(purpose, id)))
|
||||
}
|
||||
|
||||
// ---------- storage ----------
|
||||
|
||||
func subKey(kind, target string) string { return hashToken(kind + ":" + strings.ToLower(target)) }
|
||||
|
||||
// addSubscriber stores a subscriber, or returns the one already stored for that address.
|
||||
func (s *Server) addSubscriber(kind, target string, confirmed bool) (sub subscriber, existed bool, err error) {
|
||||
var n int
|
||||
s.st.db.QueryRow(`SELECT COUNT(*) FROM subscribers`).Scan(&n)
|
||||
key := subKey(kind, target)
|
||||
if got, ok := s.subscriberBy(`key=?`, key); ok {
|
||||
return got, true, nil
|
||||
}
|
||||
if n >= 20000 {
|
||||
return sub, false, errors.New("this status page cannot take more subscribers")
|
||||
}
|
||||
sub = subscriber{ID: newID(), Kind: kind, Target: target, Confirmed: confirmed, Created: s.now().Unix()}
|
||||
_, err = s.st.db.Exec(`INSERT INTO subscribers(id,kind,target,key,confirmed,created) VALUES(?,?,?,?,?,?)`,
|
||||
sub.ID, kind, s.st.seal(target), key, boolInt(confirmed), sub.Created)
|
||||
return sub, false, err
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Server) subscriberBy(where string, args ...any) (subscriber, bool) {
|
||||
var sub subscriber
|
||||
var sealed string
|
||||
var conf int
|
||||
err := s.st.db.QueryRow(`SELECT id,kind,target,confirmed,created FROM subscribers WHERE `+where, args...).Scan(&sub.ID, &sub.Kind, &sealed, &conf, &sub.Created)
|
||||
if errors.Is(err, sql.ErrNoRows) || err != nil {
|
||||
return sub, false
|
||||
}
|
||||
sub.Target, sub.Confirmed = s.st.open(sealed), conf == 1
|
||||
return sub, true
|
||||
}
|
||||
|
||||
func (s *Server) confirmedSubscribers() []subscriber {
|
||||
rows, err := s.st.db.Query(`SELECT id,kind,target,created FROM subscribers WHERE confirmed=1`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []subscriber
|
||||
for rows.Next() {
|
||||
var sub subscriber
|
||||
var sealed string
|
||||
if rows.Scan(&sub.ID, &sub.Kind, &sealed, &sub.Created) == nil {
|
||||
sub.Target, sub.Confirmed = s.st.open(sealed), true
|
||||
out = append(out, sub)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listSubscribers is for administrators. A webhook address often carries a secret, so only its host is shown.
|
||||
func (s *Server) listSubscribers(w http.ResponseWriter, r *http.Request) {
|
||||
out := []subscriber{}
|
||||
rows, err := s.st.db.Query(`SELECT id,kind,target,confirmed,created FROM subscribers ORDER BY created DESC LIMIT 1000`)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var sub subscriber
|
||||
var sealed string
|
||||
var conf int
|
||||
if rows.Scan(&sub.ID, &sub.Kind, &sealed, &conf, &sub.Created) != nil {
|
||||
continue
|
||||
}
|
||||
sub.Target, sub.Confirmed = s.st.open(sealed), conf == 1
|
||||
if sub.Kind == "webhook" {
|
||||
if u, err := url.Parse(sub.Target); err == nil {
|
||||
sub.Target = u.Host
|
||||
}
|
||||
}
|
||||
out = append(out, sub)
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
// ---------- sending ----------
|
||||
|
||||
func (s *Server) notifySubscribers(n statusNote) {
|
||||
subs := s.confirmedSubscribers()
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
title := s.statusSettings().Title
|
||||
s.notifyWG.Add(1)
|
||||
go func() {
|
||||
defer s.notifyWG.Done()
|
||||
for _, sub := range subs {
|
||||
if err := s.deliver(sub, n, title); err != nil {
|
||||
log.Printf("status page subscriber %s: %v", sub.ID, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) deliver(sub subscriber, n statusNote, pageTitle string) error {
|
||||
unsub := s.absStatusURL(nil, "/unsubscribe?token="+url.QueryEscape(s.subToken("unsubscribe", sub.ID)))
|
||||
switch sub.Kind {
|
||||
case "email":
|
||||
body := n.Message + "\n\n" + n.URL + "\n\nYou get this because you subscribed to updates from " + pageTitle + ". Unsubscribe: " + unsub
|
||||
return s.Mail(sub.Target, n.subject(pageTitle), body)
|
||||
case "webhook":
|
||||
return s.postWebhook(sub.Target, map[string]any{"page": pageTitle, "event": n.Event, "title": n.Title, "status": n.Status, "message": n.Message,
|
||||
"url": n.URL, "time": n.Time, "unsubscribe": unsub})
|
||||
}
|
||||
return errors.New("unknown subscriber kind")
|
||||
}
|
||||
|
||||
func (s *Server) postWebhook(target string, payload any) error {
|
||||
b, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest("POST", target, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Sandbar status page")
|
||||
resp, err := s.httpClient(false).Do(req) // visitors choose these addresses, so private networks stay out of reach
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("the webhook answered %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- public endpoints ----------
|
||||
|
||||
// subscribeAllowed lets each address make a handful of requests an hour.
|
||||
func (s *Server) subscribeAllowed(ip string) bool {
|
||||
s.subMu.Lock()
|
||||
defer s.subMu.Unlock()
|
||||
now := s.now()
|
||||
keep := s.subTry[ip][:0]
|
||||
for _, t := range s.subTry[ip] {
|
||||
if now.Sub(t) < time.Hour {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
if len(keep) >= 6 {
|
||||
s.subTry[ip] = keep
|
||||
return false
|
||||
}
|
||||
if len(s.subTry) > 5000 { // forget addresses that have gone quiet
|
||||
for k, v := range s.subTry {
|
||||
if len(v) == 0 || now.Sub(v[len(v)-1]) >= time.Hour {
|
||||
delete(s.subTry, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.subTry[ip] = append(keep, now)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) followStatusHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
Webhook string `json:"webhook"`
|
||||
}
|
||||
if !decode(r, &in) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
if !s.subscribeAllowed(s.realIP(r)) {
|
||||
writeJSON(w, 429, map[string]string{"error": "Too many attempts. Try again in an hour."})
|
||||
return
|
||||
}
|
||||
title := s.statusSettings().Title
|
||||
switch {
|
||||
case strings.TrimSpace(in.Email) != "":
|
||||
if !s.mailEnabled() {
|
||||
bad(w, "email updates are not set up on this page")
|
||||
return
|
||||
}
|
||||
addr := strings.TrimSpace(in.Email)
|
||||
if a, err := mail.ParseAddress(addr); err != nil || a.Address != addr || len(addr) > 254 || strings.ContainsAny(addr, "\r\n<>") {
|
||||
bad(w, "enter a valid email address")
|
||||
return
|
||||
}
|
||||
sub, existed, err := s.addSubscriber("email", addr, false)
|
||||
if err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
// The answer is the same whether or not the address was known, so the form cannot be used to probe it.
|
||||
if !(existed && sub.Confirmed) {
|
||||
link := s.absStatusURL(r, "/confirm?token="+url.QueryEscape(s.subToken("confirm", sub.ID)))
|
||||
body := "Confirm that you want email updates from " + title + " by opening this link:\n\n" + link + "\n\nIf you did not ask for this, ignore this email and nothing will be sent."
|
||||
if err := s.Mail(addr, "["+title+"] Confirm your subscription", body); err != nil {
|
||||
log.Printf("status page confirmation to %s: %v", addr, err)
|
||||
writeJSON(w, 502, map[string]string{"error": "The confirmation email could not be sent. Try again later."})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"message": "Check your email and open the link to finish subscribing."})
|
||||
case strings.TrimSpace(in.Webhook) != "":
|
||||
target := strings.TrimSpace(in.Webhook)
|
||||
if u, err := url.Parse(target); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || len(target) > 500 {
|
||||
bad(w, "the webhook must be an http:// or https:// address")
|
||||
return
|
||||
}
|
||||
sub, existed, err := s.addSubscriber("webhook", target, false)
|
||||
if err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
if existed && sub.Confirmed {
|
||||
writeJSON(w, 200, map[string]string{"message": "That webhook is subscribed."})
|
||||
return
|
||||
}
|
||||
// Answering a test request shows the address belongs to something that wants these updates.
|
||||
unsub := s.absStatusURL(r, "/unsubscribe?token="+url.QueryEscape(s.subToken("unsubscribe", sub.ID)))
|
||||
if err := s.postWebhook(target, map[string]any{"page": title, "event": "subscription.created", "message": "This address is now subscribed to " + title + ".", "time": s.now().Unix(), "unsubscribe": unsub}); err != nil {
|
||||
s.st.db.Exec(`DELETE FROM subscribers WHERE id=?`, sub.ID)
|
||||
bad(w, "that address did not accept a test request, so it was not subscribed; check the URL and try again")
|
||||
return
|
||||
}
|
||||
s.st.db.Exec(`UPDATE subscribers SET confirmed=1 WHERE id=?`, sub.ID)
|
||||
writeJSON(w, 200, map[string]string{"message": "Subscribed. A test request was sent to the webhook."})
|
||||
default:
|
||||
bad(w, "enter an email address or a webhook address")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) confirmSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
notice := "invalid"
|
||||
if id, ok := s.parseSubToken("confirm", r.URL.Query().Get("token")); ok {
|
||||
if res, err := s.st.db.Exec(`UPDATE subscribers SET confirmed=1 WHERE id=?`, id); err == nil {
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
notice = "confirmed"
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, pagePrefix(r)+"/?notice="+notice, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// unsubscribeRedirect opens the page with a button instead of acting on a GET, since mail
|
||||
// scanners follow every link in a message.
|
||||
func (s *Server) unsubscribeRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, pagePrefix(r)+"/?unsubscribe="+url.QueryEscape(r.URL.Query().Get("token")), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) unsubscribeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
decode(r, &in)
|
||||
id, ok := s.parseSubToken("unsubscribe", in.Token)
|
||||
if !ok {
|
||||
bad(w, "that unsubscribe link is not valid")
|
||||
return
|
||||
}
|
||||
s.st.db.Exec(`DELETE FROM subscribers WHERE id=?`, id)
|
||||
writeJSON(w, 200, map[string]string{"message": "You are unsubscribed."})
|
||||
}
|
||||
|
||||
// ---------- RSS ----------
|
||||
|
||||
type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID string `xml:"guid"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Desc string `xml:"description"`
|
||||
t int64
|
||||
}
|
||||
|
||||
func (s *Server) statusFeed(w http.ResponseWriter, r *http.Request) {
|
||||
now := s.now()
|
||||
cfg := s.statusSettings()
|
||||
_, _, names := s.publicComponents()
|
||||
var items []rssItem
|
||||
add := func(id string, t int64, title, link, text string) {
|
||||
items = append(items, rssItem{Title: title, Link: link, GUID: id, PubDate: time.Unix(t, 0).UTC().Format(time.RFC1123Z), Desc: text, t: t})
|
||||
}
|
||||
incs, _ := listOf[Incident](s.st, "incident")
|
||||
for _, in := range incs {
|
||||
if !in.Public {
|
||||
continue
|
||||
}
|
||||
pi := s.publicIncident(in, names)
|
||||
for _, u := range pi.Updates {
|
||||
add(fmt.Sprintf("%s-%d", in.ID, u.Time), u.Time, statusLabel(u.Status)+": "+in.Title, s.absStatusURL(r, "/incidents/"+in.ID), u.Text)
|
||||
}
|
||||
}
|
||||
for _, m := range s.loadMaintenance() {
|
||||
if m.Canceled {
|
||||
continue
|
||||
}
|
||||
add("maintenance-"+m.ID, m.Created, "Scheduled maintenance: "+m.Title, s.absStatusURL(r, ""), strings.TrimSpace(fmtWindow(m.Start, m.End)+". "+m.Message))
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool { return items[i].t > items[j].t })
|
||||
if len(items) > 50 {
|
||||
items = items[:50]
|
||||
}
|
||||
type channel struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
LastBuildDate string `xml:"lastBuildDate"`
|
||||
Items []rssItem `xml:"item"`
|
||||
}
|
||||
feed := struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Channel channel `xml:"channel"`
|
||||
}{Version: "2.0", Channel: channel{Title: cfg.Title, Link: s.absStatusURL(r, ""), Description: firstNonEmpty(cfg.Description, "Incidents and maintenance for "+cfg.Title),
|
||||
LastBuildDate: now.UTC().Format(time.RFC1123Z), Items: items}}
|
||||
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "max-age=60")
|
||||
w.Write([]byte(xml.Header))
|
||||
enc := xml.NewEncoder(w)
|
||||
enc.Indent("", " ")
|
||||
enc.Encode(feed)
|
||||
}
|
||||
|
||||
func statusLabel(status string) string {
|
||||
if l := map[string]string{"investigating": "Investigating", "identified": "Identified", "monitoring": "Monitoring", "resolved": "Resolved"}[status]; l != "" {
|
||||
return l
|
||||
}
|
||||
return "Update"
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/secretbox"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Store is Sandbar's one SQLite file (pure Go, WAL mode). Configuration that is
|
||||
// edited by hand and rarely queried (channels, monitors, schedules, policies,
|
||||
// incidents) is kept as JSON documents in one table; things that grow or are
|
||||
// searched (messages, checks, alerts, timeline events) have proper indexed tables.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
box *secretbox.Box
|
||||
}
|
||||
|
||||
var errNotFound = errors.New("not found")
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
hash TEXT NOT NULL, role TEXT NOT NULL, phone TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS apikeys (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, hash TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS docs (
|
||||
kind TEXT NOT NULL, id TEXT NOT NULL, body TEXT NOT NULL, PRIMARY KEY (kind, id));
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY, topic TEXT NOT NULL, time INTEGER NOT NULL, title TEXT NOT NULL,
|
||||
body TEXT NOT NULL, priority INTEGER NOT NULL, tags TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS messages_topic ON messages(topic, time);
|
||||
CREATE TABLE IF NOT EXISTS checks (
|
||||
monitor_id TEXT NOT NULL, time INTEGER NOT NULL, ok INTEGER NOT NULL, ms INTEGER NOT NULL, detail TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS checks_monitor ON checks(monitor_id, time);
|
||||
-- One row per monitor per UTC day, kept far longer than raw checks: it feeds the status page's uptime bars.
|
||||
CREATE TABLE IF NOT EXISTS daily (
|
||||
monitor_id TEXT NOT NULL, day TEXT NOT NULL, checks INTEGER NOT NULL, fails INTEGER NOT NULL, PRIMARY KEY (monitor_id, day));
|
||||
-- Website analytics: one row per page view or event. No IP address or user agent is kept, only labels.
|
||||
CREATE TABLE IF NOT EXISTS hits (
|
||||
website TEXT NOT NULL, time INTEGER NOT NULL, visitor TEXT NOT NULL, event TEXT NOT NULL, path TEXT NOT NULL,
|
||||
referrer TEXT NOT NULL, browser TEXT NOT NULL, os TEXT NOT NULL, device TEXT NOT NULL, country TEXT NOT NULL,
|
||||
lang TEXT NOT NULL, data TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS hits_site ON hits(website, time);
|
||||
CREATE TABLE IF NOT EXISTS mstate (
|
||||
monitor_id TEXT PRIMARY KEY, status TEXT NOT NULL, since INTEGER NOT NULL, last_check INTEGER NOT NULL,
|
||||
last_ms INTEGER NOT NULL, fails INTEGER NOT NULL, last_ping INTEGER NOT NULL, detail TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id TEXT PRIMARY KEY, key TEXT NOT NULL, source TEXT NOT NULL, summary TEXT NOT NULL, detail TEXT NOT NULL,
|
||||
severity TEXT NOT NULL, status TEXT NOT NULL, policy_id TEXT NOT NULL, step INTEGER NOT NULL,
|
||||
next_at INTEGER NOT NULL, created INTEGER NOT NULL, updated INTEGER NOT NULL, count INTEGER NOT NULL,
|
||||
acked_by TEXT NOT NULL DEFAULT '', acked_at INTEGER NOT NULL DEFAULT 0, snooze_until INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_at INTEGER NOT NULL DEFAULT 0, incident_id TEXT NOT NULL DEFAULT '');
|
||||
CREATE INDEX IF NOT EXISTS alerts_key ON alerts(key, status);
|
||||
CREATE INDEX IF NOT EXISTS alerts_due ON alerts(status, next_at);
|
||||
-- Web Push registrations, one per installed companion app.
|
||||
CREATE TABLE IF NOT EXISTS push_subs (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL, auth TEXT NOT NULL, topics TEXT NOT NULL, device TEXT NOT NULL, created INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, ref TEXT NOT NULL, time INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL, actor TEXT NOT NULL, text TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS events_ref ON events(scope, ref, time);
|
||||
-- People who follow the public status page by email or webhook. The address is sealed; key is a hash of it, for lookups.
|
||||
CREATE TABLE IF NOT EXISTS subscribers (
|
||||
id TEXT PRIMARY KEY, kind TEXT NOT NULL, target TEXT NOT NULL, key TEXT NOT NULL UNIQUE,
|
||||
confirmed INTEGER NOT NULL, created INTEGER NOT NULL);
|
||||
`
|
||||
|
||||
// OpenStore opens (creating if needed) the database in dir.
|
||||
func OpenStore(dir string, box *secretbox.Box) (*Store, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dsn := "file:" + filepath.ToSlash(filepath.Join(dir, "sandbar.db")) +
|
||||
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(4)
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
// Older databases have no status column on events: public incident posts carry the status they announce.
|
||||
db.Exec(`ALTER TABLE events ADD COLUMN status TEXT NOT NULL DEFAULT ''`)
|
||||
st := &Store{db: db, box: box}
|
||||
st.backfillDaily()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// backfillDaily fills the daily table from the raw checks, but only when it is empty: a database
|
||||
// from before the table existed. After that every check is counted as it is recorded.
|
||||
func (s *Store) backfillDaily() {
|
||||
var have int
|
||||
if s.db.QueryRow(`SELECT count(*) FROM daily`).Scan(&have) == nil && have == 0 {
|
||||
s.db.Exec(`INSERT OR IGNORE INTO daily(monitor_id,day,checks,fails)
|
||||
SELECT monitor_id, date(time,'unixepoch'), count(*), count(*)-sum(ok) FROM checks GROUP BY monitor_id, date(time,'unixepoch')`)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// ---------- ids and tokens ----------
|
||||
|
||||
func newID() string {
|
||||
b := make([]byte, 9)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func newToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func hashToken(t string) string {
|
||||
sum := sha256.Sum256([]byte(t))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ---------- JSON documents ----------
|
||||
|
||||
func (s *Store) put(kind, id string, v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO docs(kind,id,body) VALUES(?,?,?) ON CONFLICT(kind,id) DO UPDATE SET body=excluded.body`, kind, id, string(b))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) get(kind, id string, v any) error {
|
||||
var body string
|
||||
err := s.db.QueryRow(`SELECT body FROM docs WHERE kind=? AND id=?`, kind, id).Scan(&body)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return errNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal([]byte(body), v)
|
||||
}
|
||||
|
||||
// list decodes every document of a kind through add, which is called with a
|
||||
// fresh pointer-to-value from newItem and then appended by the caller.
|
||||
func (s *Store) list(kind string, each func(raw []byte) error) error {
|
||||
rows, err := s.db.Query(`SELECT body FROM docs WHERE kind=? ORDER BY id`, kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var bodies []string
|
||||
for rows.Next() {
|
||||
var b string
|
||||
if err := rows.Scan(&b); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
bodies = append(bodies, b)
|
||||
}
|
||||
rows.Close()
|
||||
for _, b := range bodies {
|
||||
if err := each([]byte(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) del(kind, id string) error {
|
||||
res, err := s.db.Exec(`DELETE FROM docs WHERE kind=? AND id=?`, kind, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func listOf[T any](s *Store, kind string) ([]T, error) {
|
||||
out := []T{}
|
||||
err := s.list(kind, func(raw []byte) error {
|
||||
var v T
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, v)
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func getOf[T any](s *Store, kind, id string) (*T, error) {
|
||||
var v T
|
||||
if err := s.get(kind, id, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// seal and open wrap secretbox so callers don't repeat the nil check.
|
||||
func (s *Store) seal(v string) string {
|
||||
if s.box == nil || v == "" {
|
||||
return v
|
||||
}
|
||||
return s.box.Seal(v)
|
||||
}
|
||||
|
||||
func (s *Store) open(v string) string {
|
||||
if s.box == nil {
|
||||
return v
|
||||
}
|
||||
out, err := s.box.Open(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- timeline events ----------
|
||||
|
||||
func (s *Store) addEvent(scope, ref, kind, actor, text string, at time.Time) {
|
||||
s.db.Exec(`INSERT INTO events(scope,ref,time,kind,actor,text) VALUES(?,?,?,?,?,?)`, scope, ref, at.Unix(), kind, actor, text)
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Time int64 `json:"time"`
|
||||
Kind string `json:"kind"`
|
||||
Actor string `json:"actor"`
|
||||
Text string `json:"text"`
|
||||
// Status is set on posts only: the incident status the post announces.
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Store) events(scope, ref string) []Event {
|
||||
out := []Event{}
|
||||
rows, err := s.db.Query(`SELECT time,kind,actor,text,status FROM events WHERE scope=? AND ref=? ORDER BY time, id`, scope, ref)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
if rows.Scan(&e.Time, &e.Kind, &e.Actor, &e.Text, &e.Status) == nil {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// addPost records a public incident update: the text and status the status page shows.
|
||||
func (s *Store) addPost(incidentID, status, text string, at time.Time) {
|
||||
s.db.Exec(`INSERT INTO events(scope,ref,time,kind,actor,text,status) VALUES('incident',?,?,'post','',?,?)`, incidentID, at.Unix(), text, status)
|
||||
}
|
||||
|
||||
// prune deletes data past its retention.
|
||||
func (s *Store) prune(now time.Time, messageDays, checkDays, hitDays int) {
|
||||
s.db.Exec(`DELETE FROM hits WHERE time < ?`, now.AddDate(0, 0, -hitDays).Unix())
|
||||
s.db.Exec(`DELETE FROM messages WHERE time < ?`, now.AddDate(0, 0, -messageDays).Unix())
|
||||
s.db.Exec(`DELETE FROM checks WHERE time < ?`, now.AddDate(0, 0, -checkDays).Unix())
|
||||
s.db.Exec(`DELETE FROM daily WHERE day < ?`, now.UTC().AddDate(0, 0, -400).Format("2006-01-02"))
|
||||
s.db.Exec(`DELETE FROM sessions WHERE expires < ?`, now.Unix())
|
||||
s.db.Exec(`DELETE FROM subscribers WHERE confirmed=0 AND created < ?`, now.AddDate(0, 0, -7).Unix())
|
||||
s.db.Exec(`DELETE FROM alerts WHERE status='resolved' AND resolved_at < ? AND incident_id=''`, now.AddDate(0, 0, -90).Unix())
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/selfupdate"
|
||||
)
|
||||
|
||||
// Self-update replaces the running binary with the newest release published on the Gitea
|
||||
// server (Reef's selfupdate does the download, checksum and swap).
|
||||
//
|
||||
// In a container the binary lives in the image, where this user can't write and where a
|
||||
// change would be lost when the container is recreated. So an update is written to
|
||||
// <data>/bin/sandbar on the data volume instead, and every start checks that file first
|
||||
// (UseNewerOverlay): it runs when it is a newer release than the image, and is deleted once
|
||||
// the image has caught up.
|
||||
|
||||
const defaultUpdateServer = "https://git.veillax.com"
|
||||
|
||||
type updateState struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stage string
|
||||
pct int
|
||||
line string
|
||||
err string
|
||||
|
||||
checked time.Time // the cached answer from the release server
|
||||
latest string
|
||||
newer bool
|
||||
checkErr string
|
||||
}
|
||||
|
||||
func (s *Server) updateConfig() selfupdate.Config {
|
||||
o := s.opts
|
||||
server, repo := o.UpdateServer, o.UpdateRepo
|
||||
if server == "" {
|
||||
server = defaultUpdateServer
|
||||
}
|
||||
if repo == "" {
|
||||
repo = "Veillax/sandbar"
|
||||
}
|
||||
return selfupdate.Config{
|
||||
Server: server, Repo: repo, App: "sandbar", Current: o.Version,
|
||||
ExePath: UpdateTarget(o.DataDir), Restart: o.Restart, Logf: log.Printf,
|
||||
}
|
||||
}
|
||||
|
||||
// RunAutoUpdate checks for a new release shortly after start and then every few hours until
|
||||
// ctx ends, installing what it finds and restarting into it.
|
||||
func (s *Server) RunAutoUpdate(ctx context.Context) {
|
||||
selfupdate.Run(ctx, s.updateConfig())
|
||||
}
|
||||
|
||||
// canInstall says whether this build can update itself, and if not, why.
|
||||
func (s *Server) canInstall() (bool, string) {
|
||||
if _, ok := parseVersion(s.opts.Version); !ok {
|
||||
return false, "This is a development build, so it does not update itself."
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return false, "A running program can't be replaced on Windows. Download the new release and restart."
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// updateStatus reports the running and newest versions. The release server is asked at most
|
||||
// every ten minutes unless ?fresh=1.
|
||||
func (s *Server) updateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
ok, why := s.canInstall()
|
||||
u := &s.upd
|
||||
u.mu.Lock()
|
||||
stale := time.Since(u.checked) > 10*time.Minute || r.URL.Query().Get("fresh") == "1"
|
||||
running := u.running
|
||||
u.mu.Unlock()
|
||||
if stale && !running {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
latest, newer, err := selfupdate.Check(ctx, s.updateConfig())
|
||||
cancel()
|
||||
u.mu.Lock()
|
||||
u.checked, u.latest, u.newer, u.checkErr = time.Now(), latest, newer, ""
|
||||
if err != nil {
|
||||
u.checkErr = err.Error()
|
||||
}
|
||||
u.mu.Unlock()
|
||||
}
|
||||
u.mu.Lock()
|
||||
out := map[string]any{
|
||||
"current": s.opts.Version, "autoUpdate": s.opts.AutoUpdate, "canInstall": ok, "reason": why,
|
||||
"latest": u.latest, "newer": u.newer, "error": u.checkErr,
|
||||
"running": u.running, "stage": u.stage, "pct": u.pct, "line": u.line, "failed": u.err,
|
||||
}
|
||||
u.mu.Unlock()
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
// updateApply installs the newest release now. It answers at once; the page polls updateStatus
|
||||
// for progress, and the server disappears for a moment when it restarts into the new binary.
|
||||
func (s *Server) updateApply(w http.ResponseWriter, r *http.Request) {
|
||||
if ok, why := s.canInstall(); !ok {
|
||||
bad(w, why)
|
||||
return
|
||||
}
|
||||
u := &s.upd
|
||||
u.mu.Lock()
|
||||
if u.running {
|
||||
u.mu.Unlock()
|
||||
writeJSON(w, 409, map[string]string{"error": "An update is already running."})
|
||||
return
|
||||
}
|
||||
u.running, u.stage, u.pct, u.line, u.err = true, "starting", 0, "", ""
|
||||
u.mu.Unlock()
|
||||
cfg := s.updateConfig()
|
||||
cfg.Progress = func(e selfupdate.Event) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
u.stage = e.Stage
|
||||
if e.Pct >= 0 {
|
||||
u.pct = e.Pct
|
||||
}
|
||||
if e.Line != "" {
|
||||
u.line = e.Line
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
_, err := selfupdate.CheckOnce(ctx, cfg) // when it installs, the process is replaced and this never returns
|
||||
u.mu.Lock()
|
||||
u.running = false
|
||||
if err != nil {
|
||||
u.err = err.Error()
|
||||
}
|
||||
u.checked = time.Time{} // look again next time
|
||||
u.mu.Unlock()
|
||||
}()
|
||||
writeJSON(w, 200, map[string]bool{"started": true})
|
||||
}
|
||||
|
||||
// UpdateTarget is the file an update replaces: the running binary when its folder is writable
|
||||
// (a plain install), and otherwise <data>/bin/sandbar (a container).
|
||||
func UpdateTarget(dataDir string) string {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if exe, err = filepath.EvalSymlinks(exe); err == nil && writableDir(filepath.Dir(exe)) {
|
||||
return exe
|
||||
}
|
||||
}
|
||||
dir, err := filepath.Abs(filepath.Join(dataDir, "bin"))
|
||||
if err != nil {
|
||||
dir = filepath.Join(dataDir, "bin")
|
||||
}
|
||||
os.MkdirAll(dir, 0o755)
|
||||
return filepath.Join(dir, "sandbar")
|
||||
}
|
||||
|
||||
func writableDir(dir string) bool {
|
||||
f, err := os.CreateTemp(dir, ".sandbar-write-test-*")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return true
|
||||
}
|
||||
|
||||
// UseNewerOverlay runs <data>/bin/sandbar instead of this binary when that file is a newer
|
||||
// release. It returns when there is nothing to switch to, and otherwise replaces the process.
|
||||
func UseNewerOverlay(dataDir, current string, logf func(string, ...any)) {
|
||||
if runtime.GOOS == "windows" || os.Getenv("SANDBAR_NO_OVERLAY") == "1" {
|
||||
return
|
||||
}
|
||||
overlay, err := filepath.Abs(filepath.Join(dataDir, "bin", "sandbar"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ofi, err := os.Stat(overlay)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if efi, err := os.Stat(exe); err == nil && os.SameFile(efi, ofi) {
|
||||
return // this already is the overlay
|
||||
}
|
||||
}
|
||||
ver := overlayVersion(overlay)
|
||||
switch {
|
||||
case ver == "":
|
||||
logf("ignoring %s: it did not report a version", overlay)
|
||||
case versionNewer(ver, current):
|
||||
logf("running %s from %s, which is newer than this image's %s", ver, overlay, current)
|
||||
if err := selfupdate.Restart(overlay); err != nil {
|
||||
logf("could not switch to %s: %v", overlay, err)
|
||||
}
|
||||
default:
|
||||
if _, ok := parseVersion(current); ok {
|
||||
os.Remove(overlay) // the image has caught up with it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// overlayVersion asks a binary for its version, or returns "" if it will not run.
|
||||
func overlayVersion(path string) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, path, "-version").Output()
|
||||
f := strings.Fields(string(out))
|
||||
if err != nil || len(f) != 2 || f[0] != "sandbar" {
|
||||
return ""
|
||||
}
|
||||
return f[1]
|
||||
}
|
||||
|
||||
// parseVersion reads vX.Y.Z. Pre-releases and local builds are not versions for this purpose.
|
||||
func parseVersion(s string) ([3]int, bool) {
|
||||
var v [3]int
|
||||
if !strings.HasPrefix(s, "v") || strings.ContainsAny(s, "-+") {
|
||||
return v, false
|
||||
}
|
||||
parts := strings.Split(s[1:], ".")
|
||||
if len(parts) != 3 {
|
||||
return v, false
|
||||
}
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 {
|
||||
return v, false
|
||||
}
|
||||
v[i] = n
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// versionNewer reports whether a is a later release than b. Either not being a release is false.
|
||||
func versionNewer(a, b string) bool {
|
||||
x, ok1 := parseVersion(a)
|
||||
y, ok2 := parseVersion(b)
|
||||
if !ok1 || !ok2 {
|
||||
return false
|
||||
}
|
||||
for i := range x {
|
||||
if x[i] != y[i] {
|
||||
return x[i] > y[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVersionNewer(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"v0.1.4", "v0.1.3", true},
|
||||
{"v0.2.0", "v0.1.9", true},
|
||||
{"v1.0.0", "v0.9.9", true},
|
||||
{"v0.1.3", "v0.1.3", false},
|
||||
{"v0.1.2", "v0.1.3", false},
|
||||
{"v0.1.10", "v0.1.9", true},
|
||||
{"v0.2.0-rc1", "v0.1.0", false}, // pre-releases are never offered
|
||||
{"v0.2.0", "dev", false}, // a development build never updates
|
||||
{"dev", "v0.1.0", false},
|
||||
} {
|
||||
if got := versionNewer(c.a, c.b); got != c.want {
|
||||
t.Errorf("versionNewer(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The update page asks the release server and reports whether it has something newer.
|
||||
func TestUpdateStatus(t *testing.T) {
|
||||
release := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/repos/Veillax/sandbar/releases/latest" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"tag_name":"v0.2.0","assets":[]}`))
|
||||
}))
|
||||
defer release.Close()
|
||||
|
||||
s, _, _ := newTestServer(t)
|
||||
s.opts.Version, s.opts.UpdateServer = "v0.1.0", release.URL
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
defer ts.Close()
|
||||
c := newAPIClient(t, ts)
|
||||
|
||||
if code := c.do("GET", "/api/v1/system/update", nil, nil); code != 401 {
|
||||
t.Fatalf("signed out: %d", code)
|
||||
}
|
||||
c.setup()
|
||||
var st map[string]any
|
||||
if code := c.do("GET", "/api/v1/system/update", nil, &st); code != 200 {
|
||||
t.Fatalf("status: %d", code)
|
||||
}
|
||||
if st["current"] != "v0.1.0" || st["latest"] != "v0.2.0" || st["newer"] != true {
|
||||
t.Fatalf("status: %v", st)
|
||||
}
|
||||
if st["error"] != "" {
|
||||
t.Fatalf("unexpected error: %v", st["error"])
|
||||
}
|
||||
|
||||
// A development build can look but not install.
|
||||
s.opts.Version = "dev"
|
||||
s.upd.checked = s.upd.checked.AddDate(-1, 0, 0)
|
||||
if code := c.do("POST", "/api/v1/system/update", map[string]string{}, &st); code != 400 {
|
||||
t.Fatalf("a development build must not install: %d %v", code, st)
|
||||
}
|
||||
}
|
||||
|
||||
// A newer binary on the data volume takes over from the image, and a stale one is removed.
|
||||
func TestOverlay(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("self-update is not supported on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "bin")
|
||||
os.MkdirAll(bin, 0o755)
|
||||
overlay := filepath.Join(bin, "sandbar")
|
||||
script := func(version string) {
|
||||
os.WriteFile(overlay, []byte("#!/bin/sh\necho 'sandbar "+version+"'\n"), 0o755)
|
||||
}
|
||||
var logged []string
|
||||
logf := func(f string, a ...any) { logged = append(logged, f) }
|
||||
|
||||
// An older overlay is deleted once the image is a release that has caught up.
|
||||
script("v0.1.0")
|
||||
UseNewerOverlay(dir, "v0.1.5", logf)
|
||||
if _, err := os.Stat(overlay); err == nil {
|
||||
t.Fatal("a stale overlay should be removed")
|
||||
}
|
||||
|
||||
// A development image never deletes or switches to anything.
|
||||
script("v0.9.0")
|
||||
UseNewerOverlay(dir, "dev", logf)
|
||||
if _, err := os.Stat(overlay); err != nil {
|
||||
t.Fatal("a development build must leave the overlay alone")
|
||||
}
|
||||
|
||||
// Something that does not report a version is ignored, not run.
|
||||
os.WriteFile(overlay, []byte("#!/bin/sh\necho hello\n"), 0o755)
|
||||
UseNewerOverlay(dir, "v0.1.5", logf)
|
||||
if len(logged) == 0 || !strings.Contains(logged[len(logged)-1], "did not report a version") {
|
||||
t.Fatalf("log: %v", logged)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,66 @@
|
||||
/* Sandbar website analytics. Add to a page:
|
||||
<script defer src="https://sandbar.example.com/script.js" data-website="WEBSITE_ID"></script>
|
||||
It sets no cookies and stores nothing in the browser. It sends the page path, the referrer, the
|
||||
language and the browser's own user agent, and it honors Do Not Track.
|
||||
Options on the tag: data-hash (count #hash changes as pages), data-auto="false" (send nothing
|
||||
until you call sandbar.view() yourself). Custom events: sandbar.track("signup", {plan: "pro"}). */
|
||||
(function () {
|
||||
var tag = document.currentScript;
|
||||
if (!tag) return;
|
||||
var site = tag.getAttribute('data-website');
|
||||
if (!site) return;
|
||||
var base = tag.src.replace(/\/script\.js(\?.*)?$/, '');
|
||||
var dnt = navigator.doNotTrack === '1' || window.doNotTrack === '1' || navigator.msDoNotTrack === '1';
|
||||
var withHash = tag.hasAttribute('data-hash');
|
||||
var last = '';
|
||||
var first = true;
|
||||
|
||||
function send(body) {
|
||||
if (dnt) return;
|
||||
body.website = site;
|
||||
body.hostname = location.hostname;
|
||||
body.language = navigator.language || '';
|
||||
var json = JSON.stringify(body);
|
||||
// text/plain keeps this a "simple" cross-origin request, so no preflight is needed
|
||||
try {
|
||||
if (navigator.sendBeacon && navigator.sendBeacon(base + '/api/send', new Blob([json], { type: 'text/plain' }))) return;
|
||||
} catch (e) { /* fall through to fetch */ }
|
||||
try {
|
||||
fetch(base + '/api/send', { method: 'POST', body: json, headers: { 'Content-Type': 'text/plain' }, keepalive: true, mode: 'cors', credentials: 'omit' });
|
||||
} catch (e) { /* analytics must never break the page */ }
|
||||
}
|
||||
|
||||
function view() {
|
||||
var path = location.pathname + (withHash ? location.hash : '');
|
||||
if (path === last) return;
|
||||
last = path;
|
||||
send({ path: path, referrer: first ? document.referrer : '' });
|
||||
first = false;
|
||||
}
|
||||
|
||||
function hook(name) {
|
||||
var orig = history[name];
|
||||
if (!orig) return;
|
||||
history[name] = function () {
|
||||
var r = orig.apply(this, arguments);
|
||||
setTimeout(view, 0);
|
||||
return r;
|
||||
};
|
||||
}
|
||||
hook('pushState');
|
||||
hook('replaceState');
|
||||
window.addEventListener('popstate', function () { setTimeout(view, 0); });
|
||||
if (withHash) window.addEventListener('hashchange', function () { setTimeout(view, 0); });
|
||||
|
||||
window.sandbar = {
|
||||
view: view,
|
||||
track: function (name, data) {
|
||||
if (!name) return;
|
||||
send({ path: location.pathname, event: String(name).slice(0, 80), data: data });
|
||||
}
|
||||
};
|
||||
|
||||
if (tag.getAttribute('data-auto') === 'false') return;
|
||||
if (document.readyState === 'complete') view();
|
||||
else window.addEventListener('load', view);
|
||||
})();
|
||||
@@ -0,0 +1,373 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>__TITLE__</title>
|
||||
<link rel="alternate" type="application/rss+xml" title="__TITLE__" href="__BASE__/feed.rss">
|
||||
<style>
|
||||
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
|
||||
:root {
|
||||
--accent:#1f5c5c; --accent-contrast:#ffffff;
|
||||
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
|
||||
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
|
||||
--accent-text:var(--accent);
|
||||
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
|
||||
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
|
||||
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
|
||||
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
|
||||
--radius:6px; --radius-lg:10px; --border-w:1px;
|
||||
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
|
||||
--content-max:720px;
|
||||
color-scheme:light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
|
||||
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
|
||||
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
|
||||
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
|
||||
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
|
||||
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
|
||||
color-scheme:dark;
|
||||
} }
|
||||
/* tokens:end */
|
||||
|
||||
*, *::before, *::after { box-sizing:border-box; }
|
||||
[hidden] { display:none !important; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; }
|
||||
h1, h2, h3 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
|
||||
h1 { font-size:var(--text-2xl); } h2 { font-size:var(--text-lg); margin:var(--s6) 0 var(--s3); } h3 { font-size:var(--text-base); }
|
||||
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s4) var(--s5); }
|
||||
footer { max-width:var(--content-max); margin:0 auto; padding:0 var(--s4) var(--s8); color:var(--text-muted); font-size:var(--text-sm); }
|
||||
footer .links { display:flex; gap:var(--s4); flex-wrap:wrap; margin-bottom:var(--s2); }
|
||||
a { color:var(--accent-text); }
|
||||
p { margin:0; }
|
||||
.dim { color:var(--text-muted); } .small { font-size:var(--text-sm); } .num { font-variant-numeric:tabular-nums; }
|
||||
.head { display:flex; align-items:center; justify-content:space-between; gap:var(--s4); flex-wrap:wrap; }
|
||||
.brand { display:flex; align-items:center; gap:var(--s3); min-width:0; }
|
||||
.brand img { max-height:40px; max-width:160px; object-fit:contain; }
|
||||
.brand a { color:inherit; text-decoration:none; }
|
||||
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
|
||||
.btn, .ghost { display:inline-flex; align-items:center; justify-content:center; min-height:36px; padding:var(--s2) var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; text-decoration:none; cursor:pointer; border:var(--border-w) solid transparent; }
|
||||
.btn { background:var(--accent); color:var(--accent-contrast); }
|
||||
.ghost { background:var(--surface); color:var(--text); border-color:var(--border-strong); }
|
||||
.btn:hover { filter:brightness(1.1); } .ghost:hover { background:var(--surface-2); }
|
||||
.desc { margin-top:var(--s2); color:var(--text-muted); }
|
||||
.banner { margin-top:var(--s4); padding:var(--s4) var(--s5); border-radius:var(--radius-lg); border:var(--border-w) solid var(--border); font-weight:600; background:var(--surface-2); }
|
||||
.banner.operational { background:var(--success-bg); color:var(--success); border-color:var(--success); }
|
||||
.banner.degraded, .banner.maintenance { background:var(--warning-bg); color:var(--warning); border-color:var(--warning); }
|
||||
.banner.outage { background:var(--danger-bg); color:var(--danger); border-color:var(--danger); }
|
||||
.notice { margin-top:var(--s4); padding:var(--s3) var(--s4); border-radius:var(--radius); border:var(--border-w) solid var(--border-strong); background:var(--surface); display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; }
|
||||
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); overflow:hidden; }
|
||||
.card + .card { margin-top:var(--s3); }
|
||||
.row { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); }
|
||||
.row:last-child { border-bottom:0; }
|
||||
.ghead { display:flex; align-items:baseline; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; padding:var(--s3) var(--s4); background:var(--surface-2); border-bottom:var(--border-w) solid var(--border); }
|
||||
.svc { padding:var(--s3) var(--s4) var(--s4); border-bottom:var(--border-w) solid var(--border); }
|
||||
.svc:last-child { border-bottom:0; }
|
||||
.svc .top { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; }
|
||||
.bars { display:flex; gap:2px; height:32px; margin-top:var(--s3); }
|
||||
.bar { flex:1; min-width:0; border-radius:2px; background:var(--neutral-bg); }
|
||||
.bar.ok { background:var(--success); } .bar.warn { background:var(--warning); } .bar.bad { background:var(--danger); }
|
||||
.bar:hover { opacity:.7; }
|
||||
.axis { display:flex; justify-content:space-between; margin-top:var(--s1); font-size:var(--text-xs); color:var(--text-faint); }
|
||||
.tip { position:fixed; z-index:10; pointer-events:none; max-width:240px; padding:var(--s2) var(--s3); border-radius:var(--radius); background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); font-size:var(--text-sm); }
|
||||
.tip strong { display:block; }
|
||||
@media (max-width:480px) { .bars { gap:1px; } }
|
||||
.badge { display:inline-flex; align-items:center; padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; }
|
||||
.badge-success { background:var(--success-bg); color:var(--success); }
|
||||
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
||||
.badge-danger { background:var(--danger-bg); color:var(--danger); }
|
||||
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
|
||||
.inc { padding:var(--s4); border-bottom:var(--border-w) solid var(--border); }
|
||||
.inc:last-child { border-bottom:0; }
|
||||
.inc h3 a { color:inherit; }
|
||||
.posts { list-style:none; margin:var(--s3) 0 0; padding:0 0 0 var(--s4); border-left:2px solid var(--border); display:flex; flex-direction:column; gap:var(--s3); }
|
||||
.posts p { white-space:pre-wrap; }
|
||||
.month { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); margin:var(--s5) 0 var(--s2); }
|
||||
.sub { padding:var(--s4); display:grid; gap:var(--s4); }
|
||||
.sub form { display:flex; gap:var(--s2); flex-wrap:wrap; align-items:flex-end; }
|
||||
.sub label { display:flex; flex-direction:column; gap:var(--s1); flex:1; min-width:200px; font-size:var(--text-sm); font-weight:500; }
|
||||
input { font:inherit; min-height:36px; padding:var(--s2) var(--s3); border-radius:var(--radius); border:var(--border-w) solid var(--border-strong); background:var(--surface); color:var(--text); }
|
||||
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:var(--text-sm); word-break:break-all; }
|
||||
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
|
||||
@media (pointer:coarse) { .btn, .ghost, input { min-height:44px; } input { font-size:16px; } }
|
||||
@media (prefers-reduced-motion:no-preference) { .btn, .ghost { transition:background-color .12s, filter .12s; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="tip" id="tip" role="presentation" hidden></div>
|
||||
<main id="main" tabindex="-1">
|
||||
<div class="head">
|
||||
<div class="brand" id="brand"></div>
|
||||
<div class="actions" id="actions"></div>
|
||||
</div>
|
||||
<p class="desc" id="desc" hidden></p>
|
||||
<div id="notice"></div>
|
||||
<div id="view" aria-live="polite"><div class="banner" role="status">Loading</div></div>
|
||||
</main>
|
||||
<footer id="foot"></footer>
|
||||
<script>
|
||||
const BASE = '__BASE__';
|
||||
const $ = id => document.getElementById(id);
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
|
||||
const link = (href, text, cls) => { const a = el('a', cls, text); a.href = href; return a; };
|
||||
const home = BASE || '/';
|
||||
const when = t => new Date(t * 1000).toLocaleString(undefined, {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'});
|
||||
const monthOf = t => new Date(t * 1000).toLocaleDateString(undefined, {month:'long', year:'numeric'});
|
||||
|
||||
const overallLabel = { operational: 'All systems are operational', degraded: 'Some systems are affected', outage: 'There is a service outage', maintenance: 'Scheduled maintenance is in progress' };
|
||||
const stateBadge = {
|
||||
operational: ['Operational', 'success'], degraded: ['Degraded performance', 'warning'], partial_outage: ['Partial outage', 'warning'],
|
||||
major_outage: ['Major outage', 'danger'], maintenance: ['Under maintenance', 'accent'], pending: ['Waiting for first check', ''],
|
||||
};
|
||||
const badge = (text, tone) => el('span', 'badge' + (tone ? ' badge-' + tone : ''), text);
|
||||
const incLabel = { investigating: 'Investigating', identified: 'Identified', monitoring: 'Monitoring', resolved: 'Resolved' };
|
||||
const incTone = s => s === 'resolved' ? 'success' : s === 'monitoring' ? 'accent' : 'warning';
|
||||
|
||||
// One bar per UTC day, oldest first. A day is green with no failed checks, amber when at least 99% passed,
|
||||
// and red below that. Color is never the only signal: the tooltip and the label say it in words.
|
||||
const dayFmt = new Intl.DateTimeFormat(undefined, {month:'short', day:'numeric', year:'numeric', timeZone:'UTC'});
|
||||
const dayClass = ([c, f]) => !c ? '' : !f ? 'ok' : (c - f) / c >= 0.99 ? 'warn' : 'bad';
|
||||
function dayText([c, f], i, n, today) {
|
||||
const d = new Date(today + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() - (n - 1 - i));
|
||||
if (!c) return [dayFmt.format(d), 'No data'];
|
||||
const up = ((c - f) * 100 / c).toFixed(2) + '% uptime';
|
||||
return [dayFmt.format(d), f ? up + ', ' + f.toLocaleString() + ' failed of ' + c.toLocaleString() + ' checks' : up];
|
||||
}
|
||||
const tip = $('tip');
|
||||
function showTip(e, lines) {
|
||||
tip.replaceChildren(el('strong', '', lines[0]), document.createTextNode(lines[1]));
|
||||
tip.hidden = false;
|
||||
const w = tip.offsetWidth, x = Math.min(Math.max(8, e.clientX - w / 2), innerWidth - w - 8);
|
||||
tip.style.left = x + 'px'; tip.style.top = Math.max(8, e.clientY - tip.offsetHeight - 14) + 'px';
|
||||
}
|
||||
function component(m, today) {
|
||||
const box = el('div', 'svc'), top = el('div', 'top');
|
||||
top.append(el('span', '', m.name));
|
||||
const right = el('span', 'small dim num'), b = stateBadge[m.state] || stateBadge.pending;
|
||||
right.append(badge(b[0], b[1]), document.createTextNode(m.uptime >= 0 ? ' ' + m.uptime.toFixed(2) + '% uptime' : ''));
|
||||
top.append(right); box.append(top);
|
||||
const days = m.days || [], bars = el('div', 'bars');
|
||||
const bad = days.filter(d => dayClass(d) === 'bad').length, warn = days.filter(d => dayClass(d) === 'warn').length;
|
||||
bars.setAttribute('role', 'img');
|
||||
bars.setAttribute('aria-label', m.name + ', the last ' + days.length + ' days: ' + (m.uptime >= 0 ? m.uptime.toFixed(2) + '% uptime' : 'no checks yet') +
|
||||
(bad || warn ? ', ' + bad + ' days with an outage and ' + warn + ' with short failures' : ', no failures'));
|
||||
days.forEach((d, i) => {
|
||||
const s = el('span', 'bar ' + dayClass(d));
|
||||
const lines = dayText(d, i, days.length, today);
|
||||
s.addEventListener('pointerenter', e => showTip(e, lines));
|
||||
s.addEventListener('pointermove', e => showTip(e, lines));
|
||||
s.addEventListener('pointerleave', () => { tip.hidden = true; });
|
||||
bars.append(s);
|
||||
});
|
||||
const axis = el('div', 'axis'); axis.setAttribute('aria-hidden', 'true');
|
||||
axis.append(el('span', '', days.length + ' days ago'), el('span', '', 'Today'));
|
||||
if (days.length) box.append(bars, axis);
|
||||
return box;
|
||||
}
|
||||
|
||||
const worst = ['operational', 'pending', 'maintenance', 'degraded', 'partial_outage', 'major_outage'];
|
||||
function groups(d) {
|
||||
const out = [], byId = {};
|
||||
const open = (id, g) => { byId[id] = { g, items: [] }; out.push(byId[id]); };
|
||||
open('', null);
|
||||
for (const g of d.groups) open(g.id, g);
|
||||
for (const m of d.monitors) (byId[m.group] || byId['']).items.push(m);
|
||||
return out.filter(x => x.items.length);
|
||||
}
|
||||
function services(d) {
|
||||
const wrap = el('div');
|
||||
if (!d.monitors.length) { const c = el('div', 'card'); c.append(el('div', 'row dim', 'No services are published yet.')); wrap.append(c); return wrap; }
|
||||
for (const { g, items } of groups(d)) {
|
||||
const card = el('div', 'card');
|
||||
if (g) {
|
||||
const head = el('div', 'ghead'), left = el('div');
|
||||
left.append(el('h3', '', g.name));
|
||||
if (g.description) left.append(el('div', 'small dim', g.description));
|
||||
const w = items.map(i => i.state).reduce((a, s) => worst.indexOf(s) > worst.indexOf(a) ? s : a, 'operational'), b = stateBadge[w];
|
||||
head.append(left, badge(b[0], b[1])); card.append(head);
|
||||
}
|
||||
for (const m of items) card.append(component(m, d.today));
|
||||
wrap.append(card);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function posts(list) {
|
||||
const ul = el('ul', 'posts');
|
||||
for (const u of list) {
|
||||
const li = el('li'), head = el('div', 'small');
|
||||
head.append(el('strong', '', incLabel[u.status] || 'Update'), el('span', 'dim num', ' · ' + when(u.time)));
|
||||
li.append(head, el('p', '', u.text)); ul.append(li);
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
function incident(i, full) {
|
||||
const b = el('div', 'inc'), head = el('h3');
|
||||
head.append(full ? document.createTextNode(i.title) : link(BASE + '/incidents/' + i.id, i.title), document.createTextNode(' '), badge(incLabel[i.status] || i.status, incTone(i.status)));
|
||||
b.append(head);
|
||||
b.append(el('div', 'small dim', 'Started ' + when(i.created) + (i.resolved ? ', resolved ' + when(i.resolved) : '') + (i.components.length ? ' · Affects ' + i.components.join(', ') : '')));
|
||||
b.append(posts(i.updates));
|
||||
return b;
|
||||
}
|
||||
function maintenance(m) {
|
||||
const b = el('div', 'inc'), head = el('h3');
|
||||
head.append(document.createTextNode(m.title + ' '), m.state === 'in_progress' ? badge('In progress', 'accent') : m.state === 'scheduled' ? badge('Scheduled', '') : badge('Completed', 'success'));
|
||||
b.append(head, el('div', 'small dim', when(m.start) + ' to ' + when(m.end) + (m.components.length ? ' · Affects ' + m.components.join(', ') : '')));
|
||||
if (m.message) { const p = el('p', '', m.message); p.style.whiteSpace = 'pre-wrap'; p.style.marginTop = 'var(--s2)'; b.append(p); }
|
||||
return b;
|
||||
}
|
||||
|
||||
// ---------- branding, header, footer ----------
|
||||
function brand(d) {
|
||||
const s = d.settings;
|
||||
if (s.accent) { document.documentElement.style.setProperty('--accent', s.accent); }
|
||||
const b = $('brand'); b.replaceChildren();
|
||||
if (s.logoUrl) { const img = el('img'); img.src = s.logoUrl; img.alt = ''; img.referrerPolicy = 'no-referrer'; img.addEventListener('error', () => img.remove()); b.append(img); }
|
||||
const h = el('h1'); h.append(link(home, s.title)); b.append(h);
|
||||
const desc = $('desc'); desc.textContent = s.description || ''; desc.hidden = !s.description;
|
||||
const a = $('actions'); a.replaceChildren();
|
||||
const sub = el('button', 'btn', 'Subscribe to updates'); sub.type = 'button'; sub.setAttribute('aria-expanded', 'false'); sub.setAttribute('aria-controls', 'subpanel');
|
||||
sub.addEventListener('click', () => { const p = $('subpanel'); if (!p) return location.assign(home + '#subscribe'); p.hidden = !p.hidden; sub.setAttribute('aria-expanded', String(!p.hidden)); if (!p.hidden) p.querySelector('input').focus(); });
|
||||
a.append(sub);
|
||||
if (s.supportUrl) a.append(link(s.supportUrl, 'Contact us', 'ghost'));
|
||||
const f = $('foot'); f.replaceChildren();
|
||||
const links = el('div', 'links');
|
||||
links.append(link(BASE + '/history', 'Incident history'), link(BASE + '/feed.rss', 'RSS feed'));
|
||||
f.append(links);
|
||||
if (s.footer) f.append(el('p', '', s.footer));
|
||||
}
|
||||
|
||||
// ---------- subscribe ----------
|
||||
async function post(path, body) {
|
||||
const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.error || 'Something went wrong. Try again in a moment.');
|
||||
return j;
|
||||
}
|
||||
function subscribePanel(d) {
|
||||
const p = el('div', 'card sub'); p.id = 'subpanel'; p.hidden = location.hash !== '#subscribe';
|
||||
p.append(el('h3', '', 'Get notified when something changes'));
|
||||
const msg = el('p', 'small'); msg.setAttribute('role', 'status');
|
||||
const form = (kind, label, type, hint, button) => {
|
||||
const f = el('form'), l = el('label', '', label), i = el('input');
|
||||
i.type = type; i.required = true; i.name = kind; i.autocomplete = kind === 'email' ? 'email' : 'off';
|
||||
if (kind === 'webhook') i.placeholder = 'https://example.com/hooks/status';
|
||||
l.append(i); f.append(l);
|
||||
const b = el('button', 'btn', button); b.type = 'submit'; f.append(b);
|
||||
f.addEventListener('submit', async e => {
|
||||
e.preventDefault(); b.disabled = true; msg.textContent = '';
|
||||
try { const j = await post(BASE + '/subscribe', { [kind]: i.value.trim() }); msg.textContent = j.message; i.value = ''; }
|
||||
catch (err) { msg.textContent = err.message; }
|
||||
b.disabled = false;
|
||||
});
|
||||
const w = el('div'); w.append(f); if (hint) w.append(el('p', 'small dim', hint)); w.lastChild.style.marginTop = 'var(--s2)';
|
||||
return w;
|
||||
};
|
||||
if (d.subscribe.email) p.append(form('email', 'Email address', 'email', 'We send a link to confirm. Every update comes with a link to unsubscribe.', 'Subscribe by email'));
|
||||
if (d.subscribe.webhook) p.append(form('webhook', 'Webhook address', 'url', 'We send a JSON request for every update. Your server has to answer a test request first.', 'Subscribe with a webhook'));
|
||||
const rss = el('div'); rss.append(el('p', 'small', 'Or follow the RSS feed:'));
|
||||
const a = link(BASE + '/feed.rss', location.origin + BASE + '/feed.rss', 'mono'); rss.append(a);
|
||||
p.append(rss, msg);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------- pages ----------
|
||||
async function getJSON(path) {
|
||||
const r = await fetch(path);
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
return r.json();
|
||||
}
|
||||
async function showHome() {
|
||||
const d = await getJSON('/status.json');
|
||||
document.title = d.settings.title;
|
||||
brand(d);
|
||||
const v = el('div');
|
||||
const ban = el('div', 'banner ' + d.overall, overallLabel[d.overall] || d.overall); ban.setAttribute('role', 'status');
|
||||
v.append(ban);
|
||||
if (d.subscribe.email || d.subscribe.webhook) { const sp = subscribePanel(d); sp.style.marginTop = 'var(--s4)'; v.append(sp); }
|
||||
if (d.maintenance.length) {
|
||||
v.append(el('h2', '', 'Scheduled maintenance'));
|
||||
const c = el('div', 'card'); for (const m of d.maintenance) c.append(maintenance(m)); v.append(c);
|
||||
}
|
||||
v.append(el('h2', '', 'Services'), services(d));
|
||||
v.append(el('h2', '', 'Incidents'));
|
||||
const ic = el('div', 'card');
|
||||
if (!d.incidents.length) ic.append(el('div', 'row dim', 'No incidents in the last 14 days.'));
|
||||
for (const i of d.incidents) ic.append(incident(i, false));
|
||||
v.append(ic);
|
||||
const more = el('p', 'small'); more.style.marginTop = 'var(--s3)'; more.append(link(BASE + '/history', 'See all past incidents'));
|
||||
v.append(more);
|
||||
const old = $('subpanel'), open = old && !old.hidden;
|
||||
$('view').replaceChildren(v);
|
||||
if (open) { $('subpanel').hidden = false; }
|
||||
}
|
||||
async function showHistory() {
|
||||
const first = await getJSON('/status.json');
|
||||
document.title = 'History - ' + first.settings.title;
|
||||
brand(first);
|
||||
const v = el('div'), list = el('div'), btn = el('button', 'ghost', 'Show older'); btn.type = 'button'; btn.style.marginTop = 'var(--s4)';
|
||||
v.append(el('h2', '', 'Incident history'), list, btn);
|
||||
$('view').replaceChildren(v);
|
||||
let next = 0, last = '';
|
||||
const load = async () => {
|
||||
const h = await getJSON('/status/api/history' + (next ? '?before=' + next : ''));
|
||||
let card = null;
|
||||
for (const e of h.entries) {
|
||||
const m = monthOf(e.time);
|
||||
if (m !== last) { last = m; v.insertBefore(el('div', 'month', m), btn); card = el('div', 'card'); v.insertBefore(card, btn); }
|
||||
card.append(e.type === 'incident' ? incident(e.incident, false) : maintenance(e.maintenance));
|
||||
}
|
||||
if (!list.children.length && !h.entries.length && !next) list.append(el('div', 'card')), list.firstChild.append(el('div', 'row dim', 'Nothing has been posted yet.'));
|
||||
next = h.next; btn.hidden = !next;
|
||||
};
|
||||
btn.addEventListener('click', () => load().catch(fail));
|
||||
await load();
|
||||
}
|
||||
async function showIncident(id) {
|
||||
const [d, i] = await Promise.all([getJSON('/status.json'), getJSON('/status/api/incidents/' + encodeURIComponent(id))]);
|
||||
document.title = i.title + ' - ' + d.settings.title;
|
||||
brand(d);
|
||||
const v = el('div'), back = el('p', 'small'); back.style.marginTop = 'var(--s5)'; back.append(link(BASE + '/history', 'Back to incident history'));
|
||||
const c = el('div', 'card'); c.style.marginTop = 'var(--s3)'; c.append(incident(i, true));
|
||||
v.append(back, c);
|
||||
$('view').replaceChildren(v);
|
||||
}
|
||||
|
||||
function fail(e) {
|
||||
const v = $('view');
|
||||
if (e && e.message === '404') { v.replaceChildren(el('div', 'banner', 'That page does not exist. Go back to the ')); v.firstChild.append(link(home, 'status page'), document.createTextNode('.')); return; }
|
||||
if (!v.querySelector('.card')) v.replaceChildren(el('div', 'banner', 'The status could not be loaded. Try again in a moment.'));
|
||||
}
|
||||
|
||||
// ---------- notices from confirm and unsubscribe links ----------
|
||||
function notices() {
|
||||
const q = new URLSearchParams(location.search), n = $('notice');
|
||||
const note = (text, ...extra) => { const d = el('div', 'notice'); d.setAttribute('role', 'status'); d.append(el('span', '', text), ...extra); n.replaceChildren(d); return d; };
|
||||
if (q.get('notice') === 'confirmed') note('You are subscribed. You will get an email for each update.');
|
||||
else if (q.get('notice') === 'invalid') note('That link is not valid. Subscribe again to get a new one.');
|
||||
const token = q.get('unsubscribe');
|
||||
if (token) {
|
||||
const b = el('button', 'btn', 'Unsubscribe'); b.type = 'button';
|
||||
const d = note('Stop getting updates from this page?', b);
|
||||
b.addEventListener('click', async () => {
|
||||
b.disabled = true;
|
||||
try { const j = await post(BASE + '/unsubscribe', { token }); d.replaceChildren(el('span', '', j.message)); history.replaceState(null, '', location.pathname); }
|
||||
catch (e) { d.replaceChildren(el('span', '', e.message)); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rel = location.pathname.slice(BASE.length).replace(/\/+$/, '');
|
||||
let run;
|
||||
if (rel === '/history') run = showHistory;
|
||||
else if (rel.startsWith('/incidents/')) run = () => showIncident(decodeURIComponent(rel.slice(11)));
|
||||
else run = showHome;
|
||||
notices();
|
||||
run().catch(fail);
|
||||
if (run === showHome) setInterval(() => { if (!document.hidden && !document.activeElement.matches('input')) showHome().catch(() => {}); }, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
// Style gate for the dashboard, following the Lightwell design language (see docs/design.md).
|
||||
// Fails on em or en dashes, emoji, exclamation marks in interface copy, and raw colors outside the
|
||||
// token block of web/index.html. Run with: node scripts/lint-design.mjs
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { extname, join, relative, sep } from "node:path";
|
||||
|
||||
const root = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1");
|
||||
const targets = ["internal/sandbar/web", "internal/sandbar", "docs", "README.md", "scripts", "cmd", "."];
|
||||
const skipDirs = new Set(["node_modules", ".git"]);
|
||||
const exts = new Set([".html", ".md", ".go", ".sh", ".mjs", ".example"]);
|
||||
|
||||
const dash = /[–—−]/;
|
||||
const emoji = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F900}-\u{1F9FF}\u{FE0F}\u{2B50}\u{2705}\u{274C}]/u;
|
||||
const rawColor = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/;
|
||||
|
||||
const seen = new Set();
|
||||
function* walk(p, top) {
|
||||
const st = statSync(p, { throwIfNoEntry: false });
|
||||
if (!st || seen.has(p)) return;
|
||||
if (st.isFile()) { seen.add(p); return yield p; }
|
||||
for (const name of readdirSync(p)) {
|
||||
if (skipDirs.has(name) || (top && name.startsWith("."))) continue;
|
||||
const full = join(p, name);
|
||||
// the "." target only contributes the files at the top level
|
||||
if (p === root && statSync(full).isDirectory()) continue;
|
||||
yield* walk(full, false);
|
||||
}
|
||||
}
|
||||
|
||||
const problems = [];
|
||||
for (const t of targets) {
|
||||
for (const file of walk(join(root, t), true)) {
|
||||
if (!exts.has(extname(file)) && !file.endsWith(".env.example")) continue;
|
||||
const rel = relative(root, file).split(sep).join("/");
|
||||
if (rel.endsWith("go.sum") || rel === "scripts/lint-design.mjs") continue;
|
||||
const isPage = rel.includes("/web/") && rel.endsWith(".html");
|
||||
let inTokens = false;
|
||||
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
|
||||
const at = (msg) => problems.push(`${rel}:${i + 1}: ${msg}`);
|
||||
if (line.includes("tokens:begin")) inTokens = true;
|
||||
if (dash.test(line)) at("em dash, en dash or minus sign. Use a comma, colon, period or the word to.");
|
||||
if (emoji.test(line)) at("emoji or pictographic symbol. Use an approved outline icon.");
|
||||
if (isPage && !inTokens && rawColor.test(line.replace(/\/\*.*?\*\//g, "")) && !/placeholder|href="#|&#/.test(line)) {
|
||||
at("raw color outside the token block. Use a token.");
|
||||
}
|
||||
if (isPage && /(?:toast|textContent\s*=|class:'(?:empty|notice)')[^;\n]*'[^']*[A-Za-z]![^']*'/.test(line)) at("exclamation mark in interface copy.");
|
||||
if (line.includes("tokens:end")) inTokens = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(problems.join("\n"));
|
||||
console.error(`\n${problems.length} style problem(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Style checks passed.");
|
||||
Reference in new issue
Block a user