Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cb24f7085 | ||
|
|
cf4d5b3ce2 | ||
|
|
042a60152c |
No files matched your search
@@ -45,3 +45,17 @@ SANDBAR_MASTER_KEY=
|
|||||||
#SANDBAR_SMTP_USER=
|
#SANDBAR_SMTP_USER=
|
||||||
#SANDBAR_SMTP_PASSWORD=
|
#SANDBAR_SMTP_PASSWORD=
|
||||||
#SANDBAR_SMTP_FROM=sandbar@example.com
|
#SANDBAR_SMTP_FROM=sandbar@example.com
|
||||||
|
|
||||||
|
# Mobile companion app (the sandbar-mobile 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=../sandbar-mobile/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=
|
||||||
@@ -8,7 +8,7 @@ Sandbar covers the ground of five tools:
|
|||||||
| Area | Like | What it does |
|
| 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 |
|
| **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 public status page |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **Incidents** | incident.io | Declare, assign, update and close an incident, with a timeline and a post-mortem |
|
||||||
@@ -190,7 +190,7 @@ app to be paged on your phone when an escalation policy reaches you.
|
|||||||
| 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 |
|
| 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
|
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 `/status`, together with
|
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.
|
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
|
The status page shows each public monitor as 90 daily uptime bars, like most hosted status pages. A day is green with
|
||||||
@@ -202,6 +202,64 @@ in a small per-day table, so the bars go back 90 days even though raw check resu
|
|||||||
Monitors cannot probe private or internal addresses unless you tick **Allow targets on private networks** on that
|
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.
|
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)
|
## Websites (analytics)
|
||||||
|
|
||||||
Add a website on the Websites page, then paste its one-line tracking code into the head of your pages:
|
Add a website on the Websites page, then paste its one-line tracking code into the head of your pages:
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ func main() {
|
|||||||
CheckDays: atoi("SANDBAR_CHECK_DAYS"),
|
CheckDays: atoi("SANDBAR_CHECK_DAYS"),
|
||||||
HitDays: atoi("SANDBAR_ANALYTICS_DAYS"),
|
HitDays: atoi("SANDBAR_ANALYTICS_DAYS"),
|
||||||
TrustProxy: os.Getenv("SANDBAR_TRUST_PROXY") == "1",
|
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,
|
Version: version,
|
||||||
AutoUpdate: *autoUpdate,
|
AutoUpdate: *autoUpdate,
|
||||||
UpdateServer: os.Getenv("SANDBAR_UPDATE_SERVER"),
|
UpdateServer: os.Getenv("SANDBAR_UPDATE_SERVER"),
|
||||||
|
|||||||
+4
-1
@@ -14,7 +14,7 @@ Sandbar's pages follow the Lightwell design language, as Hammerhead does. The re
|
|||||||
| File | What it holds |
|
| 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/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. Same token block, repeated. |
|
| `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.
|
Both are single files with no build step. Change a token in both, and in Hammerhead when the language changes.
|
||||||
|
|
||||||
@@ -31,6 +31,9 @@ The helpers at the top of the script in `index.html` are the same as Hammerhead'
|
|||||||
## Status page bars and charts
|
## 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.
|
* **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.
|
* **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.
|
* **Top lists** are tables with a proportional bar behind the name, so the numbers stay readable without the bar.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Mobile companion API
|
||||||
|
|
||||||
|
Used by the sandbar-mobile 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).
|
||||||
+29
-70
@@ -21,8 +21,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
|
|
||||||
// pages
|
// pages
|
||||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { servePage(w, indexHTML) })
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { servePage(w, indexHTML) })
|
||||||
mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) { servePage(w, statusHTML) })
|
s.statusRoutes(mux)
|
||||||
mux.HandleFunc("GET /status.json", s.statusJSON)
|
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
|
||||||
mux.HandleFunc("GET /metrics", s.metrics)
|
mux.HandleFunc("GET /metrics", s.metrics)
|
||||||
mux.HandleFunc("GET /script.js", s.serveTracker)
|
mux.HandleFunc("GET /script.js", s.serveTracker)
|
||||||
@@ -37,6 +36,15 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /api/v1/login", s.apiLogin)
|
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) })
|
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
|
// topics, ntfy-style
|
||||||
mux.HandleFunc("POST /{topic}", s.auth(false, s.publishHTTP))
|
mux.HandleFunc("POST /{topic}", s.auth(false, s.publishHTTP))
|
||||||
mux.HandleFunc("PUT /{topic}", s.auth(false, s.publishHTTP))
|
mux.HandleFunc("PUT /{topic}", s.auth(false, s.publishHTTP))
|
||||||
@@ -151,6 +159,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /api/v1/alerts/{id}/ack", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
|
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))
|
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) {
|
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))
|
fail2(w, s.ResolveAlert(r.PathValue("id"), userFrom(r.Context()).Name))
|
||||||
}))
|
}))
|
||||||
@@ -217,7 +228,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
fail2(w, s.st.del("incident", r.PathValue("id")))
|
fail2(w, s.st.del("incident", r.PathValue("id")))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return secureHeaders(mux)
|
return s.statusHost(s.appRoutes(secureHeaders(mux)))
|
||||||
}
|
}
|
||||||
|
|
||||||
var ok = map[string]bool{"ok": true}
|
var ok = map[string]bool{"ok": true}
|
||||||
@@ -233,7 +244,11 @@ func secureHeaders(next http.Handler) http.Handler {
|
|||||||
h.Set("X-Content-Type-Options", "nosniff")
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
h.Set("Referrer-Policy", "no-referrer")
|
h.Set("Referrer-Policy", "no-referrer")
|
||||||
h.Set("X-Frame-Options", "DENY")
|
h.Set("X-Frame-Options", "DENY")
|
||||||
h.Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:")
|
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)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -750,6 +765,9 @@ func (s *Server) declareHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Lead string `json:"lead"`
|
Lead string `json:"lead"`
|
||||||
AlertIDs []string `json:"alertIds"`
|
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) {
|
if !decode(r, &in) {
|
||||||
bad(w, "invalid request")
|
bad(w, "invalid request")
|
||||||
@@ -764,6 +782,13 @@ func (s *Server) declareHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
bad(w, err.Error())
|
bad(w, err.Error())
|
||||||
return
|
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)
|
writeJSON(w, 200, inc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,72 +833,6 @@ func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request) {
|
|||||||
io.WriteString(w, "ok")
|
io.WriteString(w, "ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
// statusJSON feeds the public status page: only monitors and incidents marked public.
|
|
||||||
func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) {
|
|
||||||
type mon struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Uptime float64 `json:"uptime"`
|
|
||||||
Since int64 `json:"since"`
|
|
||||||
Days [][2]int `json:"days"` // [checks, failed] per UTC day, oldest first, ending today
|
|
||||||
}
|
|
||||||
type inc struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Severity string `json:"severity"`
|
|
||||||
Created int64 `json:"created"`
|
|
||||||
Resolved int64 `json:"resolved"`
|
|
||||||
Updates []Event `json:"updates"`
|
|
||||||
}
|
|
||||||
out := struct {
|
|
||||||
Monitors []mon `json:"monitors"`
|
|
||||||
Incidents []inc `json:"incidents"`
|
|
||||||
Overall string `json:"overall"`
|
|
||||||
Today string `json:"today"` // the UTC date of the last bar
|
|
||||||
}{[]mon{}, []inc{}, "operational", s.now().UTC().Format("2006-01-02")}
|
|
||||||
mons, _ := listOf[Monitor](s.st, "monitor")
|
|
||||||
for _, m := range mons {
|
|
||||||
if !m.Public || !m.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
st := s.monitorState(m.ID)
|
|
||||||
days := s.dailyUptime(m.ID, uptimeDays, s.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)
|
|
||||||
}
|
|
||||||
out.Monitors = append(out.Monitors, mon{m.Name, st.Status, uptime, st.Since, days})
|
|
||||||
if st.Status == "down" {
|
|
||||||
out.Overall = "outage"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
incs, _ := listOf[Incident](s.st, "incident")
|
|
||||||
sortIncidents(incs)
|
|
||||||
cut := s.now().AddDate(0, 0, -14).Unix()
|
|
||||||
for _, in := range incs {
|
|
||||||
if !in.Public || (in.Status == "resolved" && in.Resolved < cut) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var ups []Event
|
|
||||||
for _, e := range s.st.events("incident", in.ID) {
|
|
||||||
if e.Kind == "update" || e.Kind == "declared" {
|
|
||||||
e.Actor = "" // staff names stay off the public page
|
|
||||||
ups = append(ups, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.Incidents = append(out.Incidents, inc{in.Title, in.Status, in.Severity, in.Created, in.Resolved, ups})
|
|
||||||
if in.Status != "resolved" && out.Overall == "operational" {
|
|
||||||
out.Overall = "degraded"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.Header().Set("Cache-Control", "max-age=15")
|
|
||||||
writeJSON(w, 200, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortIncidents(list []Incident) {
|
func sortIncidents(list []Incident) {
|
||||||
for i := 1; i < len(list); i++ { // newest first; lists are short
|
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-- {
|
for j := i; j > 0 && list[j].Created > list[j-1].Created; j-- {
|
||||||
|
|||||||
@@ -147,6 +147,22 @@ var errThrottled = errors.New("too many attempts, try again in a few minutes")
|
|||||||
|
|
||||||
// login checks credentials and starts a session.
|
// login checks credentials and starts a session.
|
||||||
func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password string) (*User, error) {
|
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)
|
ip := s.realIP(r)
|
||||||
if s.throttled(ip) {
|
if s.throttled(ip) {
|
||||||
return nil, errThrottled
|
return nil, errThrottled
|
||||||
@@ -160,13 +176,6 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password s
|
|||||||
s.noteFailure(ip)
|
s.noteFailure(ip)
|
||||||
return nil, errors.New("wrong email or password")
|
return nil, errors.New("wrong email or password")
|
||||||
}
|
}
|
||||||
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
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Incident struct {
|
|||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Postmortem string `json:"postmortem"`
|
Postmortem string `json:"postmortem"`
|
||||||
Public bool `json:"public"` // shown on the status page
|
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"`
|
Created int64 `json:"created"`
|
||||||
Resolved int64 `json:"resolved"`
|
Resolved int64 `json:"resolved"`
|
||||||
AlertIDs []string `json:"alertIds"`
|
AlertIDs []string `json:"alertIds"`
|
||||||
@@ -66,7 +67,7 @@ func (s *Server) DeclareIncident(title, severity, summary, lead, actor string, a
|
|||||||
return nil, errors.New("severity must be sev1, sev2 or sev3")
|
return nil, errors.New("severity must be sev1, sev2 or sev3")
|
||||||
}
|
}
|
||||||
now := s.now()
|
now := s.now()
|
||||||
in := &Incident{ID: newID(), Title: title, Severity: severity, Status: "investigating", Lead: lead, Summary: summary, Created: now.Unix(), AlertIDs: []string{}}
|
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 {
|
for _, id := range alertIDs {
|
||||||
if a, err := s.getAlert(id); err == nil {
|
if a, err := s.getAlert(id); err == nil {
|
||||||
in.AlertIDs = append(in.AlertIDs, a.ID)
|
in.AlertIDs = append(in.AlertIDs, a.ID)
|
||||||
@@ -104,6 +105,7 @@ type IncidentPatch struct {
|
|||||||
Summary *string `json:"summary"`
|
Summary *string `json:"summary"`
|
||||||
Postmortem *string `json:"postmortem"`
|
Postmortem *string `json:"postmortem"`
|
||||||
Public *bool `json:"public"`
|
Public *bool `json:"public"`
|
||||||
|
Components *[]string `json:"components"`
|
||||||
Note string `json:"note"` // text for the update announcement and timeline
|
Note string `json:"note"` // text for the update announcement and timeline
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +115,7 @@ func (s *Server) UpdateIncident(id, actor string, p IncidentPatch) (*Incident, e
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
now := s.now()
|
now := s.now()
|
||||||
|
prevStatus := in.Status
|
||||||
var changes []string
|
var changes []string
|
||||||
if p.Severity != nil && *p.Severity != in.Severity {
|
if p.Severity != nil && *p.Severity != in.Severity {
|
||||||
if !validSeverity(*p.Severity) {
|
if !validSeverity(*p.Severity) {
|
||||||
@@ -151,12 +154,17 @@ func (s *Server) UpdateIncident(id, actor string, p IncidentPatch) (*Incident, e
|
|||||||
if p.Postmortem != nil {
|
if p.Postmortem != nil {
|
||||||
in.Postmortem = *p.Postmortem
|
in.Postmortem = *p.Postmortem
|
||||||
}
|
}
|
||||||
|
wasPublic, statusChanged := in.Public, in.Status != prevStatus
|
||||||
if p.Public != nil {
|
if p.Public != nil {
|
||||||
in.Public = *p.Public
|
in.Public = *p.Public
|
||||||
}
|
}
|
||||||
|
if p.Components != nil {
|
||||||
|
in.Components = cleanIDs(*p.Components)
|
||||||
|
}
|
||||||
if err := s.st.put("incident", in.ID, in); err != nil {
|
if err := s.st.put("incident", in.ID, in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.postToStatusPage(in, wasPublic, statusChanged, p.Note, now)
|
||||||
if len(changes) > 0 || p.Note != "" {
|
if len(changes) > 0 || p.Note != "" {
|
||||||
text := strings.Join(changes, "; ")
|
text := strings.Join(changes, "; ")
|
||||||
if p.Note != "" {
|
if p.Note != "" {
|
||||||
|
|||||||
@@ -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 (sandbar-mobile). 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
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ type Monitor struct {
|
|||||||
PolicyID string `json:"policyId"` // escalation policy for the alert it raises
|
PolicyID string `json:"policyId"` // escalation policy for the alert it raises
|
||||||
Severity string `json:"severity"` // alert severity (default critical)
|
Severity string `json:"severity"` // alert severity (default critical)
|
||||||
Public bool `json:"public"` // shown on the status page
|
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"`
|
AllowPrivate bool `json:"allowPrivate"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Message struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
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}$`)
|
var topicRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||||
@@ -101,6 +102,10 @@ func parseTags(s string) []string {
|
|||||||
|
|
||||||
// Publish stores a message and delivers it to live subscribers.
|
// Publish stores a message and delivers it to live subscribers.
|
||||||
func (s *Server) Publish(topic, title, body string, prio int, tags []string) (Message, error) {
|
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) {
|
if !validTopic(topic) {
|
||||||
return Message{}, errors.New("a topic is 1-64 letters, digits, - or _")
|
return Message{}, errors.New("a topic is 1-64 letters, digits, - or _")
|
||||||
}
|
}
|
||||||
@@ -110,7 +115,9 @@ func (s *Server) Publish(topic, title, body string, prio int, tags []string) (Me
|
|||||||
m.ID, m.Topic, m.Time, m.Title, m.Message, m.Priority, string(tj)); err != nil {
|
m.ID, m.Topic, m.Time, m.Title, m.Message, m.Priority, string(tj)); err != nil {
|
||||||
return m, err
|
return m, err
|
||||||
}
|
}
|
||||||
|
m.Alert = alertID
|
||||||
s.hub.publish(m)
|
s.hub.publish(m)
|
||||||
|
s.pushMessage(m, status)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +162,7 @@ type Note struct {
|
|||||||
Severity string `json:"severity,omitempty"`
|
Severity string `json:"severity,omitempty"`
|
||||||
Link string `json:"link,omitempty"`
|
Link string `json:"link,omitempty"`
|
||||||
Time int64 `json:"time"`
|
Time int64 `json:"time"`
|
||||||
|
Alert string `json:"alert,omitempty"` // the alert's id, for alerts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n Note) subject() string {
|
func (n Note) subject() string {
|
||||||
@@ -291,7 +299,7 @@ func (s *Server) Mail(to, subject, body string) error {
|
|||||||
// broadcast sends a note to the topic named by the source and every enabled channel.
|
// broadcast sends a note to the topic named by the source and every enabled channel.
|
||||||
func (s *Server) broadcast(n Note, topics ...string) {
|
func (s *Server) broadcast(n Note, topics ...string) {
|
||||||
for _, t := range topics {
|
for _, t := range topics {
|
||||||
if _, err := s.Publish(t, n.subject(), n.text(), n.priority(), n.tags()); err != nil {
|
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)
|
log.Printf("publish %s: %v", t, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ func (s *Server) RaiseAlert(key, source, summary, detail, severity, policyID str
|
|||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
s.st.addEvent("alert", a.ID, "created", source, summary, now)
|
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()}, "alerts")
|
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
|
return a, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ func (s *Server) ResolveAlert(id, actor string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.st.addEvent("alert", id, "resolved", actor, "resolved", now)
|
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()}, "alerts")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ func (s *Server) AckAlert(id, actor string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.st.addEvent("alert", id, "acked", actor, "acknowledged", now)
|
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()}, "alerts")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +233,23 @@ func (s *Server) SnoozeAlert(id, actor string, d time.Duration) error {
|
|||||||
return nil
|
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 ----------
|
// ---------- escalation ----------
|
||||||
|
|
||||||
// escalate runs every alert step that has come due.
|
// escalate runs every alert step that has come due.
|
||||||
@@ -289,7 +306,7 @@ func (s *Server) runStep(id string, now time.Time) {
|
|||||||
for _, uid := range people {
|
for _, uid := range people {
|
||||||
if u, err := s.userByID(uid); err == nil {
|
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,
|
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()})
|
Link: s.link("/#alerts"), Time: now.Unix(), Alert: a.ID})
|
||||||
names = append(names, u.Name)
|
names = append(names, u.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,7 +331,7 @@ func (s *Server) runStep(id string, now time.Time) {
|
|||||||
// notifyUser reaches one person: a message on their personal topic (u-<id>, which
|
// 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.
|
// their phone subscribes to) and an email if the server can send mail.
|
||||||
func (s *Server) notifyUser(u *User, n Note) {
|
func (s *Server) notifyUser(u *User, n Note) {
|
||||||
s.Publish(userTopic(u.ID), n.subject(), n.text(), n.priority(), n.tags())
|
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) {
|
if u.Email != "" && (s.opts.SMTP.Host != "" || s.opts.Mailer != nil) {
|
||||||
go func() {
|
go func() {
|
||||||
if err := s.Mail(u.Email, n.subject(), n.text()); err != nil {
|
if err := s.Mail(u.Email, n.subject(), n.text()); err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package sandbar
|
package sandbar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -28,6 +29,17 @@ type Options struct {
|
|||||||
HitDays int // how long website analytics are kept (default 365)
|
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
|
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
|
// 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
|
// ("dev") never updates. Restart is called after a new binary is installed and should close
|
||||||
// what the process holds open before re-executing it.
|
// what the process holds open before re-executing it.
|
||||||
@@ -57,10 +69,24 @@ type Server struct {
|
|||||||
|
|
||||||
upd updateState
|
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
|
anaMu sync.Mutex // website analytics: the visitor salt and a per-address rate limit
|
||||||
anaSecret string
|
anaSecret string
|
||||||
anaTry map[string][]time.Time
|
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{}
|
stop chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
@@ -75,7 +101,7 @@ func New(opts Options, st *Store) *Server {
|
|||||||
if opts.HitDays <= 0 {
|
if opts.HitDays <= 0 {
|
||||||
opts.HitDays = 365
|
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{}, stop: make(chan struct{})}
|
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 {
|
func (s *Server) now() time.Time {
|
||||||
@@ -89,12 +115,14 @@ func (s *Server) now() time.Time {
|
|||||||
func (s *Server) Start() {
|
func (s *Server) Start() {
|
||||||
s.every(time.Second, func() { s.runDueMonitors(s.now()) })
|
s.every(time.Second, func() { s.runDueMonitors(s.now()) })
|
||||||
s.every(5*time.Second, func() { s.escalate(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) })
|
s.every(time.Hour, func() { s.st.prune(s.now(), s.opts.MessageDays, s.opts.CheckDays, s.opts.HitDays) })
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Close() {
|
func (s *Server) Close() {
|
||||||
close(s.stop)
|
close(s.stop)
|
||||||
s.wg.Wait()
|
s.wg.Wait()
|
||||||
|
s.notifyWG.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) every(d time.Duration, f func()) {
|
func (s *Server) every(d time.Duration, f func()) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -65,10 +65,18 @@ CREATE TABLE IF NOT EXISTS alerts (
|
|||||||
resolved_at INTEGER NOT NULL DEFAULT 0, incident_id TEXT NOT NULL DEFAULT '');
|
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_key ON alerts(key, status);
|
||||||
CREATE INDEX IF NOT EXISTS alerts_due ON alerts(status, next_at);
|
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 (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, ref TEXT NOT NULL, time INTEGER NOT NULL,
|
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);
|
kind TEXT NOT NULL, actor TEXT NOT NULL, text TEXT NOT NULL);
|
||||||
CREATE INDEX IF NOT EXISTS events_ref ON events(scope, ref, time);
|
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.
|
// OpenStore opens (creating if needed) the database in dir.
|
||||||
@@ -87,6 +95,8 @@ func OpenStore(dir string, box *secretbox.Box) (*Store, error) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
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 := &Store{db: db, box: box}
|
||||||
st.backfillDaily()
|
st.backfillDaily()
|
||||||
return st, nil
|
return st, nil
|
||||||
@@ -233,24 +243,31 @@ type Event struct {
|
|||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Actor string `json:"actor"`
|
Actor string `json:"actor"`
|
||||||
Text string `json:"text"`
|
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 {
|
func (s *Store) events(scope, ref string) []Event {
|
||||||
out := []Event{}
|
out := []Event{}
|
||||||
rows, err := s.db.Query(`SELECT time,kind,actor,text FROM events WHERE scope=? AND ref=? ORDER BY time, id`, scope, ref)
|
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 {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Event
|
var e Event
|
||||||
if rows.Scan(&e.Time, &e.Kind, &e.Actor, &e.Text) == nil {
|
if rows.Scan(&e.Time, &e.Kind, &e.Actor, &e.Text, &e.Status) == nil {
|
||||||
out = append(out, e)
|
out = append(out, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
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.
|
// prune deletes data past its retention.
|
||||||
func (s *Store) prune(now time.Time, messageDays, checkDays, hitDays int) {
|
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 hits WHERE time < ?`, now.AddDate(0, 0, -hitDays).Unix())
|
||||||
@@ -258,5 +275,6 @@ func (s *Store) prune(now time.Time, messageDays, checkDays, hitDays int) {
|
|||||||
s.db.Exec(`DELETE FROM checks WHERE time < ?`, now.AddDate(0, 0, -checkDays).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 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 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())
|
s.db.Exec(`DELETE FROM alerts WHERE status='resolved' AND resolved_at < ? AND incident_id=''`, now.AddDate(0, 0, -90).Unix())
|
||||||
}
|
}
|
||||||
+116
-10
@@ -445,6 +445,7 @@ const PAGES = {
|
|||||||
monitors: ['Monitors', 'Checks that tell you when a website, port, certificate or scheduled job stops working.'],
|
monitors: ['Monitors', 'Checks that tell you when a website, port, certificate or scheduled job stops working.'],
|
||||||
websites: ['Websites', 'Count visitors and page views on your sites, without cookies or personal data.'],
|
websites: ['Websites', 'Count visitors and page views on your sites, without cookies or personal data.'],
|
||||||
topics: ['Topics', 'Publish and subscribe to messages with plain HTTP requests. Phones can use the ntfy app.'],
|
topics: ['Topics', 'Publish and subscribe to messages with plain HTTP requests. Phones can use the ntfy app.'],
|
||||||
|
statuspage:['Status page', 'What customers see: components, incidents, scheduled maintenance, and who follows along.'],
|
||||||
channels: ['Channels','Email addresses and webhooks that receive every alert and incident update.'],
|
channels: ['Channels','Email addresses and webhooks that receive every alert and incident update.'],
|
||||||
users: ['People', 'Everyone who can sign in, and how to reach them.'],
|
users: ['People', 'Everyone who can sign in, and how to reach them.'],
|
||||||
account: ['Account', 'Your password, API keys and personal topic.'],
|
account: ['Account', 'Your password, API keys and personal topic.'],
|
||||||
@@ -452,7 +453,7 @@ const PAGES = {
|
|||||||
const NAV = [
|
const NAV = [
|
||||||
['Respond', [['alerts', 'Alerts', 'alerts'], ['incidents', 'Incidents', 'incidents'], ['oncall', 'On-call', 'oncall']]],
|
['Respond', [['alerts', 'Alerts', 'alerts'], ['incidents', 'Incidents', 'incidents'], ['oncall', 'On-call', 'oncall']]],
|
||||||
['Observe', [['monitors', 'Monitors', 'monitors'], ['websites', 'Websites', 'websites'], ['topics', 'Topics', 'topics']]],
|
['Observe', [['monitors', 'Monitors', 'monitors'], ['websites', 'Websites', 'websites'], ['topics', 'Topics', 'topics']]],
|
||||||
['Set up', [['channels', 'Channels', 'channels'], ['users', 'People', 'users']]],
|
['Set up', [['statuspage', 'Status page', 'status'], ['channels', 'Channels', 'channels'], ['users', 'People', 'users']]],
|
||||||
];
|
];
|
||||||
function route() {
|
function route() {
|
||||||
const [tab, arg] = (location.hash.replace(/^#\/?/, '') || 'alerts').split('/');
|
const [tab, arg] = (location.hash.replace(/^#\/?/, '') || 'alerts').split('/');
|
||||||
@@ -468,7 +469,7 @@ async function render() {
|
|||||||
side.push(h('div', {class:'navsec'}, sec));
|
side.push(h('div', {class:'navsec'}, sec));
|
||||||
for (const [k, l, i] of items) side.push(nav(k, l, i));
|
for (const [k, l, i] of items) side.push(nav(k, l, i));
|
||||||
}
|
}
|
||||||
side.push(h('div', {class:'navsec'}, 'Public'), h('a', {class:'navitem', href:'/status', target:'_blank', rel:'noopener'}, icon('status'), 'Status page'));
|
side.push(h('div', {class:'navsec'}, 'Public'), h('a', {class:'navitem', href:'/status', target:'_blank', rel:'noopener'}, icon('status'), 'View status page'));
|
||||||
side.push(h('div', {class:'grow'}), h('div', {class:'userbox'}, h('div', {class:'who'}, S.me.email), nav('account', 'Account', 'account'),
|
side.push(h('div', {class:'grow'}), h('div', {class:'userbox'}, h('div', {class:'who'}, S.me.email), nav('account', 'Account', 'account'),
|
||||||
h('button', {class:'navitem', onclick:toggleTheme}, icon('dark'), 'Switch theme'),
|
h('button', {class:'navitem', onclick:toggleTheme}, icon('dark'), 'Switch theme'),
|
||||||
h('button', {class:'navitem', onclick: act(async () => { await api('POST', V1 + '/logout'); S.me = null; boot(); })}, icon('out'), 'Sign out')));
|
h('button', {class:'navitem', onclick: act(async () => { await api('POST', V1 + '/logout'); S.me = null; boot(); })}, icon('out'), 'Sign out')));
|
||||||
@@ -566,7 +567,8 @@ function raiseAlertDialog() {
|
|||||||
const KINDS = [['http', 'Website (HTTP)'], ['tcp', 'Port (TCP)'], ['dns', 'DNS lookup'], ['tls', 'Certificate expiry'], ['heartbeat', 'Heartbeat (cron job or service pings us)']];
|
const KINDS = [['http', 'Website (HTTP)'], ['tcp', 'Port (TCP)'], ['dns', 'DNS lookup'], ['tls', 'Certificate expiry'], ['heartbeat', 'Heartbeat (cron job or service pings us)']];
|
||||||
views.monitors = async () => {
|
views.monitors = async () => {
|
||||||
S.headerAction = ['Add monitor', () => monitorDialog()];
|
S.headerAction = ['Add monitor', () => monitorDialog()];
|
||||||
const [mons, states] = await Promise.all([api('GET', V1 + '/monitors'), api('GET', V1 + '/monitor-states')]);
|
const [mons, states, groups] = await Promise.all([api('GET', V1 + '/monitors'), api('GET', V1 + '/monitor-states'), api('GET', V1 + '/status-groups')]);
|
||||||
|
S.sgroups = groups;
|
||||||
if (!mons.length) return emptyState('monitors', 'No monitors yet', 'Add a check for a website, a port, a certificate or a scheduled job. Failures raise an alert.', h('button', {class:'btn', onclick:() => monitorDialog()}, 'Add monitor'));
|
if (!mons.length) return emptyState('monitors', 'No monitors yet', 'Add a check for a website, a port, a certificate or a scheduled job. Failures raise an alert.', h('button', {class:'btn', onclick:() => monitorDialog()}, 'Add monitor'));
|
||||||
const statusBadge = (m, st) => !m.enabled ? badge('Paused') : st.status === 'up' ? badge('Up', 'success') : st.status === 'down' ? badge('Down', 'danger') : badge('Waiting for first check');
|
const statusBadge = (m, st) => !m.enabled ? badge('Paused') : st.status === 'up' ? badge('Up', 'success') : st.status === 'down' ? badge('Down', 'danger') : badge('Waiting for first check');
|
||||||
return table(['Name', 'Check', 'Status', 'Last check', 'Uptime', ''], mons.map(m => { const st = states[m.id] || {};
|
return table(['Name', 'Check', 'Status', 'Last check', 'Uptime', ''], mons.map(m => { const st = states[m.id] || {};
|
||||||
@@ -589,6 +591,7 @@ function monitorDialog(m) {
|
|||||||
warn: h('input', {type:'number', min:1, value:m.warnDays || 14}), grace: h('input', {type:'number', min:0, value:m.graceS || 0}),
|
warn: h('input', {type:'number', min:1, value:m.warnDays || 14}), grace: h('input', {type:'number', min:0, value:m.graceS || 0}),
|
||||||
pol: select([['', 'None, only broadcast'], ...S.policies.map(p => [p.id, p.name])], m.policyId || ''),
|
pol: select([['', 'None, only broadcast'], ...S.policies.map(p => [p.id, p.name])], m.policyId || ''),
|
||||||
sev: select([['critical', 'Critical'], ['warning', 'Warning'], ['info', 'Info']], m.severity || 'critical'),
|
sev: select([['critical', 'Critical'], ['warning', 'Warning'], ['info', 'Info']], m.severity || 'critical'),
|
||||||
|
group: select([['', 'No group'], ...(S.sgroups || []).map(g => [g.id, g.name])], m.group || ''), pubname: h('input', {value:m.publicName || ''}),
|
||||||
pub: chk('Show on the public status page', m.public), priv: chk('Allow targets on private networks', m.allowPrivate), on: chk('Run this check', m.enabled) };
|
pub: chk('Show on the public status page', m.public), priv: chk('Allow targets on private networks', m.allowPrivate), on: chk('Run this check', m.enabled) };
|
||||||
const only = (kinds, el) => { const w = h('div', {class:'stack'}, el); w.kinds = kinds; return w; };
|
const only = (kinds, el) => { const w = h('div', {class:'stack'}, el); w.kinds = kinds; return w; };
|
||||||
const groups = [
|
const groups = [
|
||||||
@@ -606,11 +609,12 @@ function monitorDialog(m) {
|
|||||||
groups.push(hbOnly); sync();
|
groups.push(hbOnly); sync();
|
||||||
dialogForm(e ? 'Edit ' + m.name : 'Add a monitor', null, [field('Name', f.name), field('Type', f.kind), ...groups,
|
dialogForm(e ? 'Edit ' + m.name : 'Add a monitor', null, [field('Name', f.name), field('Type', f.kind), ...groups,
|
||||||
h('div', {class:'grid'}, field('Check every (seconds)', f.interval, 'At least 10.'), field('Timeout (seconds)', f.timeout), field('Failures before down', f.retries, 'A blip is ignored.')),
|
h('div', {class:'grid'}, field('Check every (seconds)', f.interval, 'At least 10.'), field('Timeout (seconds)', f.timeout), field('Failures before down', f.retries, 'A blip is ignored.')),
|
||||||
h('div', {class:'grid'}, field('Alert severity', f.sev), field('Escalation policy', f.pol)), f.pub, f.on], e ? 'Save changes' : 'Add monitor',
|
h('div', {class:'grid'}, field('Alert severity', f.sev), field('Escalation policy', f.pol)), f.pub,
|
||||||
|
h('div', {class:'grid'}, field('Status page group', f.group, 'Groups are managed under Status page.'), field('Name on the status page', f.pubname, 'Leave empty to use the monitor name.')), f.on], e ? 'Save changes' : 'Add monitor',
|
||||||
async () => {
|
async () => {
|
||||||
const body = {...m, name:f.name.value, kind:f.kind.value, target:f.target.value, intervalS:+f.interval.value, timeoutS:+f.timeout.value, retries:+f.retries.value,
|
const body = {...m, name:f.name.value, kind:f.kind.value, target:f.target.value, intervalS:+f.interval.value, timeoutS:+f.timeout.value, retries:+f.retries.value,
|
||||||
expect:f.expect.value, keyword:f.keyword.value, warnDays:+f.warn.value, graceS:+f.grace.value, policyId:f.pol.value, severity:f.sev.value,
|
expect:f.expect.value, keyword:f.keyword.value, warnDays:+f.warn.value, graceS:+f.grace.value, policyId:f.pol.value, severity:f.sev.value,
|
||||||
public:f.pub.box.checked, allowPrivate:f.priv.box.checked, enabled:f.on.box.checked};
|
public:f.pub.box.checked, group:f.group.value, publicName:f.pubname.value.trim(), allowPrivate:f.priv.box.checked, enabled:f.on.box.checked};
|
||||||
const saved = await api(e ? 'PUT' : 'POST', V1 + '/monitors' + (e ? '/' + m.id : ''), body);
|
const saved = await api(e ? 'PUT' : 'POST', V1 + '/monitors' + (e ? '/' + m.id : ''), body);
|
||||||
toast(e ? 'Monitor saved.' : 'Monitor added.'); refresh();
|
toast(e ? 'Monitor saved.' : 'Monitor added.'); refresh();
|
||||||
if (!e && saved.kind === 'heartbeat') { const done = modal('Heartbeat address', h('div', {class:'stack'}, h('p', {class:'dim', style:'margin:0'}, 'Have your job request this address each time it runs. It goes down when the requests stop.'), cmdBox(`${origin()}/api/v1/hb/${saved.token}`), h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done')))); }
|
if (!e && saved.kind === 'heartbeat') { const done = modal('Heartbeat address', h('div', {class:'stack'}, h('p', {class:'dim', style:'margin:0'}, 'Have your job request this address each time it runs. It goes down when the requests stop.'), cmdBox(`${origin()}/api/v1/hb/${saved.token}`), h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done')))); }
|
||||||
@@ -687,10 +691,22 @@ views.incidents = async () => {
|
|||||||
h('td', {}, h('a', {href:'#incidents/' + i.id}, i.title), i.public ? h('div', {}, badge('On status page', 'accent')) : null),
|
h('td', {}, h('a', {href:'#incidents/' + i.id}, i.title), i.public ? h('div', {}, badge('On status page', 'accent')) : null),
|
||||||
h('td', {}, incStatus(i.status)), h('td', {}, userName(i.lead) || 'Nobody'), h('td', {class:'num'}, fmtTime(i.created)))));
|
h('td', {}, incStatus(i.status)), h('td', {}, userName(i.lead) || 'Nobody'), h('td', {class:'num'}, fmtTime(i.created)))));
|
||||||
};
|
};
|
||||||
function declareDialog(alertIds, title) {
|
// monitors that are on the status page, which are the components an incident can affect
|
||||||
const f = { title: h('input', {value:title || ''}), sev: select(SEVS, 'sev2'), sum: h('textarea'), lead: select(userOpts('Me'), '') };
|
const publicMonitors = async () => (await api('GET', V1 + '/monitors')).filter(m => m.public && m.enabled);
|
||||||
dialogForm('Declare an incident', 'Everyone subscribed to the incidents topic and every channel is told.', [field('Title', f.title, 'What is wrong, in a few words.'), field('Severity', f.sev), field('What we know so far', f.sum), field('Incident lead', f.lead)], 'Declare incident',
|
const monName = m => m.publicName || m.name;
|
||||||
async () => { const i = await api('POST', V1 + '/incidents', {title:f.title.value, severity:f.sev.value, summary:f.sum.value, lead:f.lead.value, alertIds:alertIds || []}); toast('Incident declared.'); location.hash = 'incidents/' + i.id; });
|
// a list of checkboxes, one per component; values() gives the ids that are ticked
|
||||||
|
function componentPicker(mons, selected) {
|
||||||
|
const boxes = mons.map(m => chk(monName(m), (selected || []).includes(m.id)));
|
||||||
|
const box = h('div', {class:'stack'}, boxes.length ? boxes : h('p', {class:'small dim', style:'margin:0'}, 'No monitors are on the status page yet. Tick "Show on the public status page" on a monitor.'));
|
||||||
|
return Object.assign(box, {values: () => mons.filter((m, i) => boxes[i].box.checked).map(m => m.id)});
|
||||||
|
}
|
||||||
|
async function declareDialog(alertIds, title) {
|
||||||
|
const mons = await publicMonitors(), picker = componentPicker(mons, []);
|
||||||
|
const f = { title: h('input', {value:title || ''}), sev: select(SEVS, 'sev2'), sum: h('textarea'), lead: select(userOpts('Me'), ''), pub: chk('Post it on the status page and tell subscribers', false) };
|
||||||
|
const comps = h('div', {class:'stack'}, field('Affected components', picker, 'Shown on the status page.'));
|
||||||
|
f.pub.box.onchange = () => { comps.style.display = f.pub.box.checked ? '' : 'none'; }; comps.style.display = 'none';
|
||||||
|
dialogForm('Declare an incident', 'Everyone subscribed to the incidents topic and every channel is told.', [field('Title', f.title, 'What is wrong, in a few words.'), field('Severity', f.sev), field('What we know so far', f.sum, 'If you post it on the status page, customers see this text.'), field('Incident lead', f.lead), f.pub, comps], 'Declare incident',
|
||||||
|
async () => { const i = await api('POST', V1 + '/incidents', {title:f.title.value, severity:f.sev.value, summary:f.sum.value, lead:f.lead.value, alertIds:alertIds || [], public:f.pub.box.checked, components:picker.values()}); toast('Incident declared.'); location.hash = 'incidents/' + i.id; });
|
||||||
}
|
}
|
||||||
async function incidentDetail(id) {
|
async function incidentDetail(id) {
|
||||||
S.noHeader = true;
|
S.noHeader = true;
|
||||||
@@ -698,13 +714,24 @@ async function incidentDetail(id) {
|
|||||||
const patch = act(async (body, msg) => { await api('PATCH', `${V1}/incidents/${id}`, body); toast(msg || 'Incident updated.'); refresh(); });
|
const patch = act(async (body, msg) => { await api('PATCH', `${V1}/incidents/${id}`, body); toast(msg || 'Incident updated.'); refresh(); });
|
||||||
const status = select(STATUSES, i.status, {onchange:e => patch({status:e.target.value})}), sev = select(SEVS, i.severity, {onchange:e => patch({severity:e.target.value})});
|
const status = select(STATUSES, i.status, {onchange:e => patch({status:e.target.value})}), sev = select(SEVS, i.severity, {onchange:e => patch({severity:e.target.value})});
|
||||||
const lead = select(userOpts('Nobody'), i.lead, {onchange:e => patch({lead:e.target.value})}), comms = select(userOpts('Nobody'), i.comms, {onchange:e => patch({comms:e.target.value})});
|
const lead = select(userOpts('Nobody'), i.lead, {onchange:e => patch({lead:e.target.value})}), comms = select(userOpts('Nobody'), i.comms, {onchange:e => patch({comms:e.target.value})});
|
||||||
|
const mons = await publicMonitors(), picker = componentPicker(mons, i.components);
|
||||||
const pub = chk('Show on the public status page', i.public, v => patch({public:v}));
|
const pub = chk('Show on the public status page', i.public, v => patch({public:v}));
|
||||||
|
const post = () => {
|
||||||
|
const f = { status: select(STATUSES, i.status), text: h('textarea', {placeholder:'What customers should know'}) };
|
||||||
|
dialogForm('Post an update', 'Customers and subscribers see this text and the status. The timeline and names stay internal.', [field('Status', f.status), field('Update', f.text, 'Leave empty to use the standard wording for the status.')], 'Post update',
|
||||||
|
async () => { await api('PATCH', `${V1}/incidents/${id}`, {status:f.status.value, note:f.text.value}); toast('Update posted.'); refresh(); });
|
||||||
|
};
|
||||||
const sum = h('textarea', {}, i.summary), pm = h('textarea', {style:'min-height:220px', class:'mono'}, i.postmortem), note = h('input', {placeholder:'Add a comment for the timeline'});
|
const sum = h('textarea', {}, i.summary), pm = h('textarea', {style:'min-height:220px', class:'mono'}, i.postmortem), note = h('input', {placeholder:'Add a comment for the timeline'});
|
||||||
const say = act(async () => { if (!note.value.trim()) return; await api('POST', `${V1}/incidents/${id}/comments`, {text:note.value}); note.value = ''; refresh(); });
|
const say = act(async () => { if (!note.value.trim()) return; await api('POST', `${V1}/incidents/${id}/comments`, {text:note.value}); note.value = ''; refresh(); });
|
||||||
note.addEventListener('keydown', e => e.key === 'Enter' && say());
|
note.addEventListener('keydown', e => e.key === 'Enter' && say());
|
||||||
setTimeout(() => { S.noHeader = false; });
|
setTimeout(() => { S.noHeader = false; });
|
||||||
return h('div', {}, h('div', {class:'page-h'}, h('div', {}, h('a', {href:'#incidents', class:'small'}, 'Back to incidents'), h('h1', {}, i.title), h('p', {}, 'Declared ' + fmtTime(i.created) + (i.resolved ? ', resolved ' + fmtTime(i.resolved) : ''))), h('div', {}, incStatus(i.status))),
|
return h('div', {}, h('div', {class:'page-h'}, h('div', {}, h('a', {href:'#incidents', class:'small'}, 'Back to incidents'), h('h1', {}, i.title), h('p', {}, 'Declared ' + fmtTime(i.created) + (i.resolved ? ', resolved ' + fmtTime(i.resolved) : ''))), h('div', {}, incStatus(i.status))),
|
||||||
card('Status', h('div', {class:'grid'}, field('Status', status), field('Severity', sev), field('Lead', lead), field('Communications', comms)), pub),
|
card('Status', h('div', {class:'grid'}, field('Status', status), field('Severity', sev), field('Lead', lead), field('Communications', comms))),
|
||||||
|
card('Status page', h('div', {class:'stack'}, pub,
|
||||||
|
i.public ? h('p', {class:'small dim', style:'margin:0'}, 'Changing the status above, or posting an update, adds a post to the public page and emails subscribers.') : h('p', {class:'small dim', style:'margin:0'}, 'Turning this on posts the summary below and tells subscribers.'),
|
||||||
|
field('Affected components', picker, 'The page shows these as affected until the incident is resolved.'),
|
||||||
|
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => patch({components:picker.values()}, 'Components saved.')}, 'Save components'),
|
||||||
|
i.public ? h('button', {class:'btn', onclick:post}, 'Post an update') : null))),
|
||||||
card('Summary', h('div', {class:'stack'}, sum, h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => patch({summary:sum.value}, 'Summary saved.')}, 'Save summary')))),
|
card('Summary', h('div', {class:'stack'}, sum, h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => patch({summary:sum.value}, 'Summary saved.')}, 'Save summary')))),
|
||||||
card('Timeline', timeline(i.timeline || []), h('div', {class:'actions', style:'margin-top:16px'}, h('div', {style:'flex:1;min-width:200px'}, note), h('button', {class:'ghost', onclick:say}, 'Add comment'))),
|
card('Timeline', timeline(i.timeline || []), h('div', {class:'actions', style:'margin-top:16px'}, h('div', {style:'flex:1;min-width:200px'}, note), h('button', {class:'ghost', onclick:say}, 'Add comment'))),
|
||||||
card('Post-mortem', h('div', {class:'stack'}, pm, h('div', {class:'actions form-actions'},
|
card('Post-mortem', h('div', {class:'stack'}, pm, h('div', {class:'actions form-actions'},
|
||||||
@@ -739,6 +766,85 @@ views.topics = async () => {
|
|||||||
card('From the command line', cmdBox(`curl -H "Authorization: Bearer <api key>" -H "Title: Disk full" -H "Priority: urgent" -d "web-1 is at 98%" ${base}/alerts\ncurl -H "Authorization: Bearer <api key>" -N ${base}/alerts/sse`)));
|
card('From the command line', cmdBox(`curl -H "Authorization: Bearer <api key>" -H "Title: Disk full" -H "Priority: urgent" -d "web-1 is at 98%" ${base}/alerts\ncurl -H "Authorization: Bearer <api key>" -N ${base}/alerts/sse`)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------- status page ----------
|
||||||
|
const MSTATE = {scheduled:['Scheduled', ''], in_progress:['In progress', 'accent'], completed:['Completed', 'success'], canceled:['Canceled', '']};
|
||||||
|
views.statuspage = async () => {
|
||||||
|
const which = S.spTab || 'page';
|
||||||
|
const tabs = h('div', {class:'tabs2', role:'group', 'aria-label':'Status page sections'}, [['page', 'Page'], ['groups', 'Groups'], ['maintenance', 'Maintenance'], ['subscribers', 'Subscribers']].map(([k, l]) =>
|
||||||
|
h('button', {class:which === k ? 'on' : '', 'aria-pressed':which === k, onclick:() => { S.spTab = k; refresh(); }}, l)));
|
||||||
|
const admin = isAdmin();
|
||||||
|
if (which === 'groups') {
|
||||||
|
S.headerAction = admin ? ['Add group', () => groupDialog()] : null;
|
||||||
|
const [groups, mons] = await Promise.all([api('GET', V1 + '/status-groups'), api('GET', V1 + '/monitors')]);
|
||||||
|
S.sgroups = groups;
|
||||||
|
if (!groups.length) return h('div', {}, tabs, emptyState('status', 'No groups yet', 'Groups put related components under one heading, such as Website, API and Infrastructure. Components without a group are listed first.', admin ? h('button', {class:'btn', onclick:() => groupDialog()}, 'Add group') : null));
|
||||||
|
return h('div', {}, tabs, table(['Group', 'Components', 'Order', ''], groups.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)).map(g => {
|
||||||
|
const inside = mons.filter(m => m.public && m.group === g.id);
|
||||||
|
return h('tr', {}, h('td', {}, g.name, g.description ? h('div', {class:'small dim'}, g.description) : null), h('td', {}, inside.length ? inside.map(monName).join(', ') : 'None yet'), h('td', {class:'num'}, g.order),
|
||||||
|
h('td', {}, admin ? h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => groupDialog(g)}, 'Edit'),
|
||||||
|
h('button', {class:'ghost danger', onclick:ask({title:`Delete ${g.name}?`, body:'Its components move to the top of the page, ungrouped. The monitors are not changed. This cannot be undone.', confirm:'Delete group'}, async () => { await api('DELETE', `${V1}/status-groups/${g.id}`); toast('Group deleted.'); refresh(); })}, 'Delete')) : null));
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
if (which === 'maintenance') {
|
||||||
|
S.headerAction = admin ? ['Schedule maintenance', () => maintenanceDialog()] : null;
|
||||||
|
const [list, mons] = await Promise.all([api('GET', V1 + '/maintenance'), api('GET', V1 + '/monitors')]);
|
||||||
|
const name = id => { const m = mons.find(x => x.id === id); return m ? monName(m) : null; };
|
||||||
|
if (!list.length) return h('div', {}, tabs, emptyState('status', 'No maintenance scheduled', 'Planned work is announced on the status page and sent to subscribers when it is scheduled, when it starts and when it ends. Affected components show as under maintenance meanwhile.', admin ? h('button', {class:'btn', onclick:() => maintenanceDialog()}, 'Schedule maintenance') : null));
|
||||||
|
return h('div', {}, tabs, table(['Maintenance', 'Window', 'Status', ''], list.map(m => { const st = MSTATE[m.state] || [m.state, ''], open = m.state === 'scheduled' || m.state === 'in_progress';
|
||||||
|
return h('tr', {}, h('td', {}, m.title, h('div', {class:'small dim'}, m.components.map(name).filter(Boolean).join(', ') || 'No components named')), h('td', {class:'num'}, fmtTime(m.start) + ' to ' + fmtTime(m.end)), h('td', {}, badge(st[0], st[1])),
|
||||||
|
h('td', {}, admin ? h('div', {class:'actions'}, open ? h('button', {class:'ghost', onclick:() => maintenanceDialog(m)}, 'Edit') : null,
|
||||||
|
open ? h('button', {class:'ghost danger', onclick:ask({title:`Cancel ${m.title}?`, body:'Subscribers are told it is canceled. It stays in the history.', confirm:'Cancel maintenance', cancel:'Keep it'}, async () => { await api('POST', `${V1}/maintenance/${m.id}/cancel`); toast('Maintenance canceled.'); refresh(); })}, 'Cancel') : null,
|
||||||
|
h('button', {class:'ghost danger', onclick:ask({title:`Delete ${m.title}?`, body:'It is removed from the status page and its history. This cannot be undone.', confirm:'Delete maintenance'}, async () => { await api('DELETE', `${V1}/maintenance/${m.id}`); toast('Maintenance deleted.'); refresh(); })}, 'Delete')) : null));
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
if (which === 'subscribers') {
|
||||||
|
S.headerAction = null;
|
||||||
|
if (!admin) return h('div', {}, tabs, emptyState('users', 'Administrators only', 'Ask an administrator to see who follows the status page.'));
|
||||||
|
const list = await api('GET', V1 + '/status-subscribers');
|
||||||
|
if (!list.length) return h('div', {}, tabs, emptyState('users', 'No subscribers yet', 'People subscribe with the button on the public page. Confirmed email addresses and webhooks appear here.'));
|
||||||
|
return h('div', {}, tabs, table(['Type', 'Address', 'Status', 'Subscribed', ''], list.map(s => h('tr', {}, h('td', {}, s.kind === 'email' ? 'Email' : 'Webhook'), h('td', {class:'mono small'}, s.target),
|
||||||
|
h('td', {}, s.confirmed ? badge('Confirmed', 'success') : badge('Waiting for confirmation')), h('td', {class:'num'}, fmtTime(s.created)),
|
||||||
|
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost danger', onclick:ask({title:`Remove ${s.target}?`, body:'They stop getting updates. They can subscribe again from the page.', confirm:'Remove subscriber'}, async () => { await api('DELETE', `${V1}/status-subscribers/${s.id}`); toast('Subscriber removed.'); refresh(); })}, 'Remove')))))));
|
||||||
|
}
|
||||||
|
S.headerAction = null;
|
||||||
|
const r = await api('GET', V1 + '/statuspage'), c = r.settings;
|
||||||
|
const f = { title: h('input', {value:c.title}), desc: h('textarea', {}, c.description), logo: h('input', {type:'url', value:c.logoUrl, placeholder:'https://example.com/logo.svg'}),
|
||||||
|
accent: h('input', {value:c.accent, placeholder:'#1f5c5c'}), support: h('input', {value:c.supportUrl, placeholder:'https://example.com/support or mailto:help@example.com'}),
|
||||||
|
footer: h('input', {value:c.footer}), domain: h('input', {value:c.domain, placeholder:'status.example.com'}) };
|
||||||
|
const save = act(async () => {
|
||||||
|
await api('PUT', V1 + '/statuspage', {title:f.title.value, description:f.desc.value, logoUrl:f.logo.value, accent:f.accent.value, supportUrl:f.support.value, footer:f.footer.value, domain:f.domain.value});
|
||||||
|
toast('Status page saved.'); refresh();
|
||||||
|
});
|
||||||
|
if (!admin) [...Object.values(f)].forEach(i => i.disabled = true);
|
||||||
|
return h('div', {}, tabs,
|
||||||
|
card('Address', h('div', {class:'stack'}, h('p', {class:'small dim', style:'margin:0'}, 'Monitors and incidents appear here when you mark them to show on the public status page.'),
|
||||||
|
h('div', {}, h('div', {class:'small dim'}, 'Status page'), h('a', {href:r.pageUrl, target:'_blank', rel:'noopener', class:'mono'}, r.pageUrl)),
|
||||||
|
h('div', {}, h('div', {class:'small dim'}, 'RSS feed'), h('a', {href:r.feedUrl, target:'_blank', rel:'noopener', class:'mono'}, r.feedUrl)),
|
||||||
|
r.email ? null : h('p', {class:'small dim', style:'margin:0'}, 'Email subscriptions are off until an SMTP server is set (SANDBAR_SMTP_HOST). Webhooks and RSS work without it.'))),
|
||||||
|
card('Branding', h('div', {class:'stack'}, field('Title', f.title), field('Description', f.desc, 'Shown under the title.'), h('div', {class:'grid'}, field('Logo address', f.logo, 'An https:// address of an image.'),
|
||||||
|
field('Accent color', f.accent, 'Buttons and links, as #rrggbb. Leave empty for the default. White text must stay readable on it.')),
|
||||||
|
h('div', {class:'grid'}, field('Contact link', f.support, 'Adds a Contact us button.'), field('Footer text', f.footer)))),
|
||||||
|
card('Custom domain', h('div', {class:'stack'}, field('Host name', f.domain, 'Serve the page at the root of this host name, such as status.example.com. Leave empty to use the address above only.'),
|
||||||
|
h('p', {class:'small dim', style:'margin:0'}, 'Point a DNS record for it at the server that runs Sandbar, and have your reverse proxy forward that host name to Sandbar with the Host header unchanged. Sandbar serves only the status page on that host. The proxy provides the certificate.'))),
|
||||||
|
admin ? h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:save}, 'Save status page')) : null);
|
||||||
|
};
|
||||||
|
function groupDialog(g) {
|
||||||
|
const e = !!g; g = g || {order:(S.sgroups || []).length + 1};
|
||||||
|
const f = { name: h('input', {value:g.name || ''}), desc: h('input', {value:g.description || ''}), order: h('input', {type:'number', value:g.order})};
|
||||||
|
dialogForm(e ? 'Edit ' + g.name : 'Add a group', null, [field('Name', f.name), field('Description', f.desc, 'Optional, shown under the name.'), field('Order', f.order, 'Groups are listed from the lowest number to the highest.')], e ? 'Save changes' : 'Add group',
|
||||||
|
async () => { await api(e ? 'PUT' : 'POST', V1 + '/status-groups' + (e ? '/' + g.id : ''), {name:f.name.value, description:f.desc.value, order:+f.order.value || 0}); toast('Group saved.'); refresh(); });
|
||||||
|
}
|
||||||
|
async function maintenanceDialog(m) {
|
||||||
|
const e = !!m, mons = await publicMonitors(), soon = Math.ceil(Date.now() / 3600000) * 3600 + 3600;
|
||||||
|
m = m || {start:soon, end:soon + 3600, components:[]};
|
||||||
|
const picker = componentPicker(mons, m.components);
|
||||||
|
const f = { title: h('input', {value:m.title || ''}), msg: h('textarea', {}, m.message || ''), start: h('input', {type:'datetime-local', value:fromUnix(m.start)}), end: h('input', {type:'datetime-local', value:fromUnix(m.end)}) };
|
||||||
|
dialogForm(e ? 'Edit ' + m.title : 'Schedule maintenance', 'Subscribers are told now, when it starts and when it ends. Times are in your time zone.',
|
||||||
|
[field('Title', f.title, 'What is happening, in a few words.'), field('Details', f.msg, 'What customers should expect.'), h('div', {class:'grid'}, field('Starts', f.start), field('Ends', f.end)), field('Affected components', picker, 'They show as under maintenance during the window.')],
|
||||||
|
e ? 'Save changes' : 'Schedule maintenance',
|
||||||
|
async () => { await api(e ? 'PUT' : 'POST', V1 + '/maintenance' + (e ? '/' + m.id : ''), {title:f.title.value, message:f.msg.value, start:toUnix(f.start.value), end:toUnix(f.end.value), components:picker.values()}); toast('Maintenance saved.'); refresh(); });
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- channels ----------
|
// ---------- channels ----------
|
||||||
views.channels = async () => {
|
views.channels = async () => {
|
||||||
S.headerAction = isAdmin() ? ['Add channel', () => channelDialog()] : null;
|
S.headerAction = isAdmin() ? ['Add channel', () => channelDialog()] : null;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<title>Status</title>
|
<title>__TITLE__</title>
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="__TITLE__" href="__BASE__/feed.rss">
|
||||||
<style>
|
<style>
|
||||||
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
|
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
|
||||||
:root {
|
:root {
|
||||||
@@ -33,18 +34,36 @@
|
|||||||
/* tokens:end */
|
/* tokens:end */
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing:border-box; }
|
*, *::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; }
|
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; }
|
||||||
h1, h2 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
|
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); }
|
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(--s8); }
|
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; }
|
.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 { 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.operational { background:var(--success-bg); color:var(--success); border-color:var(--success); }
|
||||||
.banner.degraded { background:var(--warning-bg); color:var(--warning); border-color:var(--warning); }
|
.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); }
|
.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 { 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 { 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; }
|
.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 { padding:var(--s3) var(--s4) var(--s4); border-bottom:var(--border-w) solid var(--border); }
|
||||||
.svc:last-child { border-bottom:0; }
|
.svc:last-child { border-bottom:0; }
|
||||||
.svc .top { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; }
|
.svc .top { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; }
|
||||||
@@ -60,29 +79,52 @@
|
|||||||
.badge-success { background:var(--success-bg); color:var(--success); }
|
.badge-success { background:var(--success-bg); color:var(--success); }
|
||||||
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
||||||
.badge-danger { background:var(--danger-bg); color:var(--danger); }
|
.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 { padding:var(--s4); border-bottom:var(--border-w) solid var(--border); }
|
||||||
.inc:last-child { border-bottom:0; }
|
.inc:last-child { border-bottom:0; }
|
||||||
.inc ul { margin:var(--s2) 0 0; padding-left:var(--s5); }
|
.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); }
|
: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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="tip" id="tip" role="presentation" hidden></div>
|
<div class="tip" id="tip" role="presentation" hidden></div>
|
||||||
<main>
|
<main id="main" tabindex="-1">
|
||||||
<h1>Status</h1>
|
<div class="head">
|
||||||
<div id="banner" class="banner" role="status">Loading</div>
|
<div class="brand" id="brand"></div>
|
||||||
<h2>Services</h2>
|
<div class="actions" id="actions"></div>
|
||||||
<div class="card" id="services"></div>
|
</div>
|
||||||
<h2>Incidents</h2>
|
<p class="desc" id="desc" hidden></p>
|
||||||
<div class="card" id="incidents"></div>
|
<div id="notice"></div>
|
||||||
|
<div id="view" aria-live="polite"><div class="banner" role="status">Loading</div></div>
|
||||||
</main>
|
</main>
|
||||||
|
<footer id="foot"></footer>
|
||||||
<script>
|
<script>
|
||||||
|
const BASE = '__BASE__';
|
||||||
const $ = id => document.getElementById(id);
|
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 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 when = t => new Date(t * 1000).toLocaleString(undefined, {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'});
|
||||||
const label = { operational: 'All systems are operational', degraded: 'Some systems are affected', outage: 'There is a service outage' };
|
const monthOf = t => new Date(t * 1000).toLocaleDateString(undefined, {month:'long', year:'numeric'});
|
||||||
const statusBadge = s => s === 'up' ? el('span', 'badge badge-success', 'Operational') : s === 'down' ? el('span', 'badge badge-danger', 'Down') : el('span', 'badge', 'Waiting for first check');
|
|
||||||
const incStatus = { investigating: 'Investigating', identified: 'Identified', monitoring: 'Monitoring', resolved: 'Resolved' };
|
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,
|
// 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.
|
// and red below that. Color is never the only signal: the tooltip and the label say it in words.
|
||||||
@@ -101,11 +143,11 @@ function showTip(e, lines) {
|
|||||||
const w = tip.offsetWidth, x = Math.min(Math.max(8, e.clientX - w / 2), innerWidth - w - 8);
|
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';
|
tip.style.left = x + 'px'; tip.style.top = Math.max(8, e.clientY - tip.offsetHeight - 14) + 'px';
|
||||||
}
|
}
|
||||||
function service(m, today) {
|
function component(m, today) {
|
||||||
const box = el('div', 'svc'), top = el('div', 'top');
|
const box = el('div', 'svc'), top = el('div', 'top');
|
||||||
top.append(el('span', '', m.name));
|
top.append(el('span', '', m.name));
|
||||||
const right = el('span', 'small dim num');
|
const right = el('span', 'small dim num'), b = stateBadge[m.state] || stateBadge.pending;
|
||||||
right.append(statusBadge(m.status), document.createTextNode(m.uptime >= 0 ? ' ' + m.uptime.toFixed(2) + '% uptime' : ''));
|
right.append(badge(b[0], b[1]), document.createTextNode(m.uptime >= 0 ? ' ' + m.uptime.toFixed(2) + '% uptime' : ''));
|
||||||
top.append(right); box.append(top);
|
top.append(right); box.append(top);
|
||||||
const days = m.days || [], bars = el('div', 'bars');
|
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;
|
const bad = days.filter(d => dayClass(d) === 'bad').length, warn = days.filter(d => dayClass(d) === 'warn').length;
|
||||||
@@ -113,12 +155,12 @@ function service(m, today) {
|
|||||||
bars.setAttribute('aria-label', m.name + ', the last ' + days.length + ' days: ' + (m.uptime >= 0 ? m.uptime.toFixed(2) + '% uptime' : 'no checks yet') +
|
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'));
|
(bad || warn ? ', ' + bad + ' days with an outage and ' + warn + ' with short failures' : ', no failures'));
|
||||||
days.forEach((d, i) => {
|
days.forEach((d, i) => {
|
||||||
const b = el('span', 'bar ' + dayClass(d));
|
const s = el('span', 'bar ' + dayClass(d));
|
||||||
const lines = dayText(d, i, days.length, today);
|
const lines = dayText(d, i, days.length, today);
|
||||||
b.addEventListener('pointerenter', e => showTip(e, lines));
|
s.addEventListener('pointerenter', e => showTip(e, lines));
|
||||||
b.addEventListener('pointermove', e => showTip(e, lines));
|
s.addEventListener('pointermove', e => showTip(e, lines));
|
||||||
b.addEventListener('pointerleave', () => { tip.hidden = true; });
|
s.addEventListener('pointerleave', () => { tip.hidden = true; });
|
||||||
bars.append(b);
|
bars.append(s);
|
||||||
});
|
});
|
||||||
const axis = el('div', 'axis'); axis.setAttribute('aria-hidden', 'true');
|
const axis = el('div', 'axis'); axis.setAttribute('aria-hidden', 'true');
|
||||||
axis.append(el('span', '', days.length + ' days ago'), el('span', '', 'Today'));
|
axis.append(el('span', '', days.length + ' days ago'), el('span', '', 'Today'));
|
||||||
@@ -126,27 +168,206 @@ function service(m, today) {
|
|||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
const worst = ['operational', 'pending', 'maintenance', 'degraded', 'partial_outage', 'major_outage'];
|
||||||
let d;
|
function groups(d) {
|
||||||
try { d = await (await fetch('/status.json')).json(); } catch (e) { $('banner').textContent = 'The status could not be loaded. Try again in a moment.'; return; }
|
const out = [], byId = {};
|
||||||
$('banner').className = 'banner ' + d.overall;
|
const open = (id, g) => { byId[id] = { g, items: [] }; out.push(byId[id]); };
|
||||||
$('banner').textContent = label[d.overall] || d.overall;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const sv = $('services'); sv.replaceChildren();
|
function posts(list) {
|
||||||
if (!d.monitors.length) sv.append(el('div', 'row dim', 'No services are published yet.'));
|
const ul = el('ul', 'posts');
|
||||||
for (const m of d.monitors) sv.append(service(m, d.today));
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const ic = $('incidents'); ic.replaceChildren();
|
// ---------- 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.'));
|
if (!d.incidents.length) ic.append(el('div', 'row dim', 'No incidents in the last 14 days.'));
|
||||||
for (const i of d.incidents) {
|
for (const i of d.incidents) ic.append(incident(i, false));
|
||||||
const b = el('div', 'inc');
|
v.append(ic);
|
||||||
const head = el('div'); head.append(el('strong', '', i.title + ' '), el('span', 'badge ' + (i.status === 'resolved' ? 'badge-success' : 'badge-warning'), incStatus[i.status] || i.status));
|
const more = el('p', 'small'); more.style.marginTop = 'var(--s3)'; more.append(link(BASE + '/history', 'See all past incidents'));
|
||||||
b.append(head, el('div', 'small dim', 'Started ' + when(i.created) + (i.resolved ? ', resolved ' + when(i.resolved) : '')));
|
v.append(more);
|
||||||
if (i.updates.length) { const ul = el('ul', 'small'); for (const u of i.updates) ul.append(el('li', '', when(u.time) + ': ' + u.text)); b.append(ul); }
|
const old = $('subpanel'), open = old && !old.hidden;
|
||||||
ic.append(b);
|
$('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)); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load(); setInterval(load, 30000);
|
|
||||||
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in new issue
Block a user