feat(gitsync): webhook-triggad synk + helt manuellt läge
All checks were successful
build-and-push / build (push) Successful in 1m7s

- POST /api/git-hook: HMAC-SHA256-verifierad (X-Gitea-Signature),
  reagerar bara på pushar till live-grenen, svarar 202 och synkar async
- webhook-hemlighet genereras server-side (headless via config.json),
  roteras med gitRegenerateWebhookSecret
- auto_sync_minutes=0 + webhook av ⇒ ingen automatisk hämtning alls;
  webhook på ⇒ catch-up-synk vid uppstart (missade event)
- admin-UI: webhook-toggle, target-URL + secret med copy/rotate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 23:52:01 +02:00
parent 9854d85237
commit 5819a6bdce
7 changed files with 236 additions and 7 deletions

View File

@@ -37,8 +37,15 @@ type GitSyncConfig struct {
ReadOnly bool `json:"read_only"`
// AutoSyncMinutes > 0 enables background sync on that interval.
// Background pulls are fast-forward only; anything needing a real
// merge is left for an admin to resolve in the UI.
// merge is left for an admin to resolve in the UI. With 0 and
// webhook disabled, nothing is fetched automatically at all.
AutoSyncMinutes int `json:"auto_sync_minutes"`
// WebhookEnabled exposes POST /api/git-hook so the git host can
// trigger a sync on push instead of (or besides) polling.
WebhookEnabled bool `json:"webhook_enabled"`
// WebhookSecret authenticates hook calls (HMAC-SHA256 signature in
// X-Gitea-Signature). Generated by the server when left empty.
WebhookSecret string `json:"webhook_secret"`
}
// Normalize fills in defaults for optional git-sync fields.

View File

@@ -3,10 +3,15 @@ package graph
import (
"bytes"
"crypto/ed25519"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
@@ -107,6 +112,22 @@ func (s *Server) configureGitSync(cfg *config.Config) {
return
}
// Headless path: a config.json with webhook_enabled but no secret
// gets one generated and persisted here at startup.
if gs.WebhookEnabled && gs.WebhookSecret == "" {
if secret, err := newWebhookSecret(); err == nil {
s.mu.Lock()
s.cfg.GitSync.WebhookSecret = secret
cfgCopy := *s.cfg
s.mu.Unlock()
if err := config.Save(s.configPath, &cfgCopy); err != nil {
log.Printf("[gitsync] could not persist webhook secret: %v", err)
} else {
log.Printf("[gitsync] generated webhook secret")
}
}
}
keyPath, khPath, _, err := s.ensureSSHKey()
if err != nil {
log.Printf("[gitsync] ssh key setup failed: %v", err)
@@ -120,15 +141,21 @@ func (s *Server) configureGitSync(cfg *config.Config) {
log.Printf("[gitsync] enabled — remote=%s live=%s readOnly=%v auto=%dmin",
gs.RemoteURL, gs.LiveBranch, gs.ReadOnly, gs.AutoSyncMinutes)
// Fully manual mode: neither interval nor webhook is on, so nothing
// fetches automatically — not even at startup.
if gs.AutoSyncMinutes <= 0 && !gs.WebhookEnabled {
return
}
stop := make(chan struct{})
s.syncMu.Lock()
s.syncStop = stop
s.syncMu.Unlock()
go func() {
// One eager sync shortly after (re)configuration, then on the
// configured interval. Manual-only mode still gets the eager
// run so status is populated after a restart.
// One eager sync shortly after (re)configuration: catches up on
// webhook events missed while the server was down, and seeds
// status. Then the configured interval, when one is set.
timer := time.NewTimer(10 * time.Second)
defer timer.Stop()
select {
@@ -153,6 +180,69 @@ func (s *Server) configureGitSync(cfg *config.Config) {
}()
}
// ── Webhook (push-triggered sync) ─────────────────────────────────────────────
// handleGitWebhook is the plain-HTTP endpoint the git host calls on
// push. It is unauthenticated but requires a valid HMAC-SHA256
// signature over the raw body (Gitea's X-Gitea-Signature header).
func (s *Server) handleGitWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
if cfg == nil || !cfg.GitSync.Enabled || !cfg.GitSync.WebhookEnabled ||
cfg.GitSync.WebhookSecret == "" {
http.NotFound(w, r)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(cfg.GitSync.WebhookSecret))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
got := r.Header.Get("X-Gitea-Signature")
if got == "" || !hmac.Equal([]byte(want), []byte(got)) {
log.Printf("[gitsync] webhook rejected: bad signature from %s", r.RemoteAddr)
http.Error(w, "invalid signature", http.StatusForbidden)
return
}
// Only pushes to the live branch are interesting; everything else
// is acknowledged and ignored.
var payload struct {
Ref string `json:"ref"`
}
_ = json.Unmarshal(body, &payload)
gs := cfg.GitSync
gs.Normalize()
if payload.Ref != "" && payload.Ref != "refs/heads/"+gs.LiveBranch {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"status":"ignored","ref":%q}`, payload.Ref)
return
}
log.Printf("[gitsync] webhook push event (%s) — scheduling sync", payload.Ref)
go s.backgroundSync()
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"status":"sync scheduled"}`))
}
// newWebhookSecret returns a fresh random hex secret.
func newWebhookSecret() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// backgroundSync fetches and, when the live branch is checked out,
// fast-forwards it and pushes local commits. It never merges: anything
// that needs a real merge raises the attention flag for an admin.
@@ -257,6 +347,10 @@ func (s *Server) handleGitSyncSettings(w http.ResponseWriter, sess *auth.Session
if pub, err := os.ReadFile(filepath.Join(s.sshDir(), "id_ed25519.pub")); err == nil {
publicKey = strings.TrimSpace(string(pub))
}
webhookURL := ""
if cfg.PublicURL != "" {
webhookURL = strings.TrimRight(cfg.PublicURL, "/") + "/api/git-hook"
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitSyncSettings": map[string]interface{}{
@@ -267,6 +361,9 @@ func (s *Server) handleGitSyncSettings(w http.ResponseWriter, sess *auth.Session
"autoSyncMinutes": gs.AutoSyncMinutes,
"publicKey": publicKey,
"keySet": publicKey != "",
"webhookEnabled": gs.WebhookEnabled,
"webhookSecret": gs.WebhookSecret,
"webhookUrl": webhookURL,
},
},
})
@@ -299,6 +396,16 @@ func (s *Server) handleUpdateGitSync(w http.ResponseWriter, req gqlRequest, sess
LiveBranch: strings.TrimSpace(strVal(input, "liveBranch")),
ReadOnly: boolVal(input, "readOnly"),
AutoSyncMinutes: intVal(input, "autoSyncMinutes"),
WebhookEnabled: boolVal(input, "webhookEnabled"),
WebhookSecret: cfg.GitSync.WebhookSecret, // never set from input; rotated via its own mutation
}
if gs.WebhookEnabled && gs.WebhookSecret == "" {
secret, err := newWebhookSecret()
if err != nil {
writeGQLError(w, fmt.Sprintf("webhook secret generation failed: %v", err))
return
}
gs.WebhookSecret = secret
}
gs.Normalize()
newCfg.GitSync = gs
@@ -342,6 +449,39 @@ func (s *Server) handleGitRegenerateKey(w http.ResponseWriter, sess *auth.Sessio
})
}
func (s *Server) handleGitRegenerateWebhookSecret(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
if cfg == nil {
writeGQLError(w, "server not initialised")
return
}
secret, err := newWebhookSecret()
if err != nil {
writeGQLError(w, fmt.Sprintf("secret generation failed: %v", err))
return
}
newCfg := *cfg
newCfg.GitSync.WebhookSecret = secret
if err := config.Save(s.configPath, &newCfg); err != nil {
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
return
}
s.mu.Lock()
s.cfg = &newCfg
s.mu.Unlock()
log.Printf("[admin] webhook secret rotated by %s", sess.Username)
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitRegenerateWebhookSecret": map[string]interface{}{"secret": secret},
},
})
}
func (s *Server) handleGitSyncStatus(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return

View File

@@ -176,6 +176,9 @@ type Mutation {
# Replace the sync keypair (register the new public key as deploy key).
gitRegenerateKey: GitKeyResult!
# Rotate the webhook secret (update the webhook config on the git host).
gitRegenerateWebhookSecret: GitWebhookSecret!
# Delete an image file.
deleteImage(slug: String!, filename: String!): Boolean!
@@ -296,9 +299,16 @@ type GitSyncSettings {
remoteUrl: String!
liveBranch: String!
readOnly: Boolean!
# 0 = no interval polling. With webhook off too, nothing is fetched
# automatically at all (fully manual).
autoSyncMinutes: Int!
publicKey: String!
keySet: Boolean!
# Push-triggered sync: POST /api/git-hook with HMAC-SHA256 signature
# (X-Gitea-Signature) over the raw body using webhookSecret.
webhookEnabled: Boolean!
webhookSecret: String!
webhookUrl: String!
}
type GitSyncStatus {
@@ -347,6 +357,13 @@ input GitSyncInput {
liveBranch: String!
readOnly: Boolean!
autoSyncMinutes: Int!
# The secret is never taken from input — it is generated server-side
# on first enable and rotated via gitRegenerateWebhookSecret.
webhookEnabled: Boolean!
}
type GitWebhookSecret {
secret: String!
}
type ImageFile {

View File

@@ -53,6 +53,7 @@ func NewServer(cfg *config.Config, configPath string) http.Handler {
mux.HandleFunc("/graphql", s.handleGraphQL)
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/api/upload", s.handleUpload)
mux.HandleFunc("/api/git-hook", s.handleGitWebhook)
mux.HandleFunc("/media/", s.handleMedia)
mux.HandleFunc("/auth/oidc/login", s.handleOIDCLogin)
mux.HandleFunc("/auth/oidc/callback", s.handleOIDCCallback)
@@ -268,6 +269,9 @@ func (s *Server) dispatchAuthenticated(
case strings.Contains(q, "gitResetFromRemote"):
s.handleGitResetFromRemote(w, sess)
case strings.Contains(q, "gitRegenerateWebhookSecret"):
s.handleGitRegenerateWebhookSecret(w, sess)
case strings.Contains(q, "gitRegenerateKey"):
s.handleGitRegenerateKey(w, sess)