// 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) }