d675f8ec9b
Like Team -> Switches, the page is now a table: a status badge (Active
if the key posted within a day, Quiet if it has but not lately, Never
used), when it last posted a webhook, when an alert last arrived on it,
how many distinct alerts it refreshed in the last 24 hours, and when it
was created. Adding a source moved into a "New source" sheet, and owners
can rename one from its row.
"Last alert" and the count needed alerts to remember which source they
came in on, which they never did, so migration 010 adds
alerts.integration_id and every accepted payload stamps it. Last sender
wins when two sources post the same fingerprint. It is not backfilled: a
NULL says "before this was recorded" rather than guessing, and it heals
by itself as Alertmanager re-sends each alert every repeat_interval.
Revoking a source keeps its alerts, unattributed.
Last webhook and last alert are separate on purpose: a payload with
nothing usable in it stamps the first and not the second. The Quiet
threshold is a fixed day, a colour and not an alarm, since silence that
should page is what dead man's switches are for.
The counts are indexed subqueries (alerts_integration_idx) rather than a
join, which would read every alert a source ever delivered.
API: the integrations list gains status, last_alert_at and alerts_24h,
and PATCH /api/teams/{id}/integrations/{id} renames. Both are additive;
terdut-tui needs nothing.
187 lines
8.7 KiB
Go
187 lines
8.7 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
|
"git.ryuvia.com/niklas/terdut-server/internal/web"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
// NewRouter builds the HTTP surface. notify is passed through to the webhook,
|
|
// the only handler that has to decide where a new incident's page goes; a zero
|
|
// notify disables notifications. Dead man's switches are per team and read from
|
|
// the database, so nothing about them is wired in here.
|
|
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
|
|
// One limiter each, both process-wide for the life of the router: login
|
|
// counts failed passwords, sign-up counts account creation, and mixing the
|
|
// two would let a burst of sign-ups lock somebody out of logging in.
|
|
loginLimit := newLoginLimiter()
|
|
signupLimiter := newLoginLimiter()
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.Logger)
|
|
r.Use(middleware.Recoverer)
|
|
|
|
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
respond(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
|
|
// Unauthenticated: bootstrap, the Alertmanager webhook receiver, and the
|
|
// Acknowledge button in a push notification. The last one is authorised by
|
|
// the scoped token in its path rather than an API key, and has to stay
|
|
// reachable from outside the cluster for the button to work.
|
|
r.Post("/api/bootstrap", handleBootstrap(db))
|
|
r.Post("/api/notify/ack/{token}", handleNotifyAck(db))
|
|
|
|
// Alert ingestion. The key in the path says both that the sender may post
|
|
// and which team the alerts belong to, which is why it needs no session.
|
|
//
|
|
// This is the only way in. The pre-teams /api/alertmanager/webhook, which
|
|
// took no credential at all, was removed in v0.13.0 once the cluster's
|
|
// Alertmanager had moved onto a key; a sender still posting there gets the
|
|
// JSON 404 every unknown /api path gets.
|
|
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
|
|
|
|
// Signing up. Both are unauthenticated by necessity: the caller has no
|
|
// account yet. The info endpoint says whether the door is open and whether
|
|
// an invite link is good, so the form can say so before somebody picks a
|
|
// password.
|
|
r.Get("/api/signup", handleSignupInfo(db))
|
|
r.Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
|
|
|
|
// Signing in to the web UI. Login trades a password for a session cookie,
|
|
// which AuthMiddleware accepts in place of an API key.
|
|
r.Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
|
|
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
|
|
|
|
// All other /api routes require a valid API key.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(AuthMiddleware(db))
|
|
|
|
r.Get("/api/me", handleMe(db))
|
|
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
|
|
// Proves the topic works, which is the only part of "notifications are
|
|
// set up" that the person holding the phone can confirm.
|
|
r.Post("/api/me/notify/test", handleTestNotification(notify, db))
|
|
|
|
// Readable by anyone signed in: the queue's assignment control and the
|
|
// on-call schedule both need to name people.
|
|
r.Get("/api/users", handleListUsers(db))
|
|
|
|
// Your own account, or anybody's if you are an admin. The handlers call
|
|
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
|
// which rule applies depends on the {id} in the path.
|
|
r.Get("/api/users/{id}/teams", handleUserTeams(db))
|
|
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
|
|
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
|
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
|
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
|
|
|
|
// Administration: who exists, and who is an administrator. Until #3
|
|
// these were open to any authenticated caller, which meant every user
|
|
// could delete every other one.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(AdminOnly)
|
|
|
|
r.Post("/api/users", handleCreateUser(db))
|
|
r.Delete("/api/users/{id}", handleDeleteUser(db))
|
|
r.Put("/api/users/{id}/admin", handleSetAdmin(db))
|
|
r.Put("/api/users/{id}/disabled", handleSetUserDisabled(db))
|
|
|
|
// What exists on this server, and how it behaves. /api/teams
|
|
// answers "what am I in"; this one answers "what is there".
|
|
r.Get("/api/admin/teams", handleAdminListTeams(db))
|
|
// One team and who is in it. The member list under
|
|
// /api/teams/{id}/members stays member-only and still 404s
|
|
// an administrator from outside; this is a different
|
|
// question, so it is a different endpoint.
|
|
r.Get("/api/admin/teams/{teamID}", handleAdminGetTeam(db))
|
|
r.Get("/api/admin/settings", handleGetSettings(db, cfg))
|
|
r.Put("/api/admin/settings", handleSetSettings(db))
|
|
})
|
|
|
|
// Alerts are read-only: they are Alertmanager's record, not a work
|
|
// queue. Everything a person does happens on the incident instead.
|
|
r.Get("/api/alerts", handleListAlerts(db))
|
|
r.Get("/api/alerts/{id}", handleGetAlert(db))
|
|
|
|
r.Get("/api/incidents", handleListIncidents(db))
|
|
r.Get("/api/incidents/{id}", handleGetIncident(db))
|
|
r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db))
|
|
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
|
|
r.Get("/api/incidents/{id}/similar", handleIncidentSimilar(db))
|
|
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
|
|
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
|
|
r.Post("/api/incidents/{id}/resolve", handleIncidentResolve(db))
|
|
r.Post("/api/incidents/{id}/assign", handleIncidentAssign(db))
|
|
r.Post("/api/incidents/{id}/snooze", handleIncidentSnooze(db))
|
|
r.Delete("/api/incidents/{id}/snooze", handleIncidentUnsnooze(db))
|
|
r.Post("/api/incidents/{id}/archive", handleIncidentArchive(db))
|
|
r.Delete("/api/incidents/{id}/archive", handleIncidentUnarchive(db))
|
|
r.Post("/api/incidents/{id}/notes", handleCreateNote(db))
|
|
r.Delete("/api/incidents/{id}/notes/{eventID}", handleDeleteNote(db))
|
|
|
|
// Teams. A user sees the teams they belong to; an owner configures one.
|
|
r.Get("/api/teams", handleListTeams(db))
|
|
r.Post("/api/teams", handleCreateTeam(db))
|
|
r.Put("/api/teams/{teamID}", handleRenameTeam(db))
|
|
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
|
|
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
|
|
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
|
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
|
|
|
// Invite links into this team.
|
|
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
|
|
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
|
|
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
|
|
|
|
// A team's escalation ladder: who is paged when nobody answers.
|
|
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
|
|
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
|
|
|
|
// A team's own dead man's switches: which of its alerts are heartbeats,
|
|
// and how long a silence has to last before somebody is paged.
|
|
r.Get("/api/teams/{teamID}/deadman/switches", handleListTeamDeadman(db))
|
|
r.Post("/api/teams/{teamID}/deadman/switches", handleCreateTeamDeadman(db))
|
|
r.Delete("/api/teams/{teamID}/deadman/switches/{switchID}", handleDeleteTeamDeadman(db))
|
|
|
|
// Integrations: where a team's alerts come in, and the key that says so.
|
|
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
|
|
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
|
|
r.Patch("/api/teams/{teamID}/integrations/{integrationID}", handleRenameIntegration(db))
|
|
r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
|
|
|
|
// The rota is per team. /api/schedule/current is the exception: it
|
|
// answers across every team the caller is in, which is what somebody on
|
|
// two rotas wants to see.
|
|
r.Get("/api/schedule/current", handleCurrentSchedule(db))
|
|
r.Post("/api/teams/{teamID}/schedule", handleCreateSchedule(db))
|
|
r.Get("/api/teams/{teamID}/schedule", handleListSchedule(db))
|
|
r.Delete("/api/teams/{teamID}/schedule/{id}", handleDeleteSchedule(db))
|
|
|
|
r.Get("/api/stats/incidents", handleStatsIncidents(db))
|
|
r.Get("/api/stats/alerts", handleStatsAlerts(db))
|
|
r.Get("/api/stats/alerts/top", handleStatsTop(db))
|
|
r.Get("/api/stats/alerts/by-hour", handleStatsByHour(db))
|
|
r.Get("/api/stats/alerts/by-day", handleStatsByDay(db))
|
|
})
|
|
|
|
// Anything else under /api is a mistake in a client, and should say so in
|
|
// JSON rather than get the web UI's HTML.
|
|
r.Handle("/api/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
}))
|
|
|
|
// Everything outside /api is the web UI.
|
|
site, err := web.Handler()
|
|
if err != nil {
|
|
panic(err) // the site is embedded at build time; this cannot fail at runtime
|
|
}
|
|
r.Handle("/*", site)
|
|
|
|
return r
|
|
}
|