commit 16519fd97fdbe6cdbae4b6cf6cb2812d5fec8ecb Author: ic-builder Date: Sun Sep 6 12:30:48 2026 -0500 kuma-glpi-bridge: webhook receiver opening GLPI incident tickets Go service: Kuma webhook -> GLPI REST (two-step session) ticket per monitor down-event, dedupe while open, recovery followup + solve on up. Distroless image; CI = gofmt/vet/build/secret-scan. Ticket: https://projects.knownelement.com/issues/824 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..390021f --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,23 @@ +# CI [#824] — vet + build + secret scan; image builds are done by the lane +# (digest-pinned push) per the compose/lifecycle house rules. +name: ci +on: + push: + branches: [main] + pull_request: +jobs: + vet: + runs-on: ultix + container: + image: golang:1.23-alpine + steps: + - uses: actions/checkout@v4 + - run: gofmt -l . | tee /tmp/fmt.out && test ! -s /tmp/fmt.out + - run: go vet ./... + - run: go build ./... + - name: secret scan + run: | + apk add --no-cache grep >/dev/null 2>&1 || true + if grep -rInE "BEGIN (RSA |OPENSSH |EC )?PRIVATE KEY|user_token [A-Za-z0-9]{20,}" --exclude-dir=.git .; then + echo "::error::secret material committed"; exit 1 + fi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..95a1ab4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY cmd/ ./cmd/ +RUN go build -ldflags="-s -w" -o /out/bridge ./cmd/bridge +FROM scratch +COPY --from=build /out/bridge /bridge +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["/bridge"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bcd813 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# KNELKumaGlpiBridge + +Uptime Kuma -> GLPI incident bridge. Kuma webhook POSTs land on +`/webhook`; DOWN opens a GLPI incident ticket (deduped per monitor), +UP posts a recovery followup and solves the ticket with evidence. + +Deployed on pfv-k8s via flux (NodePort 30801). GLPI tokens come from a +k8s Secret created imperatively — never in git. + +- Redmine: https://projects.knownelement.com/issues/824 +- Discourse: incident-response category (outage flow) diff --git a/bridge b/bridge new file mode 100755 index 0000000..b5622ec Binary files /dev/null and b/bridge differ diff --git a/cmd/bridge/main.go b/cmd/bridge/main.go new file mode 100644 index 0000000..ba768a6 --- /dev/null +++ b/cmd/bridge/main.go @@ -0,0 +1,234 @@ +// 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)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bb83468 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.knownelement.com/KNEL/KNELKumaGlpiBridge + +go 1.23