package main import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "io" "log/slog" "net/http" "os" "os/exec" "time" "github.com/dtoro/oikos/internal/secrets" "github.com/dtoro/oikos/internal/safego" ) func main() { ctx := context.Background() port := os.Getenv("WEBHOOK_LISTEN") if port == "" { port = ":9797" } repoDir := os.Getenv("WEBHOOK_REPO_DIR") if repoDir == "" { repoDir = os.Getenv("HOME") + "/Projects/oikos" } // Create secrets manager once, share between HMAC resolution and deploy sec := newSecrets() secret := resolveWebhookHMAC(ctx, sec) if secret == "" { fmt.Fprintln(os.Stderr, "WEBHOOK_HMAC_SECRET must be set (env var or Infisical webhook_hmac-secret)") os.Exit(1) } mux := http.NewServeMux() mux.HandleFunc("/deploy", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "read body failed", 400) return } sigHex := r.Header.Get("X-Hub-Signature-256") if sigHex == "" { http.Error(w, "missing signature", 401) return } mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(sigHex), []byte(expected)) { slog.Warn("webhook: invalid signature") http.Error(w, "invalid signature", 401) return } slog.Info("webhook: deploy triggered") w.WriteHeader(http.StatusAccepted) w.Write([]byte(`{"status":"deploy started"}`)) safego.Go("webhook:deploy", func() { apiToken := "" if sec != nil { apiToken = secrets.ResolveSecret(ctx, sec, "api_token", "") } cmd := exec.Command(repoDir + "/scripts/deploy.sh") cmd.Dir = repoDir cmd.Env = append(os.Environ(), "REPO_DIR="+repoDir, "PROFILE=full", "OIKOS_API_TOKEN="+apiToken, ) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr start := time.Now() if err := cmd.Run(); err != nil { slog.Error("webhook: deploy failed", "error", err, "duration", time.Since(start)) return } slog.Info("webhook: deploy succeeded", "duration", time.Since(start)) }) }) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) w.Write([]byte("ok")) }) slog.Info("webhook: listening", "port", port) if err := http.ListenAndServe(port, mux); err != nil { slog.Error("webhook: serve failed", "error", err) os.Exit(1) } } // newSecrets creates the Infisical secrets manager from env vars. func newSecrets() *secrets.Manager { return secrets.NewManagerFromConfig( os.Getenv("OIKOS_INFISICAL_SITE_URL"), os.Getenv("OIKOS_INFISICAL_CLIENT_ID"), os.Getenv("OIKOS_INFISICAL_CLIENT_SECRET"), os.Getenv("OIKOS_INFISICAL_PROJECT_ID"), os.Getenv("OIKOS_INFISICAL_ENV"), os.Getenv("OIKOS_SECRETS_DIR"), ) } // resolveWebhookHMAC fetches the webhook HMAC secret from Infisical, // falling back to the WEBHOOK_HMAC_SECRET env var. func resolveWebhookHMAC(ctx context.Context, sec *secrets.Manager) string { envFallback := os.Getenv("WEBHOOK_HMAC_SECRET") if sec == nil { return envFallback } return secrets.ResolveSecret(ctx, sec, "webhook_hmac-secret", envFallback) }