// kuma-glpi-bridge: Uptime Kuma webhook -> GLPI incident tickets. // Receives Kuma webhook POSTs, opens a GLPI Ticket (type 1) per monitor // down-event, dedupes while open, and posts a recovery followup on up. // Ticket: https://projects.knownelement.com/issues/824 package main import ( "crypto/tls" "encoding/json" "fmt" "log" "net/http" "net/url" "os" "strings" "sync" "time" ) type kumaWebhook struct { Monitor struct { Name string `json:"name"` URL string `json:"url"` Type string `json:"type"` } `json:"monitor"` Heartbeat struct { Status int `json:"status"` Msg string `json:"msg"` Time string `json:"time"` } `json:"heartbeat"` Msg string `json:"msg"` } type glpiClient struct { baseURL string appToken string userToken string httpClient *http.Client mu sync.Mutex session string expires time.Time } func newGLPI() *glpiClient { return &glpiClient{ baseURL: os.Getenv("GLPI_URL"), appToken: os.Getenv("GLPI_APP_TOKEN"), userToken: os.Getenv("GLPI_USER_TOKEN"), httpClient: &http.Client{ Timeout: 20 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: os.Getenv("GLPI_INSECURE") == "1"}}, //nolint:gosec // self-signed tailnet endpoint }, } } // session returns a cached GLPI session token, re-initing when stale. func (g *glpiClient) ensureSession() (string, error) { g.mu.Lock() defer g.mu.Unlock() if g.session != "" && time.Now().Before(g.expires) { return g.session, nil } req, err := http.NewRequest("GET", g.baseURL+"/initSession", nil) if err != nil { return "", err } req.Header.Set("App-Token", g.appToken) req.Header.Set("Authorization", "user_token "+g.userToken) resp, err := g.httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() var out struct { SessionToken string `json:"session_token"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.SessionToken == "" { return "", fmt.Errorf("initSession failed: HTTP %d", resp.StatusCode) } g.session = out.SessionToken g.expires = time.Now().Add(50 * time.Minute) return g.session, nil } func (g *glpiClient) do(method, path string, body any, out any) error { sess, err := g.ensureSession() if err != nil { return err } var rd *strings.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return err } rd = strings.NewReader(string(b)) } else { rd = strings.NewReader("") } req, err := http.NewRequest(method, g.baseURL+path, rd) if err != nil { return err } req.Header.Set("App-Token", g.appToken) req.Header.Set("Session-Token", sess) if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := g.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { return fmt.Errorf("%s %s: HTTP %d", method, path, resp.StatusCode) } if out != nil { return json.NewDecoder(resp.Body).Decode(out) } return nil } // openTicketFor returns the id of an existing unsolved ticket for this monitor, if any. func (g *glpiClient) openTicketFor(monitor string) (int, error) { q := url.Values{} q.Set("is_deleted", "0") q.Set("criteria[0][field]", "1") // name q.Set("criteria[0][searchtype]", "contains") q.Set("criteria[0][value]", "[kuma] "+monitor) q.Set("criteria[1][link]", "AND") q.Set("criteria[1][field]", "12") // status q.Set("criteria[1][searchtype]", "equals") q.Set("criteria[1][value]", "notold") var out []struct { ID int `json:"2"` } if err := g.do("GET", "/search/Ticket?"+q.Encode(), nil, &out); err != nil { return 0, err } if len(out) > 0 { return out[0].ID, nil } return 0, nil } func (g *glpiClient) createTicket(name, content string) (int, error) { var out struct { ID int `json:"id"` } err := g.do("POST", "/Ticket", map[string]any{ "input": map[string]any{"name": name, "content": content, "type": 1}, }, &out) return out.ID, err } func (g *glpiClient) followup(ticket int, content string) error { return g.do("POST", "/followup", map[string]any{ "input": map[string]any{"itemtype": "Ticket", "items_id": ticket, "content": content}, }, nil) } type bridge struct { glpi *glpiClient mu sync.Mutex open map[string]int // monitor -> ticket id } func (b *bridge) handle(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } var k kumaWebhook if err := json.NewDecoder(r.Body).Decode(&k); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if k.Monitor.Name == "" { w.WriteHeader(http.StatusAccepted) return } status := k.Heartbeat.Status name := "[kuma] " + k.Monitor.Name content := fmt.Sprintf("Automated incident from Uptime Kuma bridge (#824).\nMonitor: %s (type %s)\nURL: %s\nStatus code: %d\nMessage: %s\nAt: %s\n\nSource: https://status.knownelement.com — work this incident per the incidents-first mandate; see Redmine project \"Incident Response\".", k.Monitor.Name, k.Monitor.Type, k.Monitor.URL, status, k.Msg, k.Heartbeat.Time) b.mu.Lock() defer b.mu.Unlock() switch status { case 2: // DOWN (0=unknown,1=up,2=down per Kuma webhook) if _, exists := b.open[k.Monitor.Name]; !exists { tid, err := b.glpi.openTicketFor(k.Monitor.Name) if err == nil && tid == 0 { tid, err = b.glpi.createTicket(name, content) } if err != nil { log.Printf("ticket create failed: %v", err) http.Error(w, "glpi error", http.StatusBadGateway) return } b.open[k.Monitor.Name] = tid log.Printf("incident ticket %d for %s", tid, k.Monitor.Name) } case 1: // UP tid, exists := b.open[k.Monitor.Name] if !exists { return } delete(b.open, k.Monitor.Name) go func() { fu := fmt.Sprintf("RECOVERY: monitor %s back UP at %s. Ticket auto-closing as solved with recovery evidence.", k.Monitor.Name, k.Heartbeat.Time) if err := b.glpi.followup(tid, fu); err != nil { log.Printf("followup failed: %v", err) return } if err := b.glpi.do("POST", fmt.Sprintf("/Ticket/%d", tid), map[string]any{"input": map[string]any{"status": 11}}, nil); err != nil { // 11 = solved log.Printf("solve failed: %v", err) } }() } w.WriteHeader(http.StatusOK) } func main() { listen := os.Getenv("BRIDGE_LISTEN") if listen == "" { listen = ":8080" } b := &bridge{glpi: newGLPI(), open: map[string]int{}} http.HandleFunc("/webhook", b.handle) http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) log.Printf("kuma-glpi-bridge listening on %s", listen) log.Fatal(http.ListenAndServe(listen, nil)) }