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 }