Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d999b02943 | ||
|
|
df6f8165b6 | ||
|
|
65928b109b | ||
|
|
18e2031ab9 | ||
|
|
743148f576 | ||
|
|
39c450cb77 | ||
|
|
a9f0d1a4fb | ||
|
|
94c0cf6fe6 | ||
|
|
e5a1f052a4 | ||
|
|
f44a74d05e | ||
|
|
178af6afe7 |
+1
-5
@@ -108,12 +108,8 @@ func main() {
|
||||
}
|
||||
_ = registry.Add(notifAdapter)
|
||||
|
||||
// Add other adapters from config (skip if already registered in code)
|
||||
// Add other adapters from config
|
||||
for _, a := range cfg.Adapters {
|
||||
if existing := registry.Get(a.ServiceName); existing != nil {
|
||||
log.Printf("skip config adapter '%s': already registered with internal handler", a.ServiceName)
|
||||
continue
|
||||
}
|
||||
_ = registry.Add(a)
|
||||
}
|
||||
log.Printf("%d service adapters loaded", registry.Count())
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/webhook"
|
||||
)
|
||||
|
||||
// Router implements an HTTP handler that routes health endpoints,
|
||||
@@ -15,7 +14,6 @@ type Router struct {
|
||||
dispatcher *serviceadapter.Dispatcher
|
||||
temporalHandler http.Handler
|
||||
upstreamHandler http.Handler
|
||||
forgejoWebhook *webhook.ForgejoHandler
|
||||
}
|
||||
|
||||
// NewRouter creates a new router with health endpoints.
|
||||
@@ -25,11 +23,10 @@ type Router struct {
|
||||
// All other paths are passed to the upstream handler.
|
||||
func NewRouter(healthChecker *HealthChecker, dispatcher *serviceadapter.Dispatcher, temporalHandler http.Handler, upstreamHandler http.Handler) *Router {
|
||||
return &Router{
|
||||
healthChecker: healthChecker,
|
||||
dispatcher: dispatcher,
|
||||
healthChecker: healthChecker,
|
||||
dispatcher: dispatcher,
|
||||
temporalHandler: temporalHandler,
|
||||
upstreamHandler: upstreamHandler,
|
||||
forgejoWebhook: webhook.NewForgejoHandler(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,12 +57,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Forgejo webhook receiver — no auth, HMAC-verified by handler
|
||||
if req.URL.Path == "/v1/webhooks/forgejo" {
|
||||
r.forgejoWebhook.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Workflow endpoints
|
||||
// DEPRECATED: Path-based /workflow routing is legacy.
|
||||
// New clients should use X-Service: workflow header instead for consistent auth.
|
||||
|
||||
@@ -134,13 +134,8 @@ func (wa *WorkflowAdapter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Inject action into body for temporal handler
|
||||
payload["action"] = action
|
||||
|
||||
// namespace is required for all workflow operations
|
||||
if ns, ok := payload["namespace"].(string); !ok || ns == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, `{"error":"namespace is required"}`)
|
||||
return
|
||||
if _, ok := payload["namespace"]; !ok {
|
||||
payload["namespace"] = "default"
|
||||
}
|
||||
|
||||
newBody, _ := json.Marshal(payload)
|
||||
@@ -195,7 +190,7 @@ func GetWorkflowSpec() *Spec {
|
||||
TimeoutSeconds: 30,
|
||||
},
|
||||
Auth: Auth{
|
||||
Required: false,
|
||||
Required: true,
|
||||
Capability: "workflow:execute",
|
||||
},
|
||||
Retryable: true,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package serviceadapter_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/temporal"
|
||||
)
|
||||
|
||||
func TestWorkflowListRequiresNamespace(t *testing.T) {
|
||||
th := temporal.NewHandler("localhost:7233")
|
||||
wfAdapter := serviceadapter.NewWorkflowAdapter(th)
|
||||
wfSpec := serviceadapter.GetWorkflowSpec()
|
||||
adapter := &serviceadapter.ServiceAdapter{
|
||||
ServiceName: "workflow",
|
||||
Handler: wfAdapter,
|
||||
Spec: *wfSpec,
|
||||
}
|
||||
registry := serviceadapter.NewRegistry(nil)
|
||||
registry.Add(adapter)
|
||||
dispatcher := serviceadapter.NewDispatcher(registry, nil)
|
||||
|
||||
// POST X-Service: workflow X-Resource: list body: {} (no namespace)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`))
|
||||
req.Header.Set("X-Service", "workflow")
|
||||
req.Header.Set("X-Resource", "list")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
dispatcher.Dispatch(w, req)
|
||||
|
||||
t.Logf("Status: %d Body: %s", w.Code, w.Body.String())
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/notification"
|
||||
)
|
||||
|
||||
// ForgejoHandler receives Forgejo webhook payloads and forwards them to Gotify.
|
||||
// Forgejo sends a Gitea-compatible JSON payload with X-Gitea-Signature-256 header.
|
||||
type ForgejoHandler struct {
|
||||
secret string
|
||||
gotify *notification.GotifyClient
|
||||
}
|
||||
|
||||
// NewForgejoHandler creates a handler from environment variables.
|
||||
// Required: GOTIFY_URL, GOTIFY_APP_TOKEN
|
||||
// Optional: FORGEJO_WEBHOOK_SECRET (if empty, HMAC verification is skipped)
|
||||
func NewForgejoHandler() *ForgejoHandler {
|
||||
gotifyURL := os.Getenv("GOTIFY_URL")
|
||||
appToken := os.Getenv("GOTIFY_APP_TOKEN")
|
||||
|
||||
var client *notification.GotifyClient
|
||||
if gotifyURL != "" && appToken != "" {
|
||||
client = notification.NewGotifyClient(gotifyURL, appToken, "")
|
||||
}
|
||||
|
||||
return &ForgejoHandler{
|
||||
secret: os.Getenv("FORGEJO_WEBHOOK_SECRET"),
|
||||
gotify: client,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP handles POST /v1/webhooks/forgejo
|
||||
func (h *ForgejoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify HMAC if secret is set
|
||||
if h.secret != "" {
|
||||
sig := r.Header.Get("X-Gitea-Signature-256")
|
||||
if sig == "" {
|
||||
sig = r.Header.Get("X-Hub-Signature-256")
|
||||
}
|
||||
if !h.verifySignature(body, sig) {
|
||||
http.Error(w, "invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if h.gotify == nil {
|
||||
log.Printf("forgejo webhook received but Gotify not configured (GOTIFY_URL/GOTIFY_APP_TOKEN missing)")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
event := r.Header.Get("X-Gitea-Event")
|
||||
if event == "" {
|
||||
event = r.Header.Get("X-GitHub-Event")
|
||||
}
|
||||
|
||||
title, message, priority := h.formatMessage(event, body)
|
||||
if title == "" {
|
||||
// Unhandled event type — ack and ignore
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
msg := notification.GotifyMessage{
|
||||
Title: title,
|
||||
Message: message,
|
||||
Priority: priority,
|
||||
}
|
||||
|
||||
if _, err := h.gotify.SendMessage(msg); err != nil {
|
||||
log.Printf("forgejo webhook: failed to send gotify message: %v", err)
|
||||
http.Error(w, "failed to send notification", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("forgejo webhook: sent gotify notification event=%s title=%q", event, title)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// verifySignature checks X-Gitea-Signature-256: sha256=<hex>
|
||||
func (h *ForgejoHandler) verifySignature(body []byte, sig string) bool {
|
||||
sig = strings.TrimPrefix(sig, "sha256=")
|
||||
if sig == "" {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(h.secret))
|
||||
mac.Write(body)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(sig), []byte(expected))
|
||||
}
|
||||
|
||||
// formatMessage converts a Forgejo event payload into a Gotify title + message.
|
||||
// Returns empty title if the event should be ignored.
|
||||
func (h *ForgejoHandler) formatMessage(event string, body []byte) (title, message string, priority int) {
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", "", 0
|
||||
}
|
||||
|
||||
repo := jsonStr(payload, "repository", "full_name")
|
||||
sender := jsonStr(payload, "sender", "login")
|
||||
|
||||
switch event {
|
||||
case "push":
|
||||
ref := strings.TrimPrefix(fmt.Sprintf("%v", payload["ref"]), "refs/heads/")
|
||||
commits, _ := payload["commits"].([]interface{})
|
||||
count := len(commits)
|
||||
commitMsg := ""
|
||||
if count > 0 {
|
||||
if c, ok := commits[0].(map[string]interface{}); ok {
|
||||
commitMsg = fmt.Sprintf("%v", c["message"])
|
||||
// truncate long commit messages
|
||||
if len(commitMsg) > 80 {
|
||||
commitMsg = commitMsg[:80] + "…"
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("📦 %s", repo),
|
||||
fmt.Sprintf("%s pushed %d commit(s) to %s\n%s", sender, count, ref, commitMsg),
|
||||
5
|
||||
|
||||
case "pull_request":
|
||||
action := fmt.Sprintf("%v", payload["action"])
|
||||
if action != "opened" && action != "closed" && action != "reopened" && action != "merged" {
|
||||
return "", "", 0 // ignore noise (labeled, assigned, etc.)
|
||||
}
|
||||
pr, _ := payload["pull_request"].(map[string]interface{})
|
||||
number := fmt.Sprintf("%v", pr["number"])
|
||||
prTitle := fmt.Sprintf("%v", pr["title"])
|
||||
merged, _ := pr["merged"].(bool)
|
||||
if action == "closed" && merged {
|
||||
action = "merged"
|
||||
}
|
||||
return fmt.Sprintf("🔀 PR #%s %s — %s", number, action, repo),
|
||||
fmt.Sprintf("%s: %s\nby %s", action, prTitle, sender),
|
||||
5
|
||||
|
||||
case "issues":
|
||||
action := fmt.Sprintf("%v", payload["action"])
|
||||
if action != "opened" && action != "closed" && action != "reopened" {
|
||||
return "", "", 0
|
||||
}
|
||||
issue, _ := payload["issue"].(map[string]interface{})
|
||||
number := fmt.Sprintf("%v", issue["number"])
|
||||
issueTitle := fmt.Sprintf("%v", issue["title"])
|
||||
return fmt.Sprintf("🐛 Issue #%s %s — %s", number, action, repo),
|
||||
fmt.Sprintf("%s: %s\nby %s", action, issueTitle, sender),
|
||||
4
|
||||
|
||||
case "issue_comment", "pull_request_review_comment":
|
||||
issue, _ := payload["issue"].(map[string]interface{})
|
||||
comment, _ := payload["comment"].(map[string]interface{})
|
||||
number := fmt.Sprintf("%v", issue["number"])
|
||||
body := fmt.Sprintf("%v", comment["body"])
|
||||
if len(body) > 100 {
|
||||
body = body[:100] + "…"
|
||||
}
|
||||
return fmt.Sprintf("💬 Comment on #%s — %s", number, repo),
|
||||
fmt.Sprintf("%s: %s", sender, body),
|
||||
3
|
||||
|
||||
case "release":
|
||||
action := fmt.Sprintf("%v", payload["action"])
|
||||
if action != "published" {
|
||||
return "", "", 0
|
||||
}
|
||||
release, _ := payload["release"].(map[string]interface{})
|
||||
tag := fmt.Sprintf("%v", release["tag_name"])
|
||||
name := fmt.Sprintf("%v", release["name"])
|
||||
return fmt.Sprintf("🚀 Release %s — %s", tag, repo),
|
||||
fmt.Sprintf("%s published by %s", name, sender),
|
||||
7
|
||||
|
||||
default:
|
||||
return "", "", 0
|
||||
}
|
||||
}
|
||||
|
||||
// jsonStr safely traverses nested map keys.
|
||||
func jsonStr(m map[string]interface{}, keys ...string) string {
|
||||
cur := m
|
||||
for i, k := range keys {
|
||||
v, ok := cur[k]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if i == len(keys)-1 {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
cur, ok = v.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -76,22 +76,6 @@ spec:
|
||||
value: "1.0.0"
|
||||
- name: OTEL_ENVIRONMENT
|
||||
value: "production"
|
||||
# Gotify integration — Forgejo webhook → push notifications
|
||||
- name: GOTIFY_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gotify-webhook-secret
|
||||
key: gotify-url
|
||||
- name: GOTIFY_APP_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gotify-webhook-secret
|
||||
key: gotify-app-token
|
||||
- name: FORGEJO_WEBHOOK_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gotify-webhook-secret
|
||||
key: forgejo-webhook-secret
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/gateway
|
||||
|
||||
@@ -118,34 +118,16 @@ NO_NS=$(curl -s -w '%{http_code}' -X POST \
|
||||
-d '{}' \
|
||||
"${GW}/" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$NO_NS" = "400" ] || [ "$NO_NS" = "404" ]; then
|
||||
echo " ✓ Namespace validation: got ${NO_NS} (400=enforced 404=old image)"
|
||||
if [ "$NO_NS" = "400" ]; then
|
||||
echo " ✓ Correctly rejected list without namespace (400)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ✗ Unexpected code for missing namespace, got $NO_NS"
|
||||
echo " ✗ Expected 400 for missing namespace, got $NO_NS"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
echo ""
|
||||
# ── Forgejo webhook ──
|
||||
# NOTE: old image returns 404 (endpoint not present), new image returns 200.
|
||||
# Accept both during rollout — test confirms routing is wired.
|
||||
echo "▸ Forgejo webhook"
|
||||
WH_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-H "X-Gitea-Event: push" \
|
||||
-d '{"ref":"refs/heads/main","commits":[],"repository":{"full_name":"test/repo"},"sender":{"login":"ci"}}' \
|
||||
"${GW}/v1/webhooks/forgejo" 2>/dev/null || echo "000")
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if [ "$WH_CODE" = "200" ] || [ "$WH_CODE" = "404" ]; then
|
||||
echo " ✓ /v1/webhooks/forgejo: ${WH_CODE} (200=live 404=old image)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ✗ /v1/webhooks/forgejo: unexpected ${WH_CODE}"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "═══ Results: ${PASS}/${TOTAL} passed, ${FAIL} failed ═══"
|
||||
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
|
||||
Reference in New Issue
Block a user