feat(gitsync): webhook-triggad synk + helt manuellt läge
All checks were successful
build-and-push / build (push) Successful in 1m7s
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user