Files
Niklas Ye ee25552a53
CI / test (push) Successful in 17s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 23s
Sign in through the server's single sign-on, with a code
The sign-in screen asks the server how it can be signed in to
(GET /api/auth/config) and offers what it finds: the password form, and
"Sign in with <provider>" when the server can do a device login. The TUI
shows a link and a short code, the person approves it in any browser, and
the next poll hands over the ordinary session, so it works over SSH where
no browser can be opened. The terminal never talks to the identity
provider.

The password form is hidden when the server has turned password login
off. `auth: sso` in config.yaml starts the SSO login straight away, but not
right after signing out, where that would sign the person straight back
in; any other value is refused when the config is read. Polling honours the
server's interval, backs off on slow_down, and gives up after repeated
failures rather than retrying forever.

A server without /api/auth/config answers 404 and is treated as passwords
only, so the sign-in screen is the one it had. Needs terdut-server v0.29.0
for SSO.
2026-09-26 21:47:13 +02:00

713 lines
22 KiB
Go

package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// SessionCookie is the cookie terdut-server's web UI signs in with.
const SessionCookie = "terdut_session"
// Client talks to terdut-server as the user who signed in. Login trades a
// username and password for a session, the same one the web UI holds, and every
// request after it carries that session's cookie.
//
// The cookie is attached by hand rather than through a cookie jar: the server
// marks it Secure behind https, and a jar drops a Secure cookie it is handed
// over plain http, which would make a local server unusable for no reason. There
// is nothing else a jar would do here — the token is opaque and does not change
// while the session lives.
type Client struct {
baseURL string
httpClient *http.Client
session string
}
func NewClient(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// SetSession resumes a session from a token saved earlier.
func (c *Client) SetSession(token string) { c.session = token }
// HasSession reports whether there is a session to try. It says nothing about
// whether the server still honours it.
func (c *Client) HasSession() bool { return c.session != "" }
// Login signs in and returns the session token, which the client also keeps and
// sends from then on. The server answers a wrong password, an unknown user and
// an account with no password all with the same 401, so the caller cannot tell
// them apart. Too many failures come back as 429.
func (c *Client) Login(username, password string) (string, error) {
body := struct {
Username string `json:"username"`
Password string `json:"password"`
}{Username: username, Password: password}
req, err := c.newRequestWithBody(http.MethodPost, "/api/login", body)
if err != nil {
return "", err
}
// A stale session must not ride along on the request that replaces it.
req.Header.Del("Cookie")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", statusError(resp)
}
for _, ck := range resp.Cookies() {
if ck.Name == SessionCookie && ck.Value != "" {
c.session = ck.Value
return ck.Value, nil
}
}
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie)
}
// AuthConfig asks how the server can be signed in to. It is unauthenticated, so
// it works before anybody has signed in.
func (c *Client) AuthConfig() (AuthConfig, error) {
var cfg AuthConfig
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/api/auth/config", nil)
if err != nil {
return cfg, err
}
req.Header.Set("Accept", "application/json")
err = c.do(req, &cfg)
return cfg, err
}
// The ways a device login poll can end other than with a session.
var (
// ErrDevicePending means nobody has approved yet: poll again after the
// interval.
ErrDevicePending = errors.New("waiting for approval")
// ErrDeviceSlowDown means the server was polled faster than it asked. It is
// not a failure; poll again, a little slower.
ErrDeviceSlowDown = errors.New("polling too fast")
// ErrDeviceExpired means the person took too long, or the server forgot the
// login. ErrDeviceDenied means they refused it.
ErrDeviceExpired = errors.New("the sign-in expired")
ErrDeviceDenied = errors.New("the sign-in was refused")
)
// StartDeviceLogin asks the server to begin a device login.
func (c *Client) StartDeviceLogin() (*DeviceLogin, error) {
req, err := c.newRequestWithBody(http.MethodPost, "/api/oidc/device", struct{}{})
if err != nil {
return nil, err
}
req.Header.Del("Cookie")
var d DeviceLogin
if err := c.do(req, &d); err != nil {
return nil, err
}
if d.DeviceCode == "" || d.UserCode == "" || d.VerificationURL == "" {
return nil, errors.New("server started a sign-in but sent no code")
}
return &d, nil
}
// PollDeviceLogin asks whether the person has approved. On approval it returns
// the session token, which the client also keeps; until then it returns one of
// the ErrDevice* errors.
func (c *Client) PollDeviceLogin(deviceCode string) (string, error) {
req, err := c.newRequestWithBody(http.MethodPost, "/api/oidc/device/token",
struct {
DeviceCode string `json:"device_code"`
}{deviceCode})
if err != nil {
return "", err
}
req.Header.Del("Cookie")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusAccepted:
return "", ErrDevicePending
case http.StatusTooManyRequests:
return "", ErrDeviceSlowDown
case http.StatusGone:
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error == "denied" {
return "", ErrDeviceDenied
}
return "", ErrDeviceExpired
}
if resp.StatusCode >= 400 {
return "", statusError(resp)
}
for _, ck := range resp.Cookies() {
if ck.Name == SessionCookie && ck.Value != "" {
c.session = ck.Value
return ck.Value, nil
}
}
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie)
}
// Logout ends the session on the server and forgets it here.
func (c *Client) Logout() error {
req, err := c.newRequest(http.MethodPost, "/api/logout")
if err != nil {
return err
}
err = c.do(req, nil)
c.session = ""
return err
}
// authorize puts the session on a request.
func (c *Client) authorize(req *http.Request) {
req.Header.Set("Accept", "application/json")
if c.session != "" {
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: c.session})
}
}
func (c *Client) newRequest(method, path string) (*http.Request, error) {
req, err := http.NewRequest(method, c.baseURL+path, nil)
if err != nil {
return nil, err
}
c.authorize(req)
return req, nil
}
// StatusError is a response the server answered with a 4xx or 5xx. Message is
// the server's own {"error": ...} text, empty when the body carried none.
type StatusError struct {
Code int
Message string
}
func (e *StatusError) Error() string {
if e.Message != "" {
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
}
return fmt.Sprintf("server returned %d", e.Code)
}
// IsUnauthorized reports whether err is the server refusing the session: it
// expired, was ended elsewhere, or belongs to an account since disabled.
func IsUnauthorized(err error) bool {
var se *StatusError
return errors.As(err, &se) && se.Code == http.StatusUnauthorized
}
func statusError(resp *http.Response) error {
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
return &StatusError{Code: resp.StatusCode, Message: e.Error}
}
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return statusError(resp)
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// ListAlerts fetches alerts. teamID limits them to one team; 0 means every team
// the caller belongs to. status may be "firing", "resolved", or "" for all.
// Set archived=true to fetch only archived alerts; false returns only non-archived.
//
// Alerts are read-only on the server — there is nothing to acknowledge or
// archive here. This is the raw feed, useful for checking what Alertmanager is
// actually sending; the work queue is ListIncidents.
func (c *Client) ListAlerts(teamID int64, status string, archived bool, limit int) ([]Alert, error) {
q := url.Values{}
if teamID > 0 {
q.Set("team_id", strconv.FormatInt(teamID, 10))
}
if status != "" {
q.Set("status", status)
}
if archived {
q.Set("archived", "true")
}
if limit > 0 {
q.Set("limit", strconv.Itoa(limit))
}
path := "/api/alerts"
if len(q) > 0 {
path += "?" + q.Encode()
}
req, err := c.newRequest(http.MethodGet, path)
if err != nil {
return nil, err
}
var alerts []Alert
return alerts, c.do(req, &alerts)
}
// GetAlertStats fetches aggregate alert counts.
func (c *Client) GetAlertStats() (*AlertStats, error) {
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts")
if err != nil {
return nil, err
}
var stats AlertStats
return &stats, c.do(req, &stats)
}
func (c *Client) newRequestWithBody(method, path string, body any) (*http.Request, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, c.baseURL+path, bytes.NewReader(data))
if err != nil {
return nil, err
}
c.authorize(req)
req.Header.Set("Content-Type", "application/json")
return req, nil
}
func (c *Client) GetAlert(id int64) (*Alert, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/alerts/%d", id))
if err != nil {
return nil, err
}
var alert Alert
return &alert, c.do(req, &alert)
}
// ── Incidents ──────────────────────────────────────────────────────────────
// ListIncidents fetches the work queue. teamID limits it to one team; 0 means
// every team the caller belongs to. status may be "triggered",
// "acknowledged", "resolved", or "" for the server default of open incidents
// only. archived and snoozed each switch the list to that set rather than
// adding to it, matching the server's filters.
func (c *Client) ListIncidents(teamID int64, status string, archived, snoozed bool, limit int) ([]Incident, error) {
q := url.Values{}
if teamID > 0 {
q.Set("team_id", strconv.FormatInt(teamID, 10))
}
if status != "" {
q.Set("status", status)
}
if archived {
q.Set("archived", "true")
}
if snoozed {
q.Set("snoozed", "true")
}
if limit > 0 {
q.Set("limit", strconv.Itoa(limit))
}
path := "/api/incidents"
if len(q) > 0 {
path += "?" + q.Encode()
}
req, err := c.newRequest(http.MethodGet, path)
if err != nil {
return nil, err
}
var incidents []Incident
return incidents, c.do(req, &incidents)
}
// GetIncident returns one incident with its member alerts inline.
func (c *Client) GetIncident(id int64) (*Incident, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d", id))
if err != nil {
return nil, err
}
var incident Incident
return &incident, c.do(req, &incident)
}
func (c *Client) GetIncidentTimeline(id int64) ([]IncidentEvent, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d/timeline", id))
if err != nil {
return nil, err
}
var events []IncidentEvent
return events, c.do(req, &events)
}
// GetSimilarIncidents lists earlier resolved incidents that look like this one
// and have notes. Servers before the similar-incidents endpoint answer 404; the
// caller treats any error as "nothing to show".
func (c *Client) GetSimilarIncidents(id int64) ([]SimilarIncident, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d/similar", id))
if err != nil {
return nil, err
}
var similar []SimilarIncident
return similar, c.do(req, &similar)
}
func (c *Client) AcknowledgeIncident(id int64) (*Incident, error) {
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/acknowledge", id))
if err != nil {
return nil, err
}
var incident Incident
return &incident, c.do(req, &incident)
}
func (c *Client) UnacknowledgeIncident(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/acknowledge", id))
if err != nil {
return err
}
return c.do(req, nil)
}
// ResolveIncident closes an incident by hand. This is terminal on the server: a
// later occurrence in the same group opens a new incident rather than reopening
// this one, and if the alert underneath never stops firing the incident stays
// closed. Use SnoozeIncident for "not now".
func (c *Client) ResolveIncident(id int64) (*Incident, error) {
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/resolve", id))
if err != nil {
return nil, err
}
var incident Incident
return &incident, c.do(req, &incident)
}
func (c *Client) AssignIncident(id, userID int64) (*Incident, error) {
body := struct {
UserID int64 `json:"user_id"`
}{UserID: userID}
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/incidents/%d/assign", id), body)
if err != nil {
return nil, err
}
var incident Incident
return &incident, c.do(req, &incident)
}
// SnoozeIncident hides an incident from the default queue for a duration,
// without closing it.
func (c *Client) SnoozeIncident(id int64, duration string) (*Incident, error) {
body := struct {
Duration string `json:"duration"`
}{Duration: duration}
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/incidents/%d/snooze", id), body)
if err != nil {
return nil, err
}
var incident Incident
return &incident, c.do(req, &incident)
}
func (c *Client) UnsnoozeIncident(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/snooze", id))
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) ArchiveIncident(id int64) (*Incident, error) {
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/archive", id))
if err != nil {
return nil, err
}
var incident Incident
return &incident, c.do(req, &incident)
}
func (c *Client) UnarchiveIncident(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/archive", id))
if err != nil {
return err
}
return c.do(req, nil)
}
// AddNote appends a note to the incident's timeline. pinned files it as the
// resolution note: what fixed the incident, shown on similar ones later.
func (c *Client) AddNote(incidentID int64, content string, pinned bool) (*IncidentEvent, error) {
req, err := c.newRequestWithBody(http.MethodPost,
fmt.Sprintf("/api/incidents/%d/notes", incidentID), map[string]any{"content": content, "pinned": pinned})
if err != nil {
return nil, err
}
var event IncidentEvent
return &event, c.do(req, &event)
}
// DeleteNote removes one of your own notes. Only notes are deletable — the rest
// of the timeline is a record of what happened.
func (c *Client) DeleteNote(incidentID, eventID int64) error {
req, err := c.newRequest(http.MethodDelete,
fmt.Sprintf("/api/incidents/%d/notes/%d", incidentID, eventID))
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) GetIncidentStats() (*IncidentStats, error) {
req, err := c.newRequest(http.MethodGet, "/api/stats/incidents")
if err != nil {
return nil, err
}
var stats IncidentStats
return &stats, c.do(req, &stats)
}
// ── Statistics ─────────────────────────────────────────────────────────────
func (c *Client) GetTopAlerts(limit int) ([]TopAlert, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/stats/alerts/top?limit=%d", limit))
if err != nil {
return nil, err
}
var result []TopAlert
return result, c.do(req, &result)
}
func (c *Client) GetStatsByHour() ([]HourStat, error) {
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts/by-hour")
if err != nil {
return nil, err
}
var result []HourStat
return result, c.do(req, &result)
}
func (c *Client) GetStatsByDay() ([]DayStat, error) {
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts/by-day")
if err != nil {
return nil, err
}
var result []DayStat
return result, c.do(req, &result)
}
// ── Teams ──────────────────────────────────────────────────────────────────
// ListTeams returns the teams the caller belongs to, with the caller's role in
// each. Everything else the server returns is scoped to these. A server that
// predates teams (v0.12) answers 404, which is how the TUI spots one.
func (c *Client) ListTeams() ([]Team, error) {
req, err := c.newRequest(http.MethodGet, "/api/teams")
if err != nil {
return nil, err
}
var teams []Team
return teams, c.do(req, &teams)
}
// ListTeamMembers returns who belongs to a team. A schedule can only be given to
// its own members, so this is the assignee list for one.
func (c *Client) ListTeamMembers(teamID int64) ([]TeamMember, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/members", teamID))
if err != nil {
return nil, err
}
var members []TeamMember
return members, c.do(req, &members)
}
// ── Schedule ───────────────────────────────────────────────────────────────
// GetSchedule returns a team's on-call entries between two YYYY-MM-DD dates.
func (c *Client) GetSchedule(teamID int64, from, to string) ([]ScheduleEntry, error) {
q := url.Values{"from": {from}, "to": {to}}
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/schedule?%s", teamID, q.Encode()))
if err != nil {
return nil, err
}
var entries []ScheduleEntry
return entries, c.do(req, &entries)
}
// GetCurrentOnCall returns today's on-call entries, one per team that has
// somebody scheduled. It is empty, not an error, when nobody is.
func (c *Client) GetCurrentOnCall() ([]ScheduleEntry, error) {
req, err := c.newRequest(http.MethodGet, "/api/schedule/current")
if err != nil {
return nil, err
}
var entries []ScheduleEntry
return entries, c.do(req, &entries)
}
// AssignSchedule puts one team member on call for the given dates. Only a team
// owner or an administrator may.
//
// The server holds one person per day and refuses a date somebody already has,
// so replace is what takes a shift off its current holder. It is all-or-nothing
// either way: a week of free and taken days moves as a unit, or not at all.
func (c *Client) AssignSchedule(teamID, userID int64, dates []string, replace bool) ([]ScheduleEntry, error) {
body := struct {
UserID int64 `json:"user_id"`
Dates []string `json:"dates"`
Replace bool `json:"replace,omitempty"`
}{UserID: userID, Dates: dates, Replace: replace}
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/teams/%d/schedule", teamID), body)
if err != nil {
return nil, err
}
var entries []ScheduleEntry
return entries, c.do(req, &entries)
}
func (c *Client) DeleteScheduleEntry(teamID, id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/teams/%d/schedule/%d", teamID, id))
if err != nil {
return err
}
return c.do(req, nil)
}
// ── Users ──────────────────────────────────────────────────────────────────
func (c *Client) ListUsers() ([]User, error) {
req, err := c.newRequest(http.MethodGet, "/api/users")
if err != nil {
return nil, err
}
var users []User
return users, c.do(req, &users)
}
func (c *Client) CreateUser(username, email string) (*User, error) {
body := struct {
Username string `json:"username"`
Email string `json:"email"`
}{Username: username, Email: email}
req, err := c.newRequestWithBody(http.MethodPost, "/api/users", body)
if err != nil {
return nil, err
}
var user User
return &user, c.do(req, &user)
}
// SetUserNotifyTarget points a user's push notifications at an ntfy topic.
//
// An empty topic clears it: the server stores NULL, and that user's incidents
// page the shared fallback topic instead — which carries no Acknowledge button,
// because anyone subscribed to it could otherwise acknowledge as somebody else.
func (c *Client) SetUserNotifyTarget(userID int64, topic string) (*User, error) {
body := struct {
NtfyTopic string `json:"ntfy_topic"`
}{NtfyTopic: topic}
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/notify", userID), body)
if err != nil {
return nil, err
}
var user User
return &user, c.do(req, &user)
}
func (c *Client) DeleteUser(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d", id))
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) CreateAPIKey(userID int64, name string) (*APIKey, error) {
body := struct {
Name string `json:"name"`
}{Name: name}
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/users/%d/api-keys", userID), body)
if err != nil {
return nil, err
}
var key APIKey
return &key, c.do(req, &key)
}
func (c *Client) DeleteAPIKey(userID, keyID int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d/api-keys/%d", userID, keyID))
if err != nil {
return err
}
return c.do(req, nil)
}
// Me returns the user the API key belongs to, and whether they have a web UI
// password.
func (c *Client) Me() (*Me, error) {
req, err := c.newRequest(http.MethodGet, "/api/me")
if err != nil {
return nil, err
}
var me Me
return &me, c.do(req, &me)
}
// SetPassword sets a user's web UI password. current is only checked by the
// server when a user changes their own existing password; pass "" otherwise.
func (c *Client) SetPassword(userID int64, password, current string) error {
body := struct {
Password string `json:"password"`
CurrentPassword string `json:"current_password,omitempty"`
}{Password: password, CurrentPassword: current}
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/password", userID), body)
if err != nil {
return err
}
return c.do(req, nil)
}
// HealthCheck calls GET /healthz, which is unauthenticated and does no database
// check, so it says the process is up, not that the API key works.
func (c *Client) HealthCheck() error {
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
if err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("health check failed: %s", resp.Status)
}
return nil
}