package api import ( "database/sql" "net/http" "git.ryuvia.com/niklas/terdut-server/internal/config" "git.ryuvia.com/niklas/terdut-server/internal/oidc" "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() oidcLimit := 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.With(passwordLoginOnly(!cfg.DisablePasswordLogin)). Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL)) // How to sign in: what the login form and the TUI offer before anybody types. r.Get("/api/auth/config", handleAuthConfig(cfg)) // Signing in to the web UI. Login trades a password for a session cookie, // which AuthMiddleware accepts in place of an API key. r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)). Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL)) r.Post("/api/logout", handleLogout(db, notify.PublicURL)) // Single sign-on. Both routes are navigations the browser makes, to and from // the provider, so they answer with redirects rather than JSON. if cfg.OIDC.Enabled() { prov := oidc.New(cfg.OIDC, notify.PublicURL) r.Get("/api/oidc/login", handleOIDCLogin(db, prov, oidcLimit, notify.PublicURL)) r.Get("/api/oidc/callback", handleOIDCCallback(db, prov, notify.PublicURL)) // Device login, for a client with no browser of its own. Both are // unauthenticated: the device code in the body is the credential. r.Post("/api/oidc/device", handleDeviceStart(db, oidcLimit, notify.PublicURL)) r.Post("/api/oidc/device/token", handleDeviceToken(db, cfg.OIDC.SessionMaxAge, 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)) // Approving or refusing a device login is done by somebody signed in // to a browser, and needs the same SSO configuration the flow does. if cfg.OIDC.Enabled() { r.Post("/api/oidc/device/approve", handleDeviceDecision(db, true)) r.Post("/api/oidc/device/deny", handleDeviceDecision(db, false)) } 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)) // A team's own OIDC group binding: which provider groups grant member // and owner access to it. r.Get("/api/teams/{teamID}/oidc-groups", handleGetTeamOIDCGroups(db)) r.Put("/api/teams/{teamID}/oidc-groups", handleSetTeamOIDCGroups(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 }