223 lines
7.3 KiB
Go
223 lines
7.3 KiB
Go
// Package glpi is a stdlib-only client for the GLPI REST API
|
|
// (apirest.php). It exists so the harness and every vertical talk to
|
|
// the ITSM/CMDB through one Go library instead of python glue: change
|
|
// create/list/show/transition, ITIL followups, CI search, and the
|
|
// session/profile lifecycle.
|
|
//
|
|
// Security discipline: authentication travels ONLY in headers —
|
|
// App-Token on every request, Authorization: user_token on initSession,
|
|
// Session-Token after it. Tokens never appear in flags, logs, or error
|
|
// strings; response bodies are never surfaced in error messages (a
|
|
// server echo is assumed to be able to carry a token), so errors are
|
|
// one-line, parseable "sentinel: http NNN" shapes.
|
|
//
|
|
// GLPI quirks modeled faithfully:
|
|
// - POST create endpoints return an ARRAY: [{"id":N,"message":"..."}].
|
|
// - Change create takes {"input":{...}}; ITILFollowup REQUIRES
|
|
// {"input":[{...}]}.
|
|
// - search rows are objects keyed by field-id string when forcedisplay
|
|
// is used ({"1":"name","2":7,"12":3}).
|
|
// - initSession -> {"session_token":"..."}; changeActiveProfile
|
|
// switches the session's active profile (agent mode).
|
|
package glpi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Sentinel errors. Wrap-check with errors.Is; every API failure maps to
|
|
// exactly one of these plus an "http NNN" code in the message.
|
|
var (
|
|
ErrUnreachable = errors.New("glpi: unreachable")
|
|
ErrMalformedResponse = errors.New("glpi: malformed response")
|
|
ErrAuth = errors.New("glpi: auth failed")
|
|
ErrNotFound = errors.New("glpi: not found")
|
|
ErrValidation = errors.New("glpi: validation failed")
|
|
ErrServer = errors.New("glpi: server error")
|
|
)
|
|
|
|
// Config configures a Client.
|
|
type Config struct {
|
|
BaseURL string // full API endpoint, e.g. https://cmdb.knownelement.com/apirest.php
|
|
AppToken string // GLPI App-Token; header on every request, never logged
|
|
UserToken string // GLPI user token; Authorization header at initSession, never logged
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// Client is a GLPI REST client. It lazily opens a session on the first
|
|
// call (InitSession) and re-opens one after KillSession. Safe for
|
|
// concurrent use.
|
|
type Client struct {
|
|
cfg Config
|
|
http *http.Client
|
|
|
|
mu sync.Mutex
|
|
session string
|
|
}
|
|
|
|
// New builds a client. A zero Timeout means 30s. No network traffic
|
|
// happens until the first call.
|
|
func New(cfg Config) *Client {
|
|
if cfg.Timeout == 0 {
|
|
cfg.Timeout = 30 * time.Second
|
|
}
|
|
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
|
|
return &Client{cfg: cfg, http: &http.Client{Timeout: cfg.Timeout}}
|
|
}
|
|
|
|
// sessionToken returns the live session token, if any.
|
|
func (c *Client) sessionToken() string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.session
|
|
}
|
|
|
|
// setSession stores the session token obtained from initSession.
|
|
func (c *Client) setSession(tok string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.session = tok
|
|
}
|
|
|
|
// InitSession opens a GLPI session (POST /initSession). Called
|
|
// automatically on the first API call; public so callers can pre-warm
|
|
// and so the profile switch has a session to act on. Idempotent while
|
|
// the session is live.
|
|
func (c *Client) InitSession(ctx context.Context) error {
|
|
if tok := c.sessionToken(); tok != "" {
|
|
return nil
|
|
}
|
|
var resp struct {
|
|
SessionToken string `json:"session_token"`
|
|
}
|
|
if err := c.raw(ctx, http.MethodPost, "/initSession", nil, nil, &resp, true); err != nil {
|
|
if errors.Is(err, ErrValidation) {
|
|
// GLPI answers 400 (ERROR_GLPI_LOGIN / ERROR_APP_TOKEN_...)
|
|
// for bad credentials or a wrong app token — that is an
|
|
// auth failure, not a validation failure.
|
|
return fmt.Errorf("%w: http 400 (initSession refused)", ErrAuth)
|
|
}
|
|
return err
|
|
}
|
|
if resp.SessionToken == "" {
|
|
return fmt.Errorf("%w: initSession returned no session_token", ErrMalformedResponse)
|
|
}
|
|
c.setSession(resp.SessionToken)
|
|
return nil
|
|
}
|
|
|
|
// KillSession closes the session server-side (GET /killSession) and
|
|
// forgets the local token; the next call transparently re-inits.
|
|
func (c *Client) KillSession(ctx context.Context) error {
|
|
if err := c.call(ctx, http.MethodGet, "/killSession", nil, nil, nil); err != nil {
|
|
return err
|
|
}
|
|
c.setSession("")
|
|
return nil
|
|
}
|
|
|
|
// call performs one authenticated exchange: it ensures a live session,
|
|
// then sends the request with Session-Token auth.
|
|
func (c *Client) call(ctx context.Context, method, path string, query url.Values, in, out any) error {
|
|
if c.sessionToken() == "" {
|
|
if err := c.InitSession(ctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return c.raw(ctx, method, path, query, in, out, false)
|
|
}
|
|
|
|
// raw performs one JSON exchange. initAuth selects the initSession auth
|
|
// flavor (Authorization: user_token) instead of Session-Token. out may
|
|
// be nil for bodies the caller does not parse (killSession answers
|
|
// `true`, update answers a success array checked separately).
|
|
func (c *Client) raw(ctx context.Context, method, path string, query url.Values, in, out any, initAuth bool) error {
|
|
var body io.Reader
|
|
if in != nil {
|
|
b, err := json.Marshal(in)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: cannot encode request", ErrMalformedResponse)
|
|
}
|
|
body = strings.NewReader(string(b))
|
|
}
|
|
u := c.cfg.BaseURL + path
|
|
if len(query) > 0 {
|
|
u += "?" + query.Encode()
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, u, body)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: bad endpoint", ErrUnreachable)
|
|
}
|
|
// Tokens live in headers and only in headers.
|
|
req.Header.Set("App-Token", c.cfg.AppToken)
|
|
if initAuth {
|
|
req.Header.Set("Authorization", "user_token "+c.cfg.UserToken)
|
|
} else if tok := c.sessionToken(); tok != "" {
|
|
req.Header.Set("Session-Token", tok)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
if in != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
// Transport errors embed URLs and peer text; drop them all.
|
|
return ErrUnreachable
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
if err != nil {
|
|
return ErrUnreachable
|
|
}
|
|
return handleResponse(resp.StatusCode, raw, out)
|
|
}
|
|
|
|
// handleResponse maps one HTTP exchange to typed errors. Response BODIES
|
|
// are never surfaced: a server echo is assumed to be able to contain a
|
|
// token (see the fakeglpi package, which deliberately echoes it).
|
|
func handleResponse(status int, body []byte, out any) error {
|
|
switch {
|
|
case status >= 200 && status < 300:
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
if len(body) == 0 {
|
|
return fmt.Errorf("%w: empty body", ErrMalformedResponse)
|
|
}
|
|
if err := json.Unmarshal(body, out); err != nil {
|
|
return fmt.Errorf("%w: body is not valid json", ErrMalformedResponse)
|
|
}
|
|
return nil
|
|
default:
|
|
return statusError(status)
|
|
}
|
|
}
|
|
|
|
// statusError maps a non-2xx status to a sentinel + parseable one-liner.
|
|
func statusError(status int) error {
|
|
switch {
|
|
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
|
return fmt.Errorf("%w: http %d", ErrAuth, status)
|
|
case status == http.StatusNotFound:
|
|
return fmt.Errorf("%w: http %d", ErrNotFound, status)
|
|
case status == http.StatusUnprocessableEntity || status == http.StatusBadRequest || status == http.StatusConflict:
|
|
return fmt.Errorf("%w: http %d", ErrValidation, status)
|
|
default:
|
|
return fmt.Errorf("%w: http %d", ErrServer, status)
|
|
}
|
|
}
|
|
|
|
// itoa is a tiny local alias used across the endpoint files.
|
|
func itoa(n int) string { return strconv.Itoa(n) }
|