7c87ae2af8
Both existed to carry an upgrade across, and both upgrades are done. /api/alertmanager/webhook took no credential at all: anything able to reach the port could open an incident for anybody. v0.12.0 kept it, deprecated, so the teams release did not stop delivery while the Alertmanager config was edited, and logged a line per payload asking to be moved. The cluster's Alertmanager now posts on an integration key -- verified in the log, every two minutes, with no deprecation line since the rollout -- so the door can be shut rather than left ajar until somebody remembers. A sender still posting there gets the JSON 404 every unknown /api path gets. The tests move with it, which they should have done anyway: the harness mints an integration key for the default team and posts on that, so they exercise the path production uses rather than one only they still used. scripts/sqlite-to-postgres.go goes the same way. It was written to be temporary, it was the last thing needing modernc.org/sqlite, and this install migrated on 2026-09-20. `go mod tidy` drops the driver and its six transitive dependencies with it; the module graph is now chi, pgx, pgerrcode and x/crypto. Anyone still on v0.10.x can take the script out of the v0.12.0 tag, which the README now says. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
143 lines
6.3 KiB
Go
143 lines
6.3 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
|
|
"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) http.Handler {
|
|
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 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, newLoginLimiter(), 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))
|
|
|
|
// 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.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))
|
|
})
|
|
|
|
// 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.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.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))
|
|
|
|
// 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", handleGetTeamDeadman(db))
|
|
r.Put("/api/teams/{teamID}/deadman", handleSetTeamDeadman(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.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
|
|
}
|