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
This commit is contained in:
@@ -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
|
||||||
+10
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user