mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(git): push webhook receiver + repo connect on service create
New public POST /api/git/webhooks/🆔 verifies provider signatures (GitHub/Gitea HMAC-SHA256, GitLab token), filters branch pushes, matches connected repo to services by clone_url/full_name + branch, inserts deployment rows and enqueues runDeploymentAndSync (trigger=webhook). Service-create dialog now connects the selected provider repo and registers a push webhook (409-tolerant via connected-repo lookup). api-client: connectGitRepository, listConnectedGitRepositories, createGitWebhook typed from generated schemas. GitWebhook spec schema corrected (events array + branch field). Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
93e3074477
commit
25703592c6
8 files changed
+550
-11
No files matched your search
@@ -0,0 +1,214 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// handleGitWebhookPush is the public push receiver. Providers call
|
||||
// POST /api/git/webhooks/:id with a signed payload; the HMAC against
|
||||
// git_webhooks.webhook_secret replaces session auth.
|
||||
func handleGitWebhookPush(c *gin.Context) {
|
||||
dbValue, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
db := dbValue.(*database.DB)
|
||||
|
||||
webhookID := strings.TrimSpace(c.Param("id"))
|
||||
if _, err := uuid.Parse(webhookID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
secret string
|
||||
repoID string
|
||||
providerID string
|
||||
branchFilter string
|
||||
active bool
|
||||
)
|
||||
err := db.QueryRow(`
|
||||
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, ''), active
|
||||
FROM git_webhooks WHERE id = $1`, webhookID).
|
||||
Scan(&secret, &repoID, &providerID, &branchFilter, &active)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Webhook not found"})
|
||||
return
|
||||
}
|
||||
if !active {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Webhook is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 4<<20))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read payload"})
|
||||
return
|
||||
}
|
||||
|
||||
var providerName string
|
||||
_ = db.QueryRow(`SELECT name FROM git_providers WHERE id = $1`, providerID).Scan(&providerName)
|
||||
|
||||
if !verifyGitWebhookSignature(providerName, secret, c.Request, body) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Ref string `json:"ref"`
|
||||
After string `json:"after"`
|
||||
Repository struct {
|
||||
FullName string `json:"full_name"`
|
||||
PathWithNamespace string `json:"path_with_namespace"`
|
||||
} `json:"repository"`
|
||||
Project struct {
|
||||
PathWithNamespace string `json:"path_with_namespace"`
|
||||
} `json:"project"`
|
||||
HeadCommit struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"head_commit"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(payload.Ref, "refs/heads/") {
|
||||
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"})
|
||||
return
|
||||
}
|
||||
branch := strings.TrimPrefix(payload.Ref, "refs/heads/")
|
||||
if branchFilter != "" && branchFilter != branch {
|
||||
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "branch not watched"})
|
||||
return
|
||||
}
|
||||
commit := payload.HeadCommit.ID
|
||||
if commit == "" {
|
||||
commit = payload.After
|
||||
}
|
||||
|
||||
var cloneURL, fullName, repoUserID string
|
||||
err = db.QueryRow(
|
||||
`SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1`, repoID,
|
||||
).Scan(&cloneURL, &fullName, &repoUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`,
|
||||
branch, cloneURL, fullName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var services []Service
|
||||
for rows.Next() {
|
||||
var s Service
|
||||
if err := rows.Scan(
|
||||
&s.ID, &s.ProjectID, &s.Name, &s.Type, &s.Status, &s.Image, &s.Command,
|
||||
&s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory,
|
||||
&s.CreatedAt, &s.UpdatedAt,
|
||||
); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
||||
return
|
||||
}
|
||||
services = append(services, s)
|
||||
}
|
||||
|
||||
engineValue, _ := c.Get("deployment_engine")
|
||||
engine, _ := engineValue.(*deployment.DeploymentEngine)
|
||||
|
||||
enqueued := 0
|
||||
for _, service := range services {
|
||||
now := time.Now()
|
||||
var commitHash *string
|
||||
if commit != "" {
|
||||
commitHash = &commit
|
||||
}
|
||||
d := DeploymentModel{
|
||||
ID: uuid.New(),
|
||||
ServiceID: service.ID,
|
||||
CommitHash: commitHash,
|
||||
Status: "pending",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO deployments
|
||||
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
if engine == nil {
|
||||
failedAt := time.Now()
|
||||
failure := "Deployment engine unavailable. Docker may not be configured on this server."
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||
failure, failedAt, d.ID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), service.ID,
|
||||
)
|
||||
go runDeploymentAndSync(
|
||||
context.Background(), db, engine, &d, service,
|
||||
CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"},
|
||||
repoUserID,
|
||||
)
|
||||
enqueued++
|
||||
}
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"received": true,
|
||||
"branch": branch,
|
||||
"deployments": enqueued,
|
||||
})
|
||||
}
|
||||
|
||||
// verifyGitWebhookSignature checks the push signature per provider:
|
||||
// GitHub X-Hub-Signature-256 and Gitea X-Gitea-Signature are HMAC-SHA256 hex;
|
||||
// GitLab compares X-Gitlab-Token to the stored secret directly.
|
||||
func verifyGitWebhookSignature(provider, secret string, r *http.Request, body []byte) bool {
|
||||
if strings.EqualFold(provider, "gitlab") {
|
||||
token := r.Header.Get("X-Gitlab-Token")
|
||||
return token != "" && subtle.ConstantTimeCompare([]byte(token), []byte(secret)) == 1
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
sum := mac.Sum(nil)
|
||||
for _, header := range []string{"X-Hub-Signature-256", "X-Gitea-Signature"} {
|
||||
sig := strings.TrimPrefix(r.Header.Get(header), "sha256=")
|
||||
if decoded, err := hex.DecodeString(sig); err == nil && len(decoded) > 0 && hmac.Equal(decoded, sum) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func signBody(t *testing.T, secret string, body []byte) string {
|
||||
t.Helper()
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func TestVerifyGitWebhookSignature(t *testing.T) {
|
||||
body := []byte(`{"ref":"refs/heads/main"}`)
|
||||
secret := "test-secret"
|
||||
valid := signBody(t, secret, body)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
provider string
|
||||
headers map[string]string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "github sha256 prefix",
|
||||
provider: "github",
|
||||
headers: map[string]string{"X-Hub-Signature-256": "sha256=" + valid},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "gitea signature",
|
||||
provider: "gitea",
|
||||
headers: map[string]string{"X-Gitea-Signature": valid},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "gitlab token",
|
||||
provider: "gitlab",
|
||||
headers: map[string]string{"X-Gitlab-Token": secret},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "gitlab rejects wrong token",
|
||||
provider: "gitlab",
|
||||
headers: map[string]string{"X-Gitlab-Token": "wrong"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "github rejects wrong signature",
|
||||
provider: "github",
|
||||
headers: map[string]string{"X-Hub-Signature-256": "sha256=" + signBody(t, "other", body)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "missing signature",
|
||||
provider: "github",
|
||||
headers: map[string]string{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "malformed hex",
|
||||
provider: "github",
|
||||
headers: map[string]string{"X-Hub-Signature-256": "sha256=zzz"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/git/webhooks/x", nil)
|
||||
for k, v := range tc.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if got := verifyGitWebhookSignature(tc.provider, secret, req, body); got != tc.want {
|
||||
t.Fatalf("verifyGitWebhookSignature = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
publicAgents := router.Group("/api")
|
||||
agentHandler.SetupPublicRoutes(publicAgents)
|
||||
|
||||
// Public push receiver: HMAC signature on git_webhooks.webhook_secret
|
||||
// replaces session auth. Providers POST to /api/git/webhooks/:id.
|
||||
router.POST("/api/git/webhooks/:id", handleGitWebhookPush)
|
||||
|
||||
v1 := router.Group("/api/v1")
|
||||
{
|
||||
// Public routes (no authentication required)
|
||||
|
||||
Reference in new issue
Block a user