From fe539f46b579c14f4c4759b60327f3853ee60a13 Mon Sep 17 00:00:00 2001 From: "jason.liao" Date: Tue, 25 Jul 2023 13:58:53 +0800 Subject: [PATCH 1/9] feat: add new hook option `keep-file-environment` --- docs/Hook-Definition.md | 1 + internal/hook/hook.go | 1 + webhook.go | 47 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/docs/Hook-Definition.md b/docs/Hook-Definition.md index 8a7e7443..5618235d 100644 --- a/docs/Hook-Definition.md +++ b/docs/Hook-Definition.md @@ -23,6 +23,7 @@ Hooks are defined as objects in the JSON or YAML hooks configuration file. Pleas * `trigger-rule` - specifies the rule that will be evaluated in order to determine should the hook be triggered. Check [Hook rules page](Hook-Rules.md) to see the list of valid rules and their usage * `trigger-rule-mismatch-http-response-code` - specifies the HTTP status code to be returned when the trigger rule is not satisfied * `trigger-signature-soft-failures` - allow signature validation failures within Or rules; by default, signature failures are treated as errors. +* `keep-file-environment` - Keep all submitted files. Sending `curl -d 'pkg=@res.tar.gz'` will retrieve the environment variable `HOOK_FILE_PKG`, which contains the file path, and `HOOK_FILENAME_PKG`, which contains the file name as `res.tar.gz`. If `keep-file-environment` is true, the file will be preserved after the hook is executed. By default, the corresponding file will be removed after the webhook exits. ## Examples Check out [Hook examples page](Hook-Examples.md) for more complex examples of hooks. diff --git a/internal/hook/hook.go b/internal/hook/hook.go index 05100957..fbd4d67b 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -581,6 +581,7 @@ type Hook struct { IncomingPayloadContentType string `json:"incoming-payload-content-type,omitempty"` SuccessHttpResponseCode int `json:"success-http-response-code,omitempty"` HTTPMethods []string `json:"http-methods"` + KeepFileEnvironment bool `json:"keep-file-environment,omitempty"` } // ParseJSONParameters decodes specified arguments to JSON objects and replaces the diff --git a/webhook.go b/webhook.go index d23cd028..f3070ac3 100644 --- a/webhook.go +++ b/webhook.go @@ -5,6 +5,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "io/ioutil" "log" "net" @@ -615,6 +616,52 @@ func handleHook(h *hook.Hook, r *hook.Request) (string, error) { envs = append(envs, files[i].EnvName+"="+tmpfile.Name()) } + if h.KeepFileEnvironment && r.RawRequest != nil && r.RawRequest.MultipartForm != nil { + for k, v := range r.RawRequest.MultipartForm.File { + env_name := hook.EnvNamespace + "FILE_" + strings.ToUpper(k) + f, err := v[0].Open() + if err != nil { + log.Printf("[%s] error open form %s file [%s]", r.ID, k, err) + continue + } + if f1, ok := f.(*os.File); ok { + log.Printf("[%s] temporary file %s", r.ID, f1.Name()) + _ = f1.Close() + files = append(files, hook.FileParameter{File: f1, EnvName: env_name}) + envs = append(envs, + env_name+"="+f1.Name(), + hook.EnvNamespace+"FILENAME_"+strings.ToUpper(k)+"="+v[0].Filename, + ) + continue + } + tmpfile, err := os.CreateTemp("", ".hook-"+r.ID+"-"+k+"-*") + if err != nil { + _ = f.Close() + log.Printf("[%s] error creating temp file [%s]", r.ID, err) + continue + } + log.Printf("[%s] writing env %s file %s", r.ID, env_name, tmpfile.Name()) + if _, err = io.Copy(tmpfile, f); err != nil { + log.Printf("[%s] error writing file %s [%s]", r.ID, tmpfile.Name(), err) + _ = f.Close() + _ = tmpfile.Close() + _ = os.Remove(tmpfile.Name()) + continue + } + if err := tmpfile.Close(); err != nil { + log.Printf("[%s] error closing file %s [%s]", r.ID, tmpfile.Name(), err) + _ = os.Remove(tmpfile.Name()) + continue + } + _ = f.Close() + files = append(files, hook.FileParameter{File: tmpfile, EnvName: env_name}) + envs = append(envs, + env_name+"="+tmpfile.Name(), + hook.EnvNamespace+"FILENAME_"+strings.ToUpper(k)+"="+v[0].Filename, + ) + } + } + cmd.Env = append(os.Environ(), envs...) log.Printf("[%s] executing %s (%s) with arguments %q and environment %s using %s as cwd\n", r.ID, h.ExecuteCommand, cmd.Path, cmd.Args, envs, cmd.Dir) From 827212647499c50502a14513bae2eaaec2a07af5 Mon Sep 17 00:00:00 2001 From: "jason.liao" Date: Tue, 28 Apr 2026 10:35:11 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=85=8D=E7=BD=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 默认不启用,需要通过启动参数开启 --- .gitignore | 3 + README.md | 2 +- admin.go | 479 +++++++++++++++ admin_store.go | 348 +++++++++++ admin_test.go | 391 ++++++++++++ admin_ui.go | 41 ++ adminui/assets/app.js | 1254 ++++++++++++++++++++++++++++++++++++++ adminui/assets/style.css | 608 ++++++++++++++++++ adminui/index.html | 215 +++++++ go.mod | 2 +- webhook.go | 98 +-- 11 files changed, 3404 insertions(+), 37 deletions(-) create mode 100644 admin.go create mode 100644 admin_store.go create mode 100644 admin_test.go create mode 100644 admin_ui.go create mode 100644 adminui/assets/app.js create mode 100644 adminui/assets/style.css create mode 100644 adminui/index.html diff --git a/.gitignore b/.gitignore index 9ad22971..c757ec39 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ coverage webhook /test/hookecho build + +.gocache/ +.DS_Store diff --git a/README.md b/README.md index 4c31afd2..74ae0362 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ If you don't have time to waste configuring, hosting, debugging and maintaining # Getting started ## Installation ### Building from source -To get started, first make sure you've properly set up your [Go](http://golang.org/doc/install) 1.14 or newer environment and then run +To get started, first make sure you've properly set up your [Go](http://golang.org/doc/install) 1.16 or newer environment and then run ```bash $ go build github.com/adnanh/webhook ``` diff --git a/admin.go b/admin.go new file mode 100644 index 00000000..b0bd1577 --- /dev/null +++ b/admin.go @@ -0,0 +1,479 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/base32" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/adnanh/webhook/internal/hook" + "github.com/gorilla/mux" +) + +var ( + adminEnabled = flag.Bool("admin", false, "enable the admin UI and API for managing hooks") + adminURLPrefix = flag.String("admin-path", "admin", "url prefix to use for the admin UI and API") + adminTOTPSecret = flag.String("admin-totp-secret", "", "base32 encoded TOTP secret for admin login") + adminJWTSecret = flag.String("admin-jwt-secret", "", "secret used to sign admin JWT sessions") + adminSessionTTL = flag.String("admin-session-ttl", "12h", "lifetime of an admin JWT session") +) + +const adminTokenCookieName = "webhook_admin_token" + +type adminAuthConfig struct { + basePath string + totpSecret []byte + jwtSecret []byte + sessionTTL time.Duration +} + +type adminJWTHeader struct { + Algorithm string `json:"alg"` + Type string `json:"typ"` +} + +type adminJWTClaims struct { + Subject string `json:"sub"` + IssuedAt int64 `json:"iat"` + NotBefore int64 `json:"nbf"` + ExpiresAt int64 `json:"exp"` +} + +type adminLoginRequest struct { + Code string `json:"code"` +} + +type adminHookMutationRequest struct { + File string `json:"file"` + CurrentID string `json:"currentId,omitempty"` + Hook hook.Hook `json:"hook"` +} + +var currentAdminAuth *adminAuthConfig + +func initAdmin() error { + if !*adminEnabled { + return nil + } + + trimmedAdminPath := strings.Trim(strings.TrimSpace(*adminURLPrefix), "/") + if trimmedAdminPath == "" { + return errors.New("admin-path must not be empty") + } + + trimmedHooksPath := strings.Trim(strings.TrimSpace(*hooksURLPrefix), "/") + if trimmedHooksPath == trimmedAdminPath { + return errors.New("admin-path must not match urlprefix") + } + + ttl, err := time.ParseDuration(strings.TrimSpace(*adminSessionTTL)) + if err != nil { + return fmt.Errorf("invalid admin-session-ttl: %w", err) + } + if ttl <= 0 { + return errors.New("admin-session-ttl must be greater than zero") + } + + totpSecret, err := decodeTOTPSecret(*adminTOTPSecret) + if err != nil { + return fmt.Errorf("invalid admin-totp-secret: %w", err) + } + + jwtSecret := strings.TrimSpace(*adminJWTSecret) + if jwtSecret == "" { + return errors.New("admin-jwt-secret must not be empty") + } + + *adminURLPrefix = trimmedAdminPath + currentAdminAuth = &adminAuthConfig{ + basePath: makeBaseURL(adminURLPrefix), + totpSecret: totpSecret, + jwtSecret: []byte(jwtSecret), + sessionTTL: ttl, + } + + return nil +} + +func registerAdminRoutes(r *mux.Router) { + if !*adminEnabled { + return + } + + basePath := currentAdminAuth.basePath + staticHandler := http.StripPrefix(basePath+"/", adminStaticHandler()) + + r.HandleFunc(basePath, adminUIHandler).Methods(http.MethodGet) + r.HandleFunc(basePath+"/", adminUIHandler).Methods(http.MethodGet) + r.HandleFunc(basePath+"/api/auth/login", adminLoginHandler).Methods(http.MethodPost) + r.HandleFunc(basePath+"/api/auth/logout", adminLogoutHandler).Methods(http.MethodPost) + r.HandleFunc(basePath+"/api/config", adminRequireAuth(adminConfigHandler)).Methods(http.MethodGet) + r.HandleFunc(basePath+"/api/hooks", adminRequireAuth(adminCreateHookHandler)).Methods(http.MethodPost) + r.HandleFunc(basePath+"/api/hooks", adminRequireAuth(adminUpdateHookHandler)).Methods(http.MethodPut) + r.HandleFunc(basePath+"/api/hooks", adminRequireAuth(adminDeleteHookHandler)).Methods(http.MethodDelete) + r.PathPrefix(basePath + "/assets/").Handler(staticHandler).Methods(http.MethodGet) +} + +func decodeTOTPSecret(secret string) ([]byte, error) { + normalized := strings.ToUpper(strings.TrimSpace(secret)) + replacer := strings.NewReplacer(" ", "", "-", "", "\n", "", "\r", "", "\t", "", "=", "") + normalized = replacer.Replace(normalized) + + if normalized == "" { + return nil, errors.New("secret is empty") + } + + decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalized) + if err == nil { + return decoded, nil + } + + if rem := len(normalized) % 8; rem != 0 { + normalized += strings.Repeat("=", 8-rem) + } + + return base32.StdEncoding.DecodeString(normalized) +} + +func hotpCode(secret []byte, counter uint64, digits int) string { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], counter) + + mac := hmac.New(sha1.New, secret) + _, _ = mac.Write(buf[:]) + sum := mac.Sum(nil) + + offset := sum[len(sum)-1] & 0x0f + value := (int(sum[offset])&0x7f)<<24 | + int(sum[offset+1])<<16 | + int(sum[offset+2])<<8 | + int(sum[offset+3]) + + mod := 1 + for i := 0; i < digits; i++ { + mod *= 10 + } + + code := value % mod + format := "%0" + strconv.Itoa(digits) + "d" + return fmt.Sprintf(format, code) +} + +func verifyTOTP(secret []byte, code string, now time.Time) bool { + code = strings.TrimSpace(code) + if len(code) != 6 { + return false + } + for _, ch := range code { + if ch < '0' || ch > '9' { + return false + } + } + + counter := now.Unix() / 30 + for offset := int64(-1); offset <= 1; offset++ { + current := counter + offset + if current < 0 { + continue + } + + expected := hotpCode(secret, uint64(current), 6) + if hmac.Equal([]byte(expected), []byte(code)) { + return true + } + } + + return false +} + +func signAdminJWT(now time.Time) (string, error) { + if currentAdminAuth == nil { + return "", errors.New("admin auth is not initialized") + } + + header := adminJWTHeader{ + Algorithm: "HS256", + Type: "JWT", + } + claims := adminJWTClaims{ + Subject: "webhook-admin", + IssuedAt: now.Unix(), + NotBefore: now.Add(-30 * time.Second).Unix(), + ExpiresAt: now.Add(currentAdminAuth.sessionTTL).Unix(), + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", err + } + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", err + } + + payload := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + base64.RawURLEncoding.EncodeToString(claimsJSON) + + mac := hmac.New(sha256.New, currentAdminAuth.jwtSecret) + _, _ = mac.Write([]byte(payload)) + signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + + return payload + "." + signature, nil +} + +func parseAdminJWT(token string, now time.Time) (*adminJWTClaims, error) { + if currentAdminAuth == nil { + return nil, errors.New("admin auth is not initialized") + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, errors.New("invalid token format") + } + + signed := parts[0] + "." + parts[1] + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, errors.New("invalid token signature encoding") + } + + mac := hmac.New(sha256.New, currentAdminAuth.jwtSecret) + _, _ = mac.Write([]byte(signed)) + expected := mac.Sum(nil) + if !hmac.Equal(signature, expected) { + return nil, errors.New("invalid token signature") + } + + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, errors.New("invalid token header encoding") + } + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, errors.New("invalid token payload encoding") + } + + var header adminJWTHeader + if err := json.Unmarshal(headerBytes, &header); err != nil { + return nil, errors.New("invalid token header") + } + if header.Algorithm != "HS256" || header.Type != "JWT" { + return nil, errors.New("unsupported token header") + } + + var claims adminJWTClaims + if err := json.Unmarshal(payloadBytes, &claims); err != nil { + return nil, errors.New("invalid token payload") + } + if claims.Subject != "webhook-admin" { + return nil, errors.New("invalid token subject") + } + + nowUnix := now.Unix() + if claims.NotBefore != 0 && nowUnix < claims.NotBefore { + return nil, errors.New("token is not active yet") + } + if claims.ExpiresAt == 0 || nowUnix >= claims.ExpiresAt { + return nil, errors.New("token has expired") + } + + return &claims, nil +} + +func setAdminTokenCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: adminTokenCookieName, + Value: token, + Path: currentAdminAuth.basePath, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: *secure, + MaxAge: int(currentAdminAuth.sessionTTL.Seconds()), + }) +} + +func clearAdminTokenCookie(w http.ResponseWriter) { + path := "/" + if currentAdminAuth != nil { + path = currentAdminAuth.basePath + } + + http.SetCookie(w, &http.Cookie{ + Name: adminTokenCookieName, + Value: "", + Path: path, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: *secure, + MaxAge: -1, + Expires: time.Unix(0, 0), + }) +} + +func adminTokenFromRequest(r *http.Request) string { + authHeader := strings.TrimSpace(r.Header.Get("Authorization")) + if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + return strings.TrimSpace(authHeader[7:]) + } + + cookie, err := r.Cookie(adminTokenCookieName) + if err != nil { + return "" + } + + return cookie.Value +} + +func authenticateAdminRequest(r *http.Request) error { + token := adminTokenFromRequest(r) + if token == "" { + return errors.New("missing admin token") + } + + _, err := parseAdminJWT(token, time.Now()) + return err +} + +func adminRequireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := authenticateAdminRequest(r); err != nil { + writeAdminError(w, http.StatusUnauthorized, "authentication required") + return + } + + next(w, r) + } +} + +func writeAdminJSON(w http.ResponseWriter, status int, payload interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeAdminError(w http.ResponseWriter, status int, message string) { + writeAdminJSON(w, status, map[string]string{"error": message}) +} + +func decodeAdminJSON(r *http.Request, payload interface{}) error { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + return decoder.Decode(payload) +} + +func adminLoginHandler(w http.ResponseWriter, r *http.Request) { + var loginReq adminLoginRequest + if err := decodeAdminJSON(r, &loginReq); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid login payload") + return + } + + if !verifyTOTP(currentAdminAuth.totpSecret, loginReq.Code, time.Now()) { + log.Printf("admin login failed from %s", r.RemoteAddr) + writeAdminError(w, http.StatusUnauthorized, "invalid TOTP code") + return + } + + token, err := signAdminJWT(time.Now()) + if err != nil { + writeAdminError(w, http.StatusInternalServerError, "could not create session token") + return + } + + setAdminTokenCookie(w, token) + log.Printf("admin login succeeded from %s", r.RemoteAddr) + writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func adminLogoutHandler(w http.ResponseWriter, r *http.Request) { + clearAdminTokenCookie(w) + writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func adminConfigHandler(w http.ResponseWriter, r *http.Request) { + writeAdminJSON(w, http.StatusOK, hooksStateSnapshot()) +} + +func adminMutationStatus(err error) int { + switch { + case err == nil: + return http.StatusOK + case errors.Is(err, errAdminReadOnly): + return http.StatusConflict + case errors.Is(err, errUnknownHookFile): + return http.StatusNotFound + case strings.Contains(err.Error(), "was not found"): + return http.StatusNotFound + case strings.Contains(err.Error(), "already defined"): + return http.StatusConflict + default: + return http.StatusBadRequest + } +} + +func adminCreateHookHandler(w http.ResponseWriter, r *http.Request) { + var req adminHookMutationRequest + if err := decodeAdminJSON(r, &req); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid hook payload") + return + } + + if err := upsertHookInFile(req.File, "", req.Hook); err != nil { + writeAdminError(w, adminMutationStatus(err), err.Error()) + return + } + + writeAdminJSON(w, http.StatusCreated, map[string]bool{"ok": true}) +} + +func adminUpdateHookHandler(w http.ResponseWriter, r *http.Request) { + var req adminHookMutationRequest + if err := decodeAdminJSON(r, &req); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid hook payload") + return + } + if strings.TrimSpace(req.CurrentID) == "" { + writeAdminError(w, http.StatusBadRequest, "currentId is required") + return + } + + if err := upsertHookInFile(req.File, req.CurrentID, req.Hook); err != nil { + writeAdminError(w, adminMutationStatus(err), err.Error()) + return + } + + writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func adminDeleteHookHandler(w http.ResponseWriter, r *http.Request) { + var req adminHookMutationRequest + if err := decodeAdminJSON(r, &req); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid delete payload") + return + } + if strings.TrimSpace(req.File) == "" { + writeAdminError(w, http.StatusBadRequest, "file is required") + return + } + if strings.TrimSpace(req.CurrentID) == "" { + writeAdminError(w, http.StatusBadRequest, "currentId is required") + return + } + + if err := deleteHookFromFile(req.File, req.CurrentID); err != nil { + writeAdminError(w, adminMutationStatus(err), err.Error()) + return + } + + writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/admin_store.go b/admin_store.go new file mode 100644 index 00000000..f8694657 --- /dev/null +++ b/admin_store.go @@ -0,0 +1,348 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/adnanh/webhook/internal/hook" + "github.com/ghodss/yaml" +) + +var ( + loadedHooksMu sync.RWMutex + + errAdminReadOnly = errors.New("admin writes are disabled when -template is enabled") + errUnknownHookFile = errors.New("unknown hooks file") +) + +type adminHooksFile struct { + Path string `json:"path"` + Format string `json:"format"` + Writable bool `json:"writable"` + Hooks hook.Hooks `json:"hooks"` +} + +type adminHooksState struct { + Files []adminHooksFile `json:"files"` + ReadOnly bool `json:"readOnly"` + ReadOnlyReason string `json:"readOnlyReason,omitempty"` +} + +func adminWritesDisabled() bool { + return *asTemplate +} + +func adminReadOnlyReason() string { + if !adminWritesDisabled() { + return "" + } + + return "editing is disabled while webhook is running with -template because the original template source cannot be preserved safely" +} + +func cloneHook(src hook.Hook) hook.Hook { + dst := src + + if src.ResponseHeaders != nil { + dst.ResponseHeaders = append(hook.ResponseHeaders(nil), src.ResponseHeaders...) + } + if src.PassEnvironmentToCommand != nil { + dst.PassEnvironmentToCommand = append([]hook.Argument(nil), src.PassEnvironmentToCommand...) + } + if src.PassArgumentsToCommand != nil { + dst.PassArgumentsToCommand = append([]hook.Argument(nil), src.PassArgumentsToCommand...) + } + if src.PassFileToCommand != nil { + dst.PassFileToCommand = append([]hook.Argument(nil), src.PassFileToCommand...) + } + if src.JSONStringParameters != nil { + dst.JSONStringParameters = append([]hook.Argument(nil), src.JSONStringParameters...) + } + if src.HTTPMethods != nil { + dst.HTTPMethods = append([]string(nil), src.HTTPMethods...) + } + + return dst +} + +func cloneHooks(src hook.Hooks) hook.Hooks { + if src == nil { + return nil + } + + dst := make(hook.Hooks, len(src)) + for i := range src { + dst[i] = cloneHook(src[i]) + } + + return dst +} + +func cloneLoadedHooksMapLocked() map[string]hook.Hooks { + dst := make(map[string]hook.Hooks, len(loadedHooksFromFiles)) + for path, hooksInFile := range loadedHooksFromFiles { + dst[path] = cloneHooks(hooksInFile) + } + + return dst +} + +func hooksFilesSnapshot() []string { + loadedHooksMu.RLock() + defer loadedHooksMu.RUnlock() + + return append([]string(nil), []string(hooksFiles)...) +} + +func hooksStateSnapshot() adminHooksState { + loadedHooksMu.RLock() + files := append([]string(nil), []string(hooksFiles)...) + loaded := cloneLoadedHooksMapLocked() + loadedHooksMu.RUnlock() + + state := adminHooksState{ + ReadOnly: adminWritesDisabled(), + ReadOnlyReason: adminReadOnlyReason(), + Files: make([]adminHooksFile, 0, len(files)), + } + + for _, path := range files { + hooksInFile := cloneHooks(loaded[path]) + if hooksInFile == nil { + hooksInFile = hook.Hooks{} + } + sort.Slice(hooksInFile, func(i, j int) bool { + return hooksInFile[i].ID < hooksInFile[j].ID + }) + + state.Files = append(state.Files, adminHooksFile{ + Path: path, + Format: hooksFileFormat(path), + Writable: !state.ReadOnly, + Hooks: hooksInFile, + }) + } + + return state +} + +func hooksFileFormat(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".yaml", ".yml": + return "yaml" + default: + return "json" + } +} + +func marshalHooksFile(path string, hooksInFile hook.Hooks) ([]byte, error) { + var ( + data []byte + err error + ) + + switch hooksFileFormat(path) { + case "yaml": + data, err = yaml.Marshal(hooksInFile) + default: + data, err = json.MarshalIndent(hooksInFile, "", " ") + } + if err != nil { + return nil, err + } + + if len(data) == 0 || data[len(data)-1] != '\n' { + data = append(data, '\n') + } + + return data, nil +} + +func writeHooksFile(path string, hooksInFile hook.Hooks) error { + data, err := marshalHooksFile(path, hooksInFile) + if err != nil { + return err + } + + dir := filepath.Dir(path) + if dir == "" { + dir = "." + } + + tmp, err := ioutil.TempFile(dir, filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + + tmpName := tmp.Name() + defer os.Remove(tmpName) + + mode := os.FileMode(0o644) + if stat, statErr := os.Stat(path); statErr == nil { + mode = stat.Mode() + } + + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + return err + } + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + + if err := tmp.Close(); err != nil { + return err + } + + renameErr := os.Rename(tmpName, path) + if renameErr == nil { + return nil + } + + if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) { + return renameErr + } + + return os.Rename(tmpName, path) +} + +func validateUniqueHookIDs(candidate map[string]hook.Hooks) error { + seen := make(map[string]string) + + for path, hooksInFile := range candidate { + for _, currentHook := range hooksInFile { + id := strings.TrimSpace(currentHook.ID) + if id == "" { + return fmt.Errorf("hook id is required for hooks file %s", path) + } + + if prevPath, ok := seen[id]; ok { + return fmt.Errorf("hook with ID %s is already defined in %s", id, prevPath) + } + + seen[id] = path + } + } + + return nil +} + +func hookIndexByID(hooksInFile hook.Hooks, id string) int { + for i := range hooksInFile { + if hooksInFile[i].ID == id { + return i + } + } + + return -1 +} + +func normalizeAdminHook(current hook.Hook) hook.Hook { + current.ID = strings.TrimSpace(current.ID) + current.ExecuteCommand = strings.TrimSpace(current.ExecuteCommand) + current.CommandWorkingDirectory = strings.TrimSpace(current.CommandWorkingDirectory) + current.ResponseMessage = strings.TrimSpace(current.ResponseMessage) + + if len(current.HTTPMethods) != 0 { + methods := make([]string, 0, len(current.HTTPMethods)) + for _, method := range current.HTTPMethods { + method = strings.ToUpper(strings.TrimSpace(method)) + if method != "" { + methods = append(methods, method) + } + } + current.HTTPMethods = methods + } + + return current +} + +func upsertHookInFile(path, currentID string, updated hook.Hook) error { + if adminWritesDisabled() { + return errAdminReadOnly + } + + updated = normalizeAdminHook(updated) + if updated.ID == "" { + return errors.New("hook id is required") + } + + loadedHooksMu.Lock() + defer loadedHooksMu.Unlock() + + existingHooks, ok := loadedHooksFromFiles[path] + if !ok { + return fmt.Errorf("%w: %s", errUnknownHookFile, path) + } + + nextHooks := cloneHooks(existingHooks) + if currentID == "" { + if hookIndexByID(nextHooks, updated.ID) != -1 { + return fmt.Errorf("hook with ID %s is already defined", updated.ID) + } + nextHooks = append(nextHooks, updated) + } else { + index := hookIndexByID(nextHooks, currentID) + if index == -1 { + return fmt.Errorf("hook with ID %s was not found in %s", currentID, path) + } + nextHooks[index] = updated + } + + candidate := cloneLoadedHooksMapLocked() + candidate[path] = nextHooks + + if err := validateUniqueHookIDs(candidate); err != nil { + return err + } + if err := writeHooksFile(path, nextHooks); err != nil { + return err + } + + loadedHooksFromFiles[path] = nextHooks + return nil +} + +func deleteHookFromFile(path, id string) error { + if adminWritesDisabled() { + return errAdminReadOnly + } + + loadedHooksMu.Lock() + defer loadedHooksMu.Unlock() + + existingHooks, ok := loadedHooksFromFiles[path] + if !ok { + return fmt.Errorf("%w: %s", errUnknownHookFile, path) + } + + index := hookIndexByID(existingHooks, id) + if index == -1 { + return fmt.Errorf("hook with ID %s was not found in %s", id, path) + } + + nextHooks := cloneHooks(existingHooks[:index]) + nextHooks = append(nextHooks, cloneHooks(existingHooks[index+1:])...) + + candidate := cloneLoadedHooksMapLocked() + candidate[path] = nextHooks + + if err := validateUniqueHookIDs(candidate); err != nil { + return err + } + if err := writeHooksFile(path, nextHooks); err != nil { + return err + } + + loadedHooksFromFiles[path] = nextHooks + return nil +} diff --git a/admin_test.go b/admin_test.go new file mode 100644 index 00000000..6b0e9271 --- /dev/null +++ b/admin_test.go @@ -0,0 +1,391 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/adnanh/webhook/internal/hook" + "github.com/gorilla/mux" +) + +const testAdminBase32Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + +func setupAdminTestState(t *testing.T) func() { + t.Helper() + + savedSecure := *secure + savedAsTemplate := *asTemplate + savedAdminEnabled := *adminEnabled + savedAdminAuth := currentAdminAuth + + loadedHooksMu.RLock() + savedHooksFiles := append(hook.HooksFiles(nil), hooksFiles...) + savedLoaded := cloneLoadedHooksMapLocked() + loadedHooksMu.RUnlock() + + decodedSecret, err := decodeTOTPSecret(testAdminBase32Secret) + if err != nil { + t.Fatalf("decode TOTP secret: %v", err) + } + + currentAdminAuth = &adminAuthConfig{ + basePath: "/admin", + totpSecret: decodedSecret, + jwtSecret: []byte("test-jwt-secret"), + sessionTTL: time.Hour, + } + + *secure = false + *asTemplate = false + *adminEnabled = false + + loadedHooksMu.Lock() + loadedHooksFromFiles = make(map[string]hook.Hooks) + hooksFiles = nil + loadedHooksMu.Unlock() + + return func() { + currentAdminAuth = savedAdminAuth + *secure = savedSecure + *asTemplate = savedAsTemplate + *adminEnabled = savedAdminEnabled + + loadedHooksMu.Lock() + loadedHooksFromFiles = savedLoaded + hooksFiles = savedHooksFiles + loadedHooksMu.Unlock() + } +} + +func setLoadedHooksForTest(path string, hooksInFile hook.Hooks) { + loadedHooksMu.Lock() + defer loadedHooksMu.Unlock() + + loadedHooksFromFiles = map[string]hook.Hooks{ + path: cloneHooks(hooksInFile), + } + hooksFiles = hook.HooksFiles{path} +} + +func readHooksFileForTest(t *testing.T, path string) hook.Hooks { + t.Helper() + + var hooksInFile hook.Hooks + if err := hooksInFile.LoadFromFile(path, false); err != nil { + t.Fatalf("load hooks file %s: %v", path, err) + } + + return hooksInFile +} + +func loginCookieForTest(t *testing.T) *http.Cookie { + t.Helper() + + code := hotpCode(currentAdminAuth.totpSecret, uint64(time.Now().Unix()/30), 6) + req := httptest.NewRequest(http.MethodPost, "/admin/api/auth/login", strings.NewReader(`{"code":"`+code+`"}`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + adminLoginHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + for _, cookie := range rec.Result().Cookies() { + if cookie.Name == adminTokenCookieName { + return cookie + } + } + + t.Fatal("admin login did not issue session cookie") + return nil +} + +func TestVerifyTOTP(t *testing.T) { + secret, err := decodeTOTPSecret(testAdminBase32Secret) + if err != nil { + t.Fatalf("decode TOTP secret: %v", err) + } + + if got := hotpCode(secret, 1, 6); got != "287082" { + t.Fatalf("hotpCode(...) = %q, want %q", got, "287082") + } + + if !verifyTOTP(secret, "287082", time.Unix(59, 0)) { + t.Fatal("verifyTOTP should accept the RFC test vector") + } + + if verifyTOTP(secret, "287083", time.Unix(59, 0)) { + t.Fatal("verifyTOTP should reject an invalid code") + } +} + +func TestAdminJWTRoundTrip(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + now := time.Unix(1700000000, 0) + token, err := signAdminJWT(now) + if err != nil { + t.Fatalf("signAdminJWT: %v", err) + } + + claims, err := parseAdminJWT(token, now.Add(10*time.Minute)) + if err != nil { + t.Fatalf("parseAdminJWT: %v", err) + } + + if claims.Subject != "webhook-admin" { + t.Fatalf("claims.Subject = %q, want %q", claims.Subject, "webhook-admin") + } + + if _, err := parseAdminJWT(token, now.Add(2*time.Hour)); err == nil { + t.Fatal("parseAdminJWT should reject expired tokens") + } +} + +func TestAdminConfigRequiresAuth(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + handler := adminRequireAuth(adminConfigHandler) + req := httptest.NewRequest(http.MethodGet, "/admin/api/config", nil) + rec := httptest.NewRecorder() + + handler(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} + +func TestAdminCRUDHandlers(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + tmpDir := t.TempDir() + hooksPath := filepath.Join(tmpDir, "hooks.json") + + initialHooks := hook.Hooks{ + { + ID: "deploy", + ExecuteCommand: "/bin/echo", + ResponseMessage: "ok", + HTTPMethods: []string{"POST"}, + }, + } + + if err := writeHooksFile(hooksPath, initialHooks); err != nil { + t.Fatalf("writeHooksFile: %v", err) + } + setLoadedHooksForTest(hooksPath, initialHooks) + + cookie := loginCookieForTest(t) + + configReq := httptest.NewRequest(http.MethodGet, "/admin/api/config", nil) + configReq.AddCookie(cookie) + configRec := httptest.NewRecorder() + adminRequireAuth(adminConfigHandler)(configRec, configReq) + + if configRec.Code != http.StatusOK { + t.Fatalf("config status = %d, want %d", configRec.Code, http.StatusOK) + } + + var config adminHooksState + if err := json.Unmarshal(configRec.Body.Bytes(), &config); err != nil { + t.Fatalf("decode config response: %v", err) + } + if len(config.Files) != 1 || len(config.Files[0].Hooks) != 1 { + t.Fatalf("unexpected config payload: %+v", config) + } + + createReq := httptest.NewRequest(http.MethodPost, "/admin/api/hooks", strings.NewReader(`{ + "file": "`+hooksPath+`", + "hook": { + "id": "build", + "execute-command": "/usr/bin/env", + "response-message": "created", + "http-methods": [" post ", "get"] + } + }`)) + createReq.Header.Set("Content-Type", "application/json") + createReq.AddCookie(cookie) + createRec := httptest.NewRecorder() + adminRequireAuth(adminCreateHookHandler)(createRec, createReq) + + if createRec.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d, body=%s", createRec.Code, http.StatusCreated, createRec.Body.String()) + } + + hooksAfterCreate := readHooksFileForTest(t, hooksPath) + if len(hooksAfterCreate) != 2 { + t.Fatalf("hook count after create = %d, want %d", len(hooksAfterCreate), 2) + } + if hooksAfterCreate.Match("build") == nil { + t.Fatal("expected created hook to be written to file") + } + if got := hooksAfterCreate.Match("build").HTTPMethods; len(got) != 2 || got[0] != "POST" || got[1] != "GET" { + t.Fatalf("created hook methods = %#v, want %#v", got, []string{"POST", "GET"}) + } + + updateReq := httptest.NewRequest(http.MethodPut, "/admin/api/hooks", strings.NewReader(`{ + "file": "`+hooksPath+`", + "currentId": "build", + "hook": { + "id": "build-renamed", + "execute-command": "/bin/echo", + "response-message": "updated", + "http-methods": ["PATCH"] + } + }`)) + updateReq.Header.Set("Content-Type", "application/json") + updateReq.AddCookie(cookie) + updateRec := httptest.NewRecorder() + adminRequireAuth(adminUpdateHookHandler)(updateRec, updateReq) + + if updateRec.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", updateRec.Code, http.StatusOK, updateRec.Body.String()) + } + + hooksAfterUpdate := readHooksFileForTest(t, hooksPath) + if hooksAfterUpdate.Match("build") != nil { + t.Fatal("expected old hook ID to be removed after rename") + } + + renamed := hooksAfterUpdate.Match("build-renamed") + if renamed == nil { + t.Fatal("expected renamed hook to be persisted") + } + if renamed.ResponseMessage != "updated" { + t.Fatalf("updated response message = %q, want %q", renamed.ResponseMessage, "updated") + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/hooks", strings.NewReader(`{ + "file": "`+hooksPath+`", + "currentId": "build-renamed" + }`)) + deleteReq.Header.Set("Content-Type", "application/json") + deleteReq.AddCookie(cookie) + deleteRec := httptest.NewRecorder() + adminRequireAuth(adminDeleteHookHandler)(deleteRec, deleteReq) + + if deleteRec.Code != http.StatusOK { + t.Fatalf("delete status = %d, want %d, body=%s", deleteRec.Code, http.StatusOK, deleteRec.Body.String()) + } + + hooksAfterDelete := readHooksFileForTest(t, hooksPath) + if len(hooksAfterDelete) != 1 { + t.Fatalf("hook count after delete = %d, want %d", len(hooksAfterDelete), 1) + } + if hooksAfterDelete.Match("build-renamed") != nil { + t.Fatal("expected deleted hook to be removed from file") + } +} + +func TestAdminConfigEmptyHooksUsesArray(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + hooksPath := filepath.Join(t.TempDir(), "hooks.json") + setLoadedHooksForTest(hooksPath, nil) + + cookie := loginCookieForTest(t) + req := httptest.NewRequest(http.MethodGet, "/admin/api/config", nil) + req.AddCookie(cookie) + + rec := httptest.NewRecorder() + adminRequireAuth(adminConfigHandler)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("config status = %d, want %d", rec.Code, http.StatusOK) + } + + body := rec.Body.String() + if strings.Contains(body, `"hooks":null`) { + t.Fatalf("config response should not contain null hooks: %s", body) + } + if !strings.Contains(body, `"hooks":[]`) { + t.Fatalf("config response should contain empty hooks array: %s", body) + } +} + +func TestAdminUpdateHookWithSlashIDViaRouter(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + *adminEnabled = true + defer func() { + *adminEnabled = false + }() + + tmpDir := t.TempDir() + hooksPath := filepath.Join(tmpDir, "hooks.json") + + initialHooks := hook.Hooks{ + { + ID: "iot/thjk/web", + ExecuteCommand: "/bin/echo", + ResponseMessage: "ok", + HTTPMethods: []string{"POST"}, + }, + } + + if err := writeHooksFile(hooksPath, initialHooks); err != nil { + t.Fatalf("writeHooksFile: %v", err) + } + setLoadedHooksForTest(hooksPath, initialHooks) + + router := mux.NewRouter() + registerAdminRoutes(router) + + cookie := loginCookieForTest(t) + req := httptest.NewRequest(http.MethodPut, "/admin/api/hooks", strings.NewReader(`{ + "file": "`+hooksPath+`", + "currentId": "iot/thjk/web", + "hook": { + "id": "iot/thjk/web", + "execute-command": "/usr/bin/env", + "response-message": "updated slash id" + } + }`)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(cookie) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("slash id update status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updatedHooks := readHooksFileForTest(t, hooksPath) + updated := updatedHooks.Match("iot/thjk/web") + if updated == nil { + t.Fatal("expected slash-id hook to still exist after update") + } + if updated.ExecuteCommand != "/usr/bin/env" { + t.Fatalf("updated execute-command = %q, want %q", updated.ExecuteCommand, "/usr/bin/env") + } +} + +func TestAdminWritesBlockedInTemplateMode(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + tmpDir := t.TempDir() + hooksPath := filepath.Join(tmpDir, "hooks.json") + setLoadedHooksForTest(hooksPath, hook.Hooks{}) + + *asTemplate = true + + err := upsertHookInFile(hooksPath, "", hook.Hook{ID: "blocked"}) + if err == nil || !strings.Contains(err.Error(), errAdminReadOnly.Error()) { + t.Fatalf("upsertHookInFile error = %v, want %v", err, errAdminReadOnly) + } +} diff --git a/admin_ui.go b/admin_ui.go new file mode 100644 index 00000000..45b8032d --- /dev/null +++ b/admin_ui.go @@ -0,0 +1,41 @@ +package main + +import ( + "embed" + "html/template" + "io/fs" + "net/http" +) + +//go:embed adminui/index.html adminui/assets/* +var adminUIFiles embed.FS + +var ( + adminIndexTemplate = template.Must(template.ParseFS(adminUIFiles, "adminui/index.html")) + adminStaticFS = mustAdminSub(adminUIFiles, "adminui") +) + +type adminUIData struct { + BasePath string +} + +func mustAdminSub(fsys fs.FS, dir string) fs.FS { + sub, err := fs.Sub(fsys, dir) + if err != nil { + panic(err) + } + + return sub +} + +func adminUIHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + if err := adminIndexTemplate.Execute(w, adminUIData{BasePath: currentAdminAuth.basePath}); err != nil { + http.Error(w, "admin UI render failed", http.StatusInternalServerError) + } +} + +func adminStaticHandler() http.Handler { + return http.FileServer(http.FS(adminStaticFS)) +} diff --git a/adminui/assets/app.js b/adminui/assets/app.js new file mode 100644 index 00000000..de7010d5 --- /dev/null +++ b/adminui/assets/app.js @@ -0,0 +1,1254 @@ +(function () { + "use strict"; + + var adminConfig = window.__WEBHOOK_ADMIN__ || {}; + var basePath = adminConfig.basePath || ""; + + var sourceOptions = [ + { value: "", label: "Select source" }, + { value: "header", label: "Header" }, + { value: "url", label: "URL Query" }, + { value: "query", label: "Query Alias" }, + { value: "payload", label: "Payload" }, + { value: "raw-request-body", label: "Raw Request Body" }, + { value: "request", label: "Request" }, + { value: "string", label: "Static String" }, + { value: "entire-payload", label: "Entire Payload" }, + { value: "entire-query", label: "Entire Query" }, + { value: "entire-headers", label: "Entire Headers" } + ]; + + var matchTypeOptions = [ + { value: "value", label: "Value Equals" }, + { value: "regex", label: "Regex Match" }, + { value: "payload-hmac-sha1", label: "Payload HMAC SHA1" }, + { value: "payload-hmac-sha256", label: "Payload HMAC SHA256" }, + { value: "payload-hmac-sha512", label: "Payload HMAC SHA512" }, + { value: "payload-hash-sha1", label: "Payload Hash SHA1 (Deprecated)" }, + { value: "payload-hash-sha256", label: "Payload Hash SHA256 (Deprecated)" }, + { value: "payload-hash-sha512", label: "Payload Hash SHA512 (Deprecated)" }, + { value: "ip-whitelist", label: "IP Whitelist" }, + { value: "scalr-signature", label: "Scalr Signature" } + ]; + + var state = { + files: [], + currentFile: "", + currentHookId: "", + readOnly: false, + readOnlyReason: "", + ruleDraft: null + }; + + function byId(id) { + return document.getElementById(id); + } + + var els = { + loginPanel: byId("loginPanel"), + workspace: byId("workspace"), + loginForm: byId("loginForm"), + totpCode: byId("totpCode"), + loginStatus: byId("loginStatus"), + fileSelect: byId("fileSelect"), + fileMeta: byId("fileMeta"), + hookList: byId("hookList"), + refreshBtn: byId("refreshBtn"), + newHookBtn: byId("newHookBtn"), + logoutBtn: byId("logoutBtn"), + readOnlyBanner: byId("readOnlyBanner"), + editorFieldset: byId("editorFieldset"), + hookForm: byId("hookForm"), + hookId: byId("hookId"), + executeCommand: byId("executeCommand"), + commandWorkingDirectory: byId("commandWorkingDirectory"), + httpMethods: byId("httpMethods"), + responseMessage: byId("responseMessage"), + incomingPayloadContentType: byId("incomingPayloadContentType"), + successHttpResponseCode: byId("successHttpResponseCode"), + triggerRuleMismatchHttpResponseCode: byId("triggerRuleMismatchHttpResponseCode"), + captureCommandOutput: byId("captureCommandOutput"), + captureCommandOutputOnError: byId("captureCommandOutputOnError"), + triggerSignatureSoftFailures: byId("triggerSignatureSoftFailures"), + keepFileEnvironment: byId("keepFileEnvironment"), + responseHeadersRows: byId("responseHeadersRows"), + passArgumentsRows: byId("passArgumentsRows"), + passEnvironmentRows: byId("passEnvironmentRows"), + passFileRows: byId("passFileRows"), + parseJSONRows: byId("parseJSONRows"), + addResponseHeaderBtn: byId("addResponseHeaderBtn"), + addPassArgumentBtn: byId("addPassArgumentBtn"), + addPassEnvironmentBtn: byId("addPassEnvironmentBtn"), + addPassFileBtn: byId("addPassFileBtn"), + addParseJSONBtn: byId("addParseJSONBtn"), + ruleEditor: byId("ruleEditor"), + saveBtn: byId("saveBtn"), + deleteBtn: byId("deleteBtn"), + jsonPreview: byId("jsonPreview"), + workspaceStatus: byId("workspaceStatus") + }; + + var collectionDefs = { + responseHeaders: { + jsonKey: "response-headers", + title: "response headers", + emptyText: "暂无响应头。", + container: els.responseHeadersRows, + addButton: els.addResponseHeaderBtn, + fields: [ + { name: "name", label: "Name", type: "text", required: true }, + { name: "value", label: "Value", type: "text" } + ] + }, + passArguments: { + jsonKey: "pass-arguments-to-command", + title: "command arguments", + emptyText: "暂无命令参数。", + container: els.passArgumentsRows, + addButton: els.addPassArgumentBtn, + fields: [ + { name: "source", label: "Source", type: "select", options: sourceOptions, required: true }, + { name: "name", label: "Name", type: "text", required: true } + ] + }, + passEnvironment: { + jsonKey: "pass-environment-to-command", + title: "environment mappings", + emptyText: "暂无环境变量映射。", + container: els.passEnvironmentRows, + addButton: els.addPassEnvironmentBtn, + fields: [ + { name: "source", label: "Source", type: "select", options: sourceOptions, required: true }, + { name: "name", label: "Name", type: "text", required: true }, + { name: "envname", label: "Env Name", type: "text" } + ] + }, + passFile: { + jsonKey: "pass-file-to-command", + title: "file mappings", + emptyText: "暂无文件参数。", + container: els.passFileRows, + addButton: els.addPassFileBtn, + fields: [ + { name: "source", label: "Source", type: "select", options: sourceOptions, required: true }, + { name: "name", label: "Name", type: "text", required: true }, + { name: "envname", label: "Env Name", type: "text" }, + { name: "base64decode", label: "Base64", type: "checkbox" } + ] + }, + parseJSON: { + jsonKey: "parse-parameters-as-json", + title: "JSON parameter mappings", + emptyText: "暂无 JSON 参数映射。", + container: els.parseJSONRows, + addButton: els.addParseJSONBtn, + fields: [ + { name: "source", label: "Source", type: "select", options: sourceOptions, required: true }, + { name: "name", label: "Name", type: "text", required: true } + ] + } + }; + + function clone(value) { + if (value === null || value === undefined) { + return value; + } + + return JSON.parse(JSON.stringify(value)); + } + + function normalizeArray(value) { + return Array.isArray(value) ? value : []; + } + + function normalizeHookFile(file) { + var next = clone(file) || {}; + next.hooks = normalizeArray(next.hooks); + return next; + } + + function normalizeHook(hook) { + var next = clone(hook) || {}; + next["response-headers"] = normalizeArray(next["response-headers"]); + next["pass-arguments-to-command"] = normalizeArray(next["pass-arguments-to-command"]); + next["pass-environment-to-command"] = normalizeArray(next["pass-environment-to-command"]); + next["pass-file-to-command"] = normalizeArray(next["pass-file-to-command"]); + next["parse-parameters-as-json"] = normalizeArray(next["parse-parameters-as-json"]); + next["http-methods"] = normalizeArray(next["http-methods"]); + return next; + } + + function emptyHook() { + return normalizeHook({ + id: "", + "execute-command": "", + "command-working-directory": "", + "response-message": "", + "http-methods": ["POST"] + }); + } + + function splitCSV(value) { + return String(value || "") + .split(",") + .map(function (item) { + return item.trim().toUpperCase(); + }) + .filter(function (item) { + return item !== ""; + }); + } + + function setStatus(target, message, tone) { + target.textContent = message; + target.className = "status " + (tone ? "status-" + tone : "status-muted"); + } + + function showLogin(message) { + els.workspace.hidden = true; + els.loginPanel.hidden = false; + setStatus(els.loginStatus, message || "请输入 TOTP 动态码。", message ? "error" : "muted"); + } + + function showWorkspace() { + els.loginPanel.hidden = true; + els.workspace.hidden = false; + } + + function request(path, options) { + var fetchOptions = options || {}; + fetchOptions.credentials = "same-origin"; + fetchOptions.headers = fetchOptions.headers || {}; + fetchOptions.headers.Accept = "application/json"; + + return fetch(basePath + path, fetchOptions).then(function (response) { + return response.text().then(function (text) { + var data = null; + if (text) { + try { + data = JSON.parse(text); + } catch (error) { + data = null; + } + } + + if (response.status === 401) { + showLogin("会话已失效,请重新登录。"); + throw new Error("unauthorized"); + } + + if (!response.ok) { + throw new Error(data && data.error ? data.error : "请求失败。"); + } + + return data; + }); + }); + } + + function getCurrentFile() { + for (var i = 0; i < state.files.length; i++) { + if (state.files[i].path === state.currentFile) { + return state.files[i]; + } + } + + return null; + } + + function getCurrentHook() { + var currentFile = getCurrentFile(); + var hooks = currentFile ? normalizeArray(currentFile.hooks) : []; + + for (var i = 0; i < hooks.length; i++) { + if (hooks[i].id === state.currentHookId) { + return hooks[i]; + } + } + + return null; + } + + function createOption(option, selectedValue) { + var el = document.createElement("option"); + el.value = option.value; + el.textContent = option.label; + if (option.value === selectedValue) { + el.selected = true; + } + return el; + } + + function inputValue(el) { + return String(el.value || "").trim(); + } + + function appendEmptyState(container, text) { + var empty = document.createElement("div"); + empty.className = "repeatable-empty"; + empty.textContent = text; + container.appendChild(empty); + } + + function clearEmptyState(container) { + var empty = container.querySelector(".repeatable-empty"); + if (empty) { + empty.remove(); + } + } + + function ensureCollectionState(def) { + if (!def.container.querySelector(".repeatable-row")) { + def.container.innerHTML = ""; + appendEmptyState(def.container, def.emptyText); + } + } + + function createRowField(field, value) { + var wrapper = document.createElement("div"); + + if (field.type === "checkbox") { + wrapper.className = "row-check"; + + var checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = !!value; + checkbox.dataset.field = field.name; + checkbox.title = field.label; + checkbox.setAttribute("aria-label", field.label); + checkbox.addEventListener("change", updatePreview); + wrapper.appendChild(checkbox); + return wrapper; + } + + var label = document.createElement("span"); + label.className = "field-inline-label"; + label.textContent = field.label; + wrapper.appendChild(label); + + var input; + if (field.type === "select") { + input = document.createElement("select"); + field.options.forEach(function (option) { + input.appendChild(createOption(option, value || "")); + }); + } else { + input = document.createElement("input"); + input.type = "text"; + input.value = value || ""; + } + + input.dataset.field = field.name; + input.addEventListener("input", updatePreview); + input.addEventListener("change", updatePreview); + wrapper.appendChild(input); + + return wrapper; + } + + function appendCollectionRow(def, rowData) { + clearEmptyState(def.container); + + var row = document.createElement("div"); + row.className = "repeatable-row columns-" + def.fields.length; + + def.fields.forEach(function (field) { + row.appendChild(createRowField(field, rowData ? rowData[field.name] : "")); + }); + + var remove = document.createElement("button"); + remove.type = "button"; + remove.className = "icon-button"; + remove.textContent = "×"; + remove.title = "删除这一行"; + remove.addEventListener("click", function () { + row.remove(); + ensureCollectionState(def); + updatePreview(); + }); + row.appendChild(remove); + + def.container.appendChild(row); + } + + function renderCollection(def, rows) { + def.container.innerHTML = ""; + + var safeRows = normalizeArray(rows); + if (!safeRows.length) { + appendEmptyState(def.container, def.emptyText); + return; + } + + safeRows.forEach(function (row) { + appendCollectionRow(def, row); + }); + } + + function readCollection(def, errors) { + var items = []; + var rows = def.container.querySelectorAll(".repeatable-row"); + + rows.forEach(function (row, index) { + var item = {}; + var used = false; + + def.fields.forEach(function (field) { + var input = row.querySelector("[data-field='" + field.name + "']"); + if (!input) { + return; + } + + if (field.type === "checkbox") { + if (input.checked) { + item[field.name] = true; + used = true; + } + return; + } + + var value = String(input.value || "").trim(); + if (value !== "") { + item[field.name] = value; + used = true; + } + }); + + if (!used) { + return; + } + + def.fields.forEach(function (field) { + if (!field.required || field.type === "checkbox") { + return; + } + + if (!item[field.name]) { + errors.push("Incomplete " + def.title + " row " + (index + 1) + ": " + field.label + " is required."); + } + }); + + items.push(item); + }); + + return items; + } + + function ruleKind(rule) { + if (!rule) { + return "none"; + } + if (Array.isArray(rule.and)) { + return "and"; + } + if (Array.isArray(rule.or)) { + return "or"; + } + if (rule.not) { + return "not"; + } + if (rule.match) { + return "match"; + } + return "none"; + } + + function createRule(kind) { + switch (kind) { + case "and": + return { and: [createRule("match")] }; + case "or": + return { or: [createRule("match")] }; + case "not": + return { not: createRule("match") }; + case "match": + return { + match: { + type: "value", + parameter: { + source: "", + name: "" + }, + value: "" + } + }; + default: + return null; + } + } + + function ensureMatch(rule) { + if (!rule.match) { + rule.match = createRule("match").match; + } + if (!rule.match.parameter) { + rule.match.parameter = { source: "", name: "" }; + } + return rule.match; + } + + function isSignatureRule(type) { + return ( + type === "payload-hmac-sha1" || + type === "payload-hmac-sha256" || + type === "payload-hmac-sha512" || + type === "payload-hash-sha1" || + type === "payload-hash-sha256" || + type === "payload-hash-sha512" + ); + } + + function needsParameter(type) { + return type !== "ip-whitelist" && type !== "scalr-signature"; + } + + function createLabeledField(labelText, control) { + var wrapper = document.createElement("div"); + var label = document.createElement("label"); + label.textContent = labelText; + wrapper.appendChild(label); + wrapper.appendChild(control); + return wrapper; + } + + function createTextInput(value, placeholder, onChange) { + var input = document.createElement("input"); + input.type = "text"; + input.value = value || ""; + input.placeholder = placeholder || ""; + input.addEventListener("input", function () { + onChange(input.value); + updatePreview(); + }); + return input; + } + + function createSelectInput(options, value, onChange) { + var select = document.createElement("select"); + options.forEach(function (option) { + select.appendChild(createOption(option, value)); + }); + select.addEventListener("change", function () { + onChange(select.value); + }); + return select; + } + + function renderMatchRule(card, rule) { + var match = ensureMatch(rule); + var fields = document.createElement("div"); + fields.className = "grid grid-2"; + + var typeSelect = createSelectInput(matchTypeOptions, match.type || "value", function (nextType) { + match.type = nextType; + renderRuleEditor(); + updatePreview(); + }); + fields.appendChild(createLabeledField("Match Type", typeSelect)); + + if (needsParameter(match.type)) { + var sourceSelect = createSelectInput(sourceOptions, match.parameter ? match.parameter.source : "", function (nextSource) { + ensureMatch(rule).parameter.source = nextSource; + updatePreview(); + }); + fields.appendChild(createLabeledField("Parameter Source", sourceSelect)); + + var nameInput = createTextInput(match.parameter ? match.parameter.name : "", "e.g. X-Hub-Signature-256", function (nextName) { + ensureMatch(rule).parameter.name = nextName; + }); + fields.appendChild(createLabeledField("Parameter Name", nameInput)); + } + + if (match.type === "value") { + fields.appendChild(createLabeledField("Expected Value", createTextInput(match.value, "main", function (nextValue) { + ensureMatch(rule).value = nextValue; + }))); + } + + if (match.type === "regex") { + fields.appendChild(createLabeledField("Regex", createTextInput(match.regex, "^refs/heads/main$", function (nextRegex) { + ensureMatch(rule).regex = nextRegex; + }))); + } + + if (isSignatureRule(match.type) || match.type === "scalr-signature") { + fields.appendChild(createLabeledField("Secret", createTextInput(match.secret, "shared secret", function (nextSecret) { + ensureMatch(rule).secret = nextSecret; + }))); + } + + if (match.type === "ip-whitelist") { + fields.appendChild(createLabeledField("IP Range", createTextInput(match["ip-range"] || match.ipRange, "192.168.1.0/24 10.0.0.1", function (nextRange) { + ensureMatch(rule)["ip-range"] = nextRange; + }))); + } + + card.appendChild(fields); + } + + function renderRuleCard(rule, depth, replaceRule, removeRule, isRoot) { + var card = document.createElement("div"); + card.className = "rule-card"; + card.dataset.depth = String(Math.min(depth, 3)); + + var head = document.createElement("div"); + head.className = "rule-head"; + + var title = document.createElement("strong"); + title.textContent = isRoot ? "Root Rule" : "Rule"; + head.appendChild(title); + + var controls = document.createElement("div"); + controls.className = "toolbar-row"; + + var kindSelect = createSelectInput( + [ + { value: "none", label: "No Rule" }, + { value: "match", label: "Match" }, + { value: "and", label: "And" }, + { value: "or", label: "Or" }, + { value: "not", label: "Not" } + ], + ruleKind(rule), + function (nextKind) { + replaceRule(createRule(nextKind)); + renderRuleEditor(); + updatePreview(); + } + ); + controls.appendChild(kindSelect); + + if (removeRule) { + var remove = document.createElement("button"); + remove.type = "button"; + remove.className = "icon-button"; + remove.textContent = "×"; + remove.title = "删除这一条规则"; + remove.addEventListener("click", function () { + removeRule(); + renderRuleEditor(); + updatePreview(); + }); + controls.appendChild(remove); + } + + head.appendChild(controls); + card.appendChild(head); + + var kind = ruleKind(rule); + if (kind === "none") { + var note = document.createElement("div"); + note.className = "rule-note"; + note.textContent = "当前没有启用触发规则。"; + card.appendChild(note); + return card; + } + + if (kind === "match") { + renderMatchRule(card, rule); + return card; + } + + if (kind === "not") { + if (!rule.not) { + rule.not = createRule("match"); + } + + var single = document.createElement("div"); + single.className = "rule-children"; + single.appendChild( + renderRuleCard( + rule.not, + depth + 1, + function (nextRule) { + rule.not = nextRule; + }, + null, + false + ) + ); + card.appendChild(single); + return card; + } + + var listKey = kind; + if (!Array.isArray(rule[listKey])) { + rule[listKey] = []; + } + + var children = document.createElement("div"); + children.className = "rule-children"; + + if (!rule[listKey].length) { + var empty = document.createElement("div"); + empty.className = "rule-note"; + empty.textContent = "当前没有子规则。"; + children.appendChild(empty); + } else { + rule[listKey].forEach(function (childRule, index) { + children.appendChild( + renderRuleCard( + childRule, + depth + 1, + function (nextRule) { + rule[listKey][index] = nextRule; + }, + function () { + rule[listKey].splice(index, 1); + }, + false + ) + ); + }); + } + + card.appendChild(children); + + var addChild = document.createElement("button"); + addChild.type = "button"; + addChild.className = "ghost"; + addChild.textContent = "添加子规则"; + addChild.addEventListener("click", function () { + rule[listKey].push(createRule("match")); + renderRuleEditor(); + updatePreview(); + }); + card.appendChild(addChild); + + return card; + } + + function renderRuleEditor() { + els.ruleEditor.innerHTML = ""; + els.ruleEditor.appendChild( + renderRuleCard( + state.ruleDraft, + 0, + function (nextRule) { + state.ruleDraft = nextRule; + }, + null, + true + ) + ); + } + + function setFormValue(el, value) { + el.value = value || ""; + } + + function renderCurrentHook() { + var hook = normalizeHook(getCurrentHook() || emptyHook()); + + setFormValue(els.hookId, hook.id); + setFormValue(els.executeCommand, hook["execute-command"]); + setFormValue(els.commandWorkingDirectory, hook["command-working-directory"]); + setFormValue(els.httpMethods, normalizeArray(hook["http-methods"]).join(", ")); + setFormValue(els.responseMessage, hook["response-message"]); + setFormValue(els.incomingPayloadContentType, hook["incoming-payload-content-type"]); + setFormValue(els.successHttpResponseCode, hook["success-http-response-code"] || ""); + setFormValue(els.triggerRuleMismatchHttpResponseCode, hook["trigger-rule-mismatch-http-response-code"] || ""); + + els.captureCommandOutput.checked = !!hook["include-command-output-in-response"]; + els.captureCommandOutputOnError.checked = !!hook["include-command-output-in-response-on-error"]; + els.triggerSignatureSoftFailures.checked = !!hook["trigger-signature-soft-failures"]; + els.keepFileEnvironment.checked = !!hook["keep-file-environment"]; + + renderCollection(collectionDefs.responseHeaders, hook["response-headers"]); + renderCollection(collectionDefs.passArguments, hook["pass-arguments-to-command"]); + renderCollection(collectionDefs.passEnvironment, hook["pass-environment-to-command"]); + renderCollection(collectionDefs.passFile, hook["pass-file-to-command"]); + renderCollection(collectionDefs.parseJSON, hook["parse-parameters-as-json"]); + + state.ruleDraft = clone(hook["trigger-rule"]) || null; + renderRuleEditor(); + updatePreview(); + } + + function renderFileOptions() { + els.fileSelect.innerHTML = ""; + els.fileSelect.disabled = state.files.length === 0; + + if (!state.files.length) { + var empty = document.createElement("option"); + empty.value = ""; + empty.textContent = "No hooks file"; + els.fileSelect.appendChild(empty); + return; + } + + state.files.forEach(function (file) { + var option = document.createElement("option"); + option.value = file.path; + option.textContent = file.path; + if (file.path === state.currentFile) { + option.selected = true; + } + els.fileSelect.appendChild(option); + }); + } + + function renderFileMeta() { + var currentFile = getCurrentFile(); + if (!currentFile) { + els.fileMeta.textContent = "暂无可管理的 hooks 文件。"; + return; + } + + els.fileMeta.textContent = + "格式: " + + String(currentFile.format || "json").toUpperCase() + + " | hooks: " + + normalizeArray(currentFile.hooks).length + + " | 路径: " + + currentFile.path; + } + + function renderHookList() { + els.hookList.innerHTML = ""; + + var currentFile = getCurrentFile(); + var hooks = currentFile ? normalizeArray(currentFile.hooks) : []; + + if (!hooks.length) { + appendEmptyState(els.hookList, "当前文件还没有 hook,可以直接点击“新建”。"); + return; + } + + hooks.forEach(function (hookItem) { + var button = document.createElement("button"); + button.type = "button"; + button.className = "hook-item" + (hookItem.id === state.currentHookId ? " active" : ""); + button.dataset.id = hookItem.id; + + var methods = normalizeArray(hookItem["http-methods"]).length ? hookItem["http-methods"].join(", ") : "ALL"; + var command = hookItem["execute-command"] || "(no execute-command)"; + + var title = document.createElement("strong"); + title.textContent = hookItem.id || "(no id)"; + button.appendChild(title); + + var subtitle = document.createElement("small"); + subtitle.textContent = methods + " · " + command; + button.appendChild(subtitle); + + button.addEventListener("click", function () { + state.currentHookId = hookItem.id; + renderHookList(); + renderCurrentHook(); + setStatus(els.workspaceStatus, "已载入 hook “" + hookItem.id + "”。", "muted"); + }); + + els.hookList.appendChild(button); + }); + } + + function applyReadOnlyState() { + var hasFiles = state.files.length > 0; + var canEdit = hasFiles && !state.readOnly; + + if (state.readOnly) { + els.readOnlyBanner.hidden = false; + els.readOnlyBanner.textContent = state.readOnlyReason || "当前运行模式为只读。"; + } else { + els.readOnlyBanner.hidden = true; + els.readOnlyBanner.textContent = ""; + } + + els.editorFieldset.disabled = !canEdit; + els.newHookBtn.disabled = !canEdit; + els.saveBtn.disabled = !canEdit; + els.deleteBtn.disabled = !canEdit || !state.currentHookId; + } + + function renderAll() { + renderFileOptions(); + renderFileMeta(); + renderHookList(); + renderCurrentHook(); + applyReadOnlyState(); + } + + function parseOptionalInt(value, label, errors) { + var trimmed = String(value || "").trim(); + if (trimmed === "") { + return null; + } + + var parsed = parseInt(trimmed, 10); + if (isNaN(parsed)) { + errors.push(label + " must be a valid integer."); + return null; + } + + return parsed; + } + + function cleanRule(rule, errors, pathLabel) { + var kind = ruleKind(rule); + if (kind === "none") { + return null; + } + + if (kind === "match") { + var match = clone((rule && rule.match) || {}); + var type = match.type || "value"; + var cleanedMatch = { type: type }; + + if (needsParameter(type)) { + var parameter = clone(match.parameter) || {}; + parameter.source = String(parameter.source || "").trim(); + parameter.name = String(parameter.name || "").trim(); + if (!parameter.source || !parameter.name) { + errors.push(pathLabel + " requires parameter source and parameter name."); + } else { + cleanedMatch.parameter = parameter; + } + } + + if (type === "value") { + var expectedValue = String(match.value || "").trim(); + if (!expectedValue) { + errors.push(pathLabel + " requires a comparison value."); + } else { + cleanedMatch.value = expectedValue; + } + } else if (type === "regex") { + var regex = String(match.regex || "").trim(); + if (!regex) { + errors.push(pathLabel + " requires a regex."); + } else { + cleanedMatch.regex = regex; + } + } else if (isSignatureRule(type) || type === "scalr-signature") { + var secret = String(match.secret || "").trim(); + if (!secret) { + errors.push(pathLabel + " requires a secret."); + } else { + cleanedMatch.secret = secret; + } + } else if (type === "ip-whitelist") { + var ipRange = String(match["ip-range"] || match.ipRange || "").trim(); + if (!ipRange) { + errors.push(pathLabel + " requires an IP range."); + } else { + cleanedMatch["ip-range"] = ipRange; + } + } + + return { match: cleanedMatch }; + } + + if (kind === "not") { + var childRule = cleanRule(rule.not, errors, pathLabel + " > not"); + if (!childRule) { + errors.push(pathLabel + " requires a nested rule."); + return null; + } + return { not: childRule }; + } + + var key = kind; + var children = normalizeArray(rule[key]).map(function (child, index) { + return cleanRule(child, errors, pathLabel + " > child " + (index + 1)); + }).filter(function (child) { + return !!child; + }); + + if (!children.length) { + errors.push(pathLabel + " requires at least one child rule."); + return null; + } + + var composite = {}; + composite[key] = children; + return composite; + } + + function buildHookFromForm() { + var errors = []; + var hook = {}; + + var id = inputValue(els.hookId); + if (!id) { + errors.push("Hook ID is required."); + } else { + hook.id = id; + } + + var executeCommand = inputValue(els.executeCommand); + if (!executeCommand) { + errors.push("Execute Command is required."); + } else { + hook["execute-command"] = executeCommand; + } + + var workingDirectory = inputValue(els.commandWorkingDirectory); + if (workingDirectory) { + hook["command-working-directory"] = workingDirectory; + } + + var methods = splitCSV(els.httpMethods.value); + if (methods.length) { + hook["http-methods"] = methods; + } + + var responseMessage = inputValue(els.responseMessage); + if (responseMessage) { + hook["response-message"] = responseMessage; + } + + var contentType = inputValue(els.incomingPayloadContentType); + if (contentType) { + hook["incoming-payload-content-type"] = contentType; + } + + var successCode = parseOptionalInt(els.successHttpResponseCode.value, "Success HTTP Code", errors); + if (successCode !== null) { + hook["success-http-response-code"] = successCode; + } + + var mismatchCode = parseOptionalInt(els.triggerRuleMismatchHttpResponseCode.value, "Rule Mismatch HTTP Code", errors); + if (mismatchCode !== null) { + hook["trigger-rule-mismatch-http-response-code"] = mismatchCode; + } + + if (els.captureCommandOutput.checked) { + hook["include-command-output-in-response"] = true; + } + if (els.captureCommandOutputOnError.checked) { + hook["include-command-output-in-response-on-error"] = true; + } + if (els.triggerSignatureSoftFailures.checked) { + hook["trigger-signature-soft-failures"] = true; + } + if (els.keepFileEnvironment.checked) { + hook["keep-file-environment"] = true; + } + + Object.keys(collectionDefs).forEach(function (key) { + var def = collectionDefs[key]; + var items = readCollection(def, errors); + if (items.length) { + hook[def.jsonKey] = items; + } + }); + + var cleanedRule = cleanRule(state.ruleDraft, errors, "Trigger rule"); + if (cleanedRule) { + hook["trigger-rule"] = cleanedRule; + } + + return { + hook: hook, + errors: errors + }; + } + + function updatePreview() { + var result = buildHookFromForm(); + var preview = JSON.stringify(result.hook, null, 2); + + if (result.errors.length) { + els.jsonPreview.textContent = "// Validation issues\n// " + result.errors.join("\n// ") + "\n\n" + preview; + return; + } + + els.jsonPreview.textContent = preview; + } + + function loadConfig(preferredHookId) { + return request("/api/config").then(function (data) { + state.files = normalizeArray(data && data.files).map(normalizeHookFile); + state.readOnly = !!(data && data.readOnly); + state.readOnlyReason = (data && data.readOnlyReason) || ""; + + if (!state.files.length) { + state.currentFile = ""; + state.currentHookId = ""; + } else { + var fileExists = false; + state.files.forEach(function (file) { + if (file.path === state.currentFile) { + fileExists = true; + } + }); + + if (!fileExists) { + state.currentFile = state.files[0].path; + } + + var currentFile = getCurrentFile(); + var hooks = currentFile ? normalizeArray(currentFile.hooks) : []; + var desiredHookId = preferredHookId || state.currentHookId; + var hookExists = false; + + hooks.forEach(function (hookItem) { + if (hookItem.id === desiredHookId) { + hookExists = true; + } + }); + + if (hookExists) { + state.currentHookId = desiredHookId; + } else { + state.currentHookId = hooks.length ? hooks[0].id : ""; + } + } + + showWorkspace(); + renderAll(); + setStatus(els.workspaceStatus, "配置已同步。", "success"); + }); + } + + function handleSave() { + if (!state.currentFile) { + setStatus(els.workspaceStatus, "当前没有可写入的 hooks 文件。", "error"); + return; + } + + var result = buildHookFromForm(); + if (result.errors.length) { + setStatus(els.workspaceStatus, result.errors[0], "error"); + return; + } + + var isUpdate = !!state.currentHookId; + var requestPath = "/api/hooks"; + var method = isUpdate ? "PUT" : "POST"; + var payload = { + file: state.currentFile, + hook: result.hook + }; + + if (isUpdate) { + payload.currentId = state.currentHookId; + } + + request(requestPath, { + method: method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }).then(function () { + state.currentHookId = result.hook.id || ""; + return loadConfig(state.currentHookId); + }).then(function () { + setStatus(els.workspaceStatus, "保存成功。", "success"); + }).catch(function (error) { + setStatus(els.workspaceStatus, error.message || "保存失败。", "error"); + }); + } + + function handleDelete() { + if (!state.currentHookId) { + setStatus(els.workspaceStatus, "当前没有选中的 hook。", "error"); + return; + } + + if (!window.confirm("确认删除 hook “" + state.currentHookId + "” 吗?")) { + return; + } + + request("/api/hooks", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file: state.currentFile, + currentId: state.currentHookId + }) + }).then(function () { + state.currentHookId = ""; + return loadConfig(); + }).then(function () { + setStatus(els.workspaceStatus, "删除成功。", "success"); + }).catch(function (error) { + setStatus(els.workspaceStatus, error.message || "删除失败。", "error"); + }); + } + + function bindBaseFormEvents() { + [ + els.hookId, + els.executeCommand, + els.commandWorkingDirectory, + els.httpMethods, + els.responseMessage, + els.incomingPayloadContentType, + els.successHttpResponseCode, + els.triggerRuleMismatchHttpResponseCode + ].forEach(function (input) { + input.addEventListener("input", updatePreview); + }); + + [ + els.captureCommandOutput, + els.captureCommandOutputOnError, + els.triggerSignatureSoftFailures, + els.keepFileEnvironment + ].forEach(function (checkbox) { + checkbox.addEventListener("change", updatePreview); + }); + + Object.keys(collectionDefs).forEach(function (key) { + var def = collectionDefs[key]; + def.addButton.addEventListener("click", function () { + appendCollectionRow(def, {}); + updatePreview(); + }); + }); + } + + els.loginForm.addEventListener("submit", function (event) { + event.preventDefault(); + + request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: els.totpCode.value }) + }).then(function () { + els.totpCode.value = ""; + return loadConfig(); + }).catch(function (error) { + setStatus(els.loginStatus, error.message || "登录失败。", "error"); + }); + }); + + els.fileSelect.addEventListener("change", function () { + state.currentFile = els.fileSelect.value; + var currentFile = getCurrentFile(); + var hooks = currentFile ? normalizeArray(currentFile.hooks) : []; + state.currentHookId = hooks.length ? hooks[0].id : ""; + renderAll(); + setStatus(els.workspaceStatus, "已切换文件。", "muted"); + }); + + els.refreshBtn.addEventListener("click", function () { + loadConfig(state.currentHookId).catch(function (error) { + setStatus(els.workspaceStatus, error.message || "刷新失败。", "error"); + }); + }); + + els.newHookBtn.addEventListener("click", function () { + state.currentHookId = ""; + renderHookList(); + renderCurrentHook(); + applyReadOnlyState(); + setStatus(els.workspaceStatus, "正在创建新 hook。", "muted"); + }); + + els.saveBtn.addEventListener("click", handleSave); + els.deleteBtn.addEventListener("click", handleDelete); + + els.logoutBtn.addEventListener("click", function () { + request("/api/auth/logout", { method: "POST" }).finally(function () { + showLogin("已退出管理员会话。"); + }); + }); + + bindBaseFormEvents(); + + loadConfig().catch(function () { + showLogin("请输入 TOTP 动态码。"); + }); +})(); diff --git a/adminui/assets/style.css b/adminui/assets/style.css new file mode 100644 index 00000000..488a78a3 --- /dev/null +++ b/adminui/assets/style.css @@ -0,0 +1,608 @@ +:root { + --bg: #f4efe7; + --bg-strong: #fffaf3; + --panel: rgba(255, 252, 247, 0.9); + --ink: #1f2a24; + --muted: #607067; + --line: rgba(31, 42, 36, 0.12); + --line-strong: rgba(31, 42, 36, 0.18); + --accent: #0f6d5a; + --accent-strong: #0a4f42; + --danger: #b0453c; + --danger-bg: rgba(176, 69, 60, 0.1); + --warn-bg: rgba(183, 123, 32, 0.12); + --muted-bg: rgba(31, 42, 36, 0.05); + --shadow: 0 24px 64px rgba(31, 42, 36, 0.12); + --radius: 22px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(15, 109, 90, 0.16), transparent 26rem), + radial-gradient(circle at bottom right, rgba(176, 69, 60, 0.1), transparent 24rem), + linear-gradient(180deg, #faf5ee 0%, #efe6db 100%); + font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; +} + +.shell { + width: min(1440px, calc(100vw - 32px)); + margin: 24px auto 40px; +} + +.hero { + padding: 28px 30px; + border: 1px solid var(--line); + border-radius: calc(var(--radius) + 4px); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(247, 241, 233, 0.84)); + box-shadow: var(--shadow); +} + +.eyebrow { + display: inline-block; + padding: 6px 10px; + border-radius: 999px; + background: rgba(15, 109, 90, 0.1); + color: var(--accent-strong); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1 { + margin: 16px 0 10px; + font-size: clamp(30px, 5vw, 48px); + line-height: 1.02; + letter-spacing: -0.04em; +} + +.hero p, +.panel-subtitle, +.section-head p, +.tiny, +.meta-card, +.status, +.banner, +.preview pre, +.hook-item small { + line-height: 1.55; +} + +.hero p { + margin: 0; + max-width: 860px; + color: var(--muted); + font-size: 15px; +} + +.workspace { + display: grid; + grid-template-columns: 320px minmax(0, 1fr); + gap: 20px; + margin-top: 20px; +} + +.workspace[hidden], +.login[hidden] { + display: none; +} + +.panel { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(14px); +} + +.panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px 0; +} + +.panel-head h2, +.panel-head h3, +.toolbar-row h3, +.toolbar-row h4, +.section-head h3 { + margin: 0; + letter-spacing: -0.02em; +} + +.panel-head h2 { + font-size: 18px; +} + +.panel-subtitle { + margin: 6px 0 0; + font-size: 13px; + color: var(--muted); +} + +.panel-body { + padding: 18px 20px 20px; +} + +.login { + max-width: 420px; + margin: 28px auto 0; +} + +.stack { + display: grid; + gap: 14px; +} + +label { + display: block; + margin-bottom: 8px; + font-size: 12px; + font-weight: 700; + color: var(--muted); + letter-spacing: 0.03em; + text-transform: uppercase; +} + +input, +select, +button { + font: inherit; +} + +input, +select { + width: 100%; + height: 44px; + padding: 0 14px; + border: 1px solid rgba(31, 42, 36, 0.18); + border-radius: 14px; + background: rgba(255, 255, 255, 0.96); + color: var(--ink); + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +input:focus, +select:focus { + outline: none; + border-color: rgba(15, 109, 90, 0.55); + box-shadow: 0 0 0 4px rgba(15, 109, 90, 0.12); + transform: translateY(-1px); +} + +code, +pre, +.hook-item strong, +.code-input { + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; +} + +.code-input { + letter-spacing: 0.35em; + text-align: center; + font-size: 22px; +} + +button { + border: 0; + border-radius: 14px; + min-height: 42px; + padding: 0 16px; + cursor: pointer; + transition: transform 150ms ease, opacity 150ms ease, box-shadow 150ms ease; +} + +button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(31, 42, 36, 0.12); +} + +button:disabled { + opacity: 0.48; + cursor: not-allowed; + box-shadow: none; +} + +.primary { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + color: #fff; +} + +.secondary { + background: rgba(31, 42, 36, 0.08); + color: var(--ink); +} + +.ghost { + background: transparent; + color: var(--accent-strong); + border: 1px dashed rgba(15, 109, 90, 0.28); +} + +.danger { + background: rgba(176, 69, 60, 0.14); + color: var(--danger); +} + +.meta-card, +.status, +.banner { + padding: 14px 16px; + border-radius: 16px; + font-size: 13px; +} + +.meta-card { + background: rgba(15, 109, 90, 0.08); + color: var(--accent-strong); +} + +.status { + margin-top: 14px; + border: 1px solid rgba(31, 42, 36, 0.08); +} + +.status-muted { + background: var(--muted-bg); + color: var(--muted); +} + +.status-error { + background: var(--danger-bg); + border-color: rgba(176, 69, 60, 0.18); + color: #7b3029; +} + +.status-success { + background: rgba(15, 109, 90, 0.1); + border-color: rgba(15, 109, 90, 0.18); + color: var(--accent-strong); +} + +.banner { + margin-bottom: 16px; + background: var(--warn-bg); + color: #81561d; + border: 1px solid rgba(183, 123, 32, 0.18); +} + +.toolbar-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.toolbar-row h3, +.toolbar-row h4 { + font-size: 15px; +} + +.hook-list { + display: grid; + gap: 10px; + max-height: 58vh; + overflow: auto; + padding-right: 2px; +} + +.hook-item { + width: 100%; + padding: 14px; + border: 1px solid rgba(31, 42, 36, 0.1); + border-radius: 16px; + background: rgba(255, 255, 255, 0.88); + cursor: pointer; + text-align: left; +} + +.hook-item.active { + border-color: rgba(15, 109, 90, 0.42); + background: rgba(15, 109, 90, 0.1); +} + +.hook-item strong { + display: block; + font-size: 13px; + line-height: 1.4; +} + +.hook-item small { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 12px; +} + +.hook-form { + display: grid; + gap: 16px; +} + +.editor-fieldset { + margin: 0; + padding: 0; + border: 0; + min-width: 0; +} + +.section, +.preview { + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.55); +} + +.section { + padding: 18px; +} + +.section-head { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.section-head h3 { + font-size: 16px; +} + +.section-head p { + margin: 0; + color: var(--muted); + font-size: 13px; + max-width: 560px; +} + +.grid { + display: grid; + gap: 14px; +} + +.grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.grid-span-2 { + grid-column: span 2; +} + +.toggle-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.toggle { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.7); + cursor: pointer; +} + +.toggle input { + width: 18px; + height: 18px; + margin: 0; + padding: 0; + accent-color: var(--accent); +} + +.toggle span { + font-size: 14px; + line-height: 1.45; +} + +.subsection + .subsection { + margin-top: 18px; +} + +.repeatable-list { + display: grid; + gap: 10px; +} + +.repeatable-empty { + padding: 14px 16px; + border: 1px dashed rgba(31, 42, 36, 0.18); + border-radius: 16px; + color: var(--muted); + font-size: 13px; +} + +.repeatable-row { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.8); +} + +.repeatable-row.columns-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)) 44px; +} + +.repeatable-row.columns-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)) 44px; +} + +.repeatable-row.columns-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)) 44px; +} + +.repeatable-row.columns-5 { + grid-template-columns: repeat(5, minmax(0, 1fr)) 44px; +} + +.field-inline-label { + display: block; + margin-bottom: 6px; + font-size: 11px; + font-weight: 700; + color: var(--muted); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.row-check { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 255, 255, 0.92); +} + +.row-check input { + width: 18px; + height: 18px; + margin: 0; + padding: 0; + accent-color: var(--accent); +} + +.icon-button { + width: 44px; + min-height: 44px; + padding: 0; + border-radius: 14px; + background: rgba(176, 69, 60, 0.12); + color: var(--danger); +} + +.rule-editor { + display: grid; + gap: 12px; +} + +.rule-card { + border: 1px solid var(--line-strong); + border-radius: 18px; + background: rgba(255, 255, 255, 0.82); + padding: 14px; +} + +.rule-card[data-depth="1"] { + margin-left: 14px; +} + +.rule-card[data-depth="2"] { + margin-left: 28px; +} + +.rule-card[data-depth="3"] { + margin-left: 42px; +} + +.rule-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.rule-head strong { + font-size: 14px; +} + +.rule-children { + display: grid; + gap: 12px; + margin-top: 14px; +} + +.rule-note { + padding: 12px 14px; + border-radius: 14px; + background: var(--muted-bg); + color: var(--muted); + font-size: 13px; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.preview { + margin-top: 16px; + overflow: hidden; +} + +.preview summary { + padding: 14px 16px; + cursor: pointer; + font-weight: 700; +} + +.preview pre { + margin: 0; + padding: 0 16px 16px; + color: var(--ink); + overflow: auto; + font-size: 12px; +} + +.tiny { + margin-top: 10px; + color: var(--muted); + font-size: 12px; +} + +@media (max-width: 1180px) { + .workspace { + grid-template-columns: 1fr; + } +} + +@media (max-width: 920px) { + .grid-2, + .grid-3, + .toggle-grid, + .repeatable-row.columns-2, + .repeatable-row.columns-3, + .repeatable-row.columns-4, + .repeatable-row.columns-5 { + grid-template-columns: 1fr; + } + + .grid-span-2 { + grid-column: auto; + } + + .repeatable-row.columns-2 .icon-button, + .repeatable-row.columns-3 .icon-button, + .repeatable-row.columns-4 .icon-button, + .repeatable-row.columns-5 .icon-button { + width: 100%; + } + + .rule-card[data-depth="1"], + .rule-card[data-depth="2"], + .rule-card[data-depth="3"] { + margin-left: 0; + } +} diff --git a/adminui/index.html b/adminui/index.html new file mode 100644 index 00000000..4f888edb --- /dev/null +++ b/adminui/index.html @@ -0,0 +1,215 @@ + + + + + + Webhook Admin + + + + + +
+
+ Webhook Control Plane +

集中管理所有 webhook 配置

+

使用 TOTP 获取短期 JWT 会话。页面直接管理已加载的 hooks 文件,不需要额外前端构建流程。

+
+ + + + +
+ + diff --git a/go.mod b/go.mod index 48f350cd..5c10b75a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/adnanh/webhook -go 1.14 +go 1.16 require ( github.com/clbanning/mxj v1.8.4 diff --git a/webhook.go b/webhook.go index f3070ac3..bc8bc998 100644 --- a/webhook.go +++ b/webhook.go @@ -65,9 +65,13 @@ var ( ) func matchLoadedHook(id string) *hook.Hook { - for _, hooks := range loadedHooksFromFiles { - if hook := hooks.Match(id); hook != nil { - return hook + loadedHooksMu.RLock() + defer loadedHooksMu.RUnlock() + + for _, hooksInFile := range loadedHooksFromFiles { + if matched := hooksInFile.Match(id); matched != nil { + cloned := cloneHook(*matched) + return &cloned } } @@ -75,9 +79,12 @@ func matchLoadedHook(id string) *hook.Hook { } func lenLoadedHooks() int { + loadedHooksMu.RLock() + defer loadedHooksMu.RUnlock() + sum := 0 - for _, hooks := range loadedHooksFromFiles { - sum += len(hooks) + for _, hooksInFile := range loadedHooksFromFiles { + sum += len(hooksInFile) } return sum @@ -116,6 +123,11 @@ func main() { hooksFiles = append(hooksFiles, "hooks.json") } + if err := initAdmin(); err != nil { + fmt.Println("error:", err) + os.Exit(1) + } + // logQueue is a queue for log messages encountered during startup. We need // to queue the messages so that we can handle any privilege dropping and // log file opening prior to writing our first log message. @@ -197,27 +209,34 @@ func main() { if err != nil { log.Printf("couldn't load hooks from file! %+v\n", err) } else { + loadedHooksMu.Lock() + candidate := cloneLoadedHooksMapLocked() + candidate[hooksFilePath] = cloneHooks(newHooks) + if err := validateUniqueHookIDs(candidate); err != nil { + loadedHooksMu.Unlock() + log.Fatalf("error: %s\nplease check your hooks files for duplicate hook ids!\n", err) + } + log.Printf("found %d hook(s) in file\n", len(newHooks)) - for _, hook := range newHooks { - if matchLoadedHook(hook.ID) != nil { - log.Fatalf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!\n", hook.ID) - } - log.Printf("\tloaded: %s\n", hook.ID) + for _, currentHook := range newHooks { + log.Printf("\tloaded: %s\n", currentHook.ID) } loadedHooksFromFiles[hooksFilePath] = newHooks + loadedHooksMu.Unlock() } } + loadedHooksMu.Lock() newHooksFiles := hooksFiles[:0] for _, filePath := range hooksFiles { if _, ok := loadedHooksFromFiles[filePath]; ok { newHooksFiles = append(newHooksFiles, filePath) } } - hooksFiles = newHooksFiles + loadedHooksMu.Unlock() if !*verbose && !*noPanic && lenLoadedHooks() == 0 { log.SetOutput(os.Stdout) @@ -233,7 +252,7 @@ func main() { } defer watcher.Close() - for _, hooksFilePath := range hooksFiles { + for _, hooksFilePath := range hooksFilesSnapshot() { // set up file watcher log.Printf("setting up file watcher for %s\n", hooksFilePath) @@ -273,6 +292,7 @@ func main() { fmt.Fprint(w, "OK") }) + registerAdminRoutes(r) r.HandleFunc(hooksURL, hookHandler) // Create common HTTP server settings @@ -284,6 +304,9 @@ func main() { // Serve HTTP if !*secure { log.Printf("serving hooks on http://%s%s", addr, makeHumanPattern(hooksURLPrefix)) + if *adminEnabled { + log.Printf("serving admin on http://%s%s", addr, currentAdminAuth.basePath) + } log.Print(svr.Serve(ln)) return @@ -299,6 +322,9 @@ func main() { svr.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) // disable http/2 log.Printf("serving hooks on https://%s%s", addr, makeHumanPattern(hooksURLPrefix)) + if *adminEnabled { + log.Printf("serving admin on https://%s%s", addr, currentAdminAuth.basePath) + } log.Print(svr.ServeTLS(ln, *cert, *key)) } @@ -710,28 +736,21 @@ func reloadHooks(hooksFilePath string) { if err != nil { log.Printf("couldn't load hooks from file! %+v\n", err) } else { - seenHooksIds := make(map[string]bool) + loadedHooksMu.Lock() + defer loadedHooksMu.Unlock() + + candidate := cloneLoadedHooksMapLocked() + candidate[hooksFilePath] = cloneHooks(hooksInFile) + if err := validateUniqueHookIDs(candidate); err != nil { + log.Printf("error: %s", err) + log.Println("reverting hooks back to the previous configuration") + return + } log.Printf("found %d hook(s) in file\n", len(hooksInFile)) - for _, hook := range hooksInFile { - wasHookIDAlreadyLoaded := false - - for _, loadedHook := range loadedHooksFromFiles[hooksFilePath] { - if loadedHook.ID == hook.ID { - wasHookIDAlreadyLoaded = true - break - } - } - - if (matchLoadedHook(hook.ID) != nil && !wasHookIDAlreadyLoaded) || seenHooksIds[hook.ID] { - log.Printf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!", hook.ID) - log.Println("reverting hooks back to the previous configuration") - return - } - - seenHooksIds[hook.ID] = true - log.Printf("\tloaded: %s\n", hook.ID) + for _, currentHook := range hooksInFile { + log.Printf("\tloaded: %s\n", currentHook.ID) } loadedHooksFromFiles[hooksFilePath] = hooksInFile @@ -739,14 +758,16 @@ func reloadHooks(hooksFilePath string) { } func reloadAllHooks() { - for _, hooksFilePath := range hooksFiles { + for _, hooksFilePath := range hooksFilesSnapshot() { reloadHooks(hooksFilePath) } } func removeHooks(hooksFilePath string) { - for _, hook := range loadedHooksFromFiles[hooksFilePath] { - log.Printf("\tremoving: %s\n", hook.ID) + loadedHooksMu.Lock() + + for _, currentHook := range loadedHooksFromFiles[hooksFilePath] { + log.Printf("\tremoving: %s\n", currentHook.ID) } newHooksFiles := hooksFiles[:0] @@ -764,7 +785,14 @@ func removeHooks(hooksFilePath string) { log.Printf("removed %d hook(s) that were loaded from file %s\n", removedHooksCount, hooksFilePath) - if !*verbose && !*noPanic && lenLoadedHooks() == 0 { + remainingHooksCount := 0 + for _, hooksInFile := range loadedHooksFromFiles { + remainingHooksCount += len(hooksInFile) + } + + loadedHooksMu.Unlock() + + if !*verbose && !*noPanic && remainingHooksCount == 0 { log.SetOutput(os.Stdout) log.Fatalln("couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to run without the hooks, either use -verbose flag, or -nopanic") } From 709674ba01272a250a4a7bbe4c2b9cff912e67d4 Mon Sep 17 00:00:00 2001 From: "jason.liao" Date: Tue, 28 Apr 2026 13:46:21 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=85=20keep-file-envi?= =?UTF-8?q?ronment=20=E7=9A=84=E6=96=87=E6=A1=A3=E5=92=8C=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- docs/Hook-Definition.md | 2 +- docs/Hook-Examples.md | 8 +- test/hookecho.go | 44 ++++++++-- test/hooks.json.tmpl | 24 ++++++ test/hooks.yaml.tmpl | 16 ++++ webhook.go | 10 ++- webhook_test.go | 187 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 283 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 74ae0362..2895853d 100644 --- a/README.md +++ b/README.md @@ -94,12 +94,14 @@ However, hook defined like that could pose a security threat to your system, bec Multipart form data can contain two types of parts: values and files. All form _values_ are automatically added to the `payload` scope. Use the `parse-parameters-as-json` settings to parse a given value as JSON. -All files are ignored unless they match one of the following criteria: +All files are ignored unless they match one of the following criteria: 1. The `Content-Type` header is `application/json`. 1. The part is named in the `parse-parameters-as-json` setting. -In either case, the given file part will be parsed as JSON and added to the `payload` map. +In either case, the given file part will be parsed as JSON and added to the `payload` map. + +If you want a hook command to inspect uploaded files directly, enable the `keep-file-environment` hook option. During command execution webhook will expose each uploaded file as `HOOK_FILE_` and the original filename as `HOOK_FILENAME_`, then remove the temporary file once the command exits. Because multipart field names are copied into the environment variable name after uppercasing, prefer names made of letters, numbers, and underscores if the command will read them from a shell script. ## Templates [webhook][w] can parse the hooks configuration file as a Go template when given the `-template` [CLI parameter](docs/Webhook-Parameters.md). See the [Templates page](docs/Templates.md) for more details on template usage. diff --git a/docs/Hook-Definition.md b/docs/Hook-Definition.md index 5618235d..ad56e4d1 100644 --- a/docs/Hook-Definition.md +++ b/docs/Hook-Definition.md @@ -23,7 +23,7 @@ Hooks are defined as objects in the JSON or YAML hooks configuration file. Pleas * `trigger-rule` - specifies the rule that will be evaluated in order to determine should the hook be triggered. Check [Hook rules page](Hook-Rules.md) to see the list of valid rules and their usage * `trigger-rule-mismatch-http-response-code` - specifies the HTTP status code to be returned when the trigger rule is not satisfied * `trigger-signature-soft-failures` - allow signature validation failures within Or rules; by default, signature failures are treated as errors. -* `keep-file-environment` - Keep all submitted files. Sending `curl -d 'pkg=@res.tar.gz'` will retrieve the environment variable `HOOK_FILE_PKG`, which contains the file path, and `HOOK_FILENAME_PKG`, which contains the file name as `res.tar.gz`. If `keep-file-environment` is true, the file will be preserved after the hook is executed. By default, the corresponding file will be removed after the webhook exits. +* `keep-file-environment` - expose uploaded multipart files to the executed command as temporary environment variables. For a multipart form field named `pkg`, webhook will provide `HOOK_FILE_PKG` with the temporary file path and `HOOK_FILENAME_PKG` with the original filename. These files exist only for the lifetime of the command execution and are removed afterwards. Multipart field names are uppercased and embedded into the environment variable name verbatim; if you plan to read them from a shell script, prefer field names that are safe shell variable suffixes such as letters, numbers, and underscores. ## Examples Check out [Hook examples page](Hook-Examples.md) for more complex examples of hooks. diff --git a/docs/Hook-Examples.md b/docs/Hook-Examples.md index d9701049..ef764694 100644 --- a/docs/Hook-Examples.md +++ b/docs/Hook-Examples.md @@ -605,9 +605,11 @@ Content-Disposition: form-data; name="payload" Content-Disposition: form-data; name="thumb"; filename="thumb.jpg" ``` -We key off of the `name` attribute in the `Content-Disposition` value. - -## Pass string arguments to command +We key off of the `name` attribute in the `Content-Disposition` value. + +If you need the executed command to read uploaded files directly, enable `keep-file-environment`. For a file part named `pkg`, webhook will expose `HOOK_FILE_PKG` with the temporary file path and `HOOK_FILENAME_PKG` with the original filename while the command is running, then clean the file up when the command exits. Since the multipart field name is copied into the environment variable name after uppercasing, prefer shell-safe field names such as `pkg`, `release_tarball`, or `artifact1`. + +## Pass string arguments to command To pass simple string arguments to a command, use the `string` parameter source. The following example will pass two static string parameters ("-e 123123") to the diff --git a/test/hookecho.go b/test/hookecho.go index 6e5e9f7b..d8aee108 100644 --- a/test/hookecho.go +++ b/test/hookecho.go @@ -5,8 +5,10 @@ package main import ( "fmt" "os" + "sort" "strconv" "strings" + "time" ) func main() { @@ -20,18 +22,46 @@ func main() { env = append(env, v) } } + sort.Strings(env) if len(env) > 0 { fmt.Printf("env: %s\n", strings.Join(env, " ")) } - if (len(os.Args) > 1) && (strings.HasPrefix(os.Args[1], "exit=")) { - exit_code_str := os.Args[1][5:] - exit_code, err := strconv.Atoi(exit_code_str) - if err != nil { - fmt.Printf("Exit code %s not an int!", exit_code_str) - os.Exit(-1) + for _, arg := range os.Args[1:] { + switch { + case strings.HasPrefix(arg, "cat-env-file="): + key := strings.TrimPrefix(arg, "cat-env-file=") + path := os.Getenv(key) + if path == "" { + fmt.Printf("File env %s is not set!", key) + os.Exit(-1) + } + content, err := os.ReadFile(path) + if err != nil { + fmt.Printf("Failed to read %s (%s): %v", key, path, err) + os.Exit(-1) + } + fmt.Printf("file: %s=%s\n", key, string(content)) + + case strings.HasPrefix(arg, "sleep="): + sleepFor := strings.TrimPrefix(arg, "sleep=") + duration, err := time.ParseDuration(sleepFor) + if err != nil { + fmt.Printf("Sleep duration %s is invalid!", sleepFor) + os.Exit(-1) + } + time.Sleep(duration) + fmt.Printf("slept: %s\n", duration) + + case strings.HasPrefix(arg, "exit="): + exit_code_str := arg[5:] + exit_code, err := strconv.Atoi(exit_code_str) + if err != nil { + fmt.Printf("Exit code %s not an int!", exit_code_str) + os.Exit(-1) + } + os.Exit(exit_code) } - os.Exit(exit_code) } } diff --git a/test/hooks.json.tmpl b/test/hooks.json.tmpl index 9cfe348f..634be026 100644 --- a/test/hooks.json.tmpl +++ b/test/hooks.json.tmpl @@ -531,6 +531,30 @@ ] } }, + { + "id": "keep-file-environment", + "execute-command": "{{ .Hookecho }}", + "include-command-output-in-response": true, + "keep-file-environment": true, + "pass-arguments-to-command": [ + { + "source": "string", + "name": "cat-env-file=HOOK_FILE_PKG" + } + ] + }, + { + "id": "keep-file-environment-special-name", + "execute-command": "{{ .Hookecho }}", + "include-command-output-in-response": true, + "keep-file-environment": true, + "pass-arguments-to-command": [ + { + "source": "string", + "name": "cat-env-file=HOOK_FILE_PKG-NAME" + } + ] + }, { "id": "empty-payload-signature", "execute-command": "{{ .Hookecho }}", diff --git a/test/hooks.yaml.tmpl b/test/hooks.yaml.tmpl index b18c1914..a093b58d 100644 --- a/test/hooks.yaml.tmpl +++ b/test/hooks.yaml.tmpl @@ -302,6 +302,22 @@ type: value value: 1 +- id: keep-file-environment + execute-command: '{{ .Hookecho }}' + include-command-output-in-response: true + keep-file-environment: true + pass-arguments-to-command: + - source: string + name: cat-env-file=HOOK_FILE_PKG + +- id: keep-file-environment-special-name + execute-command: '{{ .Hookecho }}' + include-command-output-in-response: true + keep-file-environment: true + pass-arguments-to-command: + - source: string + name: cat-env-file=HOOK_FILE_PKG-NAME + - id: empty-payload-signature include-command-output-in-response: true execute-command: '{{ .Hookecho }}' diff --git a/webhook.go b/webhook.go index bc8bc998..9a015756 100644 --- a/webhook.go +++ b/webhook.go @@ -488,6 +488,9 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("[%s] error parsing JSON payload file: %+v\n", req.ID, err) } + if err := f.Close(); err != nil { + log.Printf("[%s] error closing multipart form file: %+v\n", req.ID, err) + } if req.Payload == nil { req.Payload = make(map[string]interface{}) @@ -704,11 +707,16 @@ func handleHook(h *hook.Hook, r *hook.Request) (string, error) { if files[i].File != nil { log.Printf("[%s] removing file %s\n", r.ID, files[i].File.Name()) err := os.Remove(files[i].File.Name()) - if err != nil { + if err != nil && !os.IsNotExist(err) { log.Printf("[%s] error removing file %s [%s]", r.ID, files[i].File.Name(), err) } } } + if r.RawRequest != nil && r.RawRequest.MultipartForm != nil { + if err := r.RawRequest.MultipartForm.RemoveAll(); err != nil && !os.IsNotExist(err) { + log.Printf("[%s] error removing multipart form temp files [%s]", r.ID, err) + } + } log.Printf("[%s] finished handling %s\n", r.ID, h.ID) diff --git a/webhook_test.go b/webhook_test.go index 50fef521..a0c25b40 100755 --- a/webhook_test.go +++ b/webhook_test.go @@ -5,6 +5,7 @@ import ( "fmt" "io/ioutil" "log" + "mime/multipart" "net" "net/http" "os" @@ -296,6 +297,120 @@ func killAndWait(cmd *exec.Cmd) { cmd.Wait() } +func startWebhookServer(t *testing.T, webhookBin, configPath string, extraArgs ...string) (*exec.Cmd, *buffer, string) { + t.Helper() + + ip, port := serverAddress(t) + args := []string{ + fmt.Sprintf("-hooks=%s", configPath), + fmt.Sprintf("-ip=%s", ip), + fmt.Sprintf("-port=%s", port), + "-debug", + } + args = append(args, extraArgs...) + + logs := &buffer{} + + cmd := exec.Command(webhookBin, args...) + cmd.Stderr = logs + cmd.Env = webhookEnv() + cmd.Args[0] = "webhook" + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start webhook: %s", err) + } + + waitForServerReady(t, ip, port) + + return cmd, logs, "http://" + net.JoinHostPort(ip, port) +} + +func doJSONHookRequestResult(baseURL, hookID string) (int, string, error) { + req, err := http.NewRequest(http.MethodPost, baseURL+"/hooks/"+hookID, bytes.NewBufferString(`{}`)) + if err != nil { + return 0, "", err + } + req.Header.Set("Content-Type", "application/json") + req.ContentLength = int64(len(`{}`)) + + client := &http.Client{} + res, err := client.Do(req) + if err != nil { + return 0, "", err + } + defer res.Body.Close() + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return 0, "", err + } + + return res.StatusCode, string(body), nil +} + +func doJSONHookRequest(t *testing.T, baseURL, hookID string) (int, string) { + t.Helper() + + status, body, err := doJSONHookRequestResult(baseURL, hookID) + if err != nil { + t.Fatalf("failed to execute request: %v", err) + } + + return status, body +} + +func doMultipartHookRequest(t *testing.T, baseURL, hookID, fieldName, fileName string, data []byte) (int, string) { + t.Helper() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + part, err := writer.CreateFormFile(fieldName, fileName) + if err != nil { + t.Fatalf("failed to create multipart form file: %v", err) + } + if _, err := part.Write(data); err != nil { + t.Fatalf("failed to write multipart payload: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, baseURL+"/hooks/"+hookID, bytes.NewReader(body.Bytes())) + if err != nil { + t.Fatalf("failed to create multipart request: %v", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.ContentLength = int64(body.Len()) + + client := &http.Client{} + res, err := client.Do(req) + if err != nil { + t.Fatalf("failed to execute multipart request: %v", err) + } + defer res.Body.Close() + + respBody, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatalf("failed to read multipart response body: %v", err) + } + + return res.StatusCode, string(respBody) +} + +func waitForBufferContains(t *testing.T, b *buffer, needle string, timeout time.Duration) { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if strings.Contains(b.String(), needle) { + return + } + time.Sleep(10 * time.Millisecond) + } + + t.Fatalf("buffer did not contain %q within %v; got:\n%s", needle, timeout, b.String()) +} + // webhookEnv returns the process environment without any existing hook // namespace variables. func webhookEnv() (env []string) { @@ -308,6 +423,78 @@ func webhookEnv() (env []string) { return } +func TestWebhookKeepFileEnvironment(t *testing.T) { + hookecho, cleanupHookecho := buildHookecho(t) + defer cleanupHookecho() + + webhookBin, cleanupWebhook := buildWebhook(t) + defer cleanupWebhook() + + tests := []struct { + name string + id string + fieldName string + fileName string + fileContent string + fileEnv string + nameEnv string + }{ + { + name: "default field name", + id: "keep-file-environment", + fieldName: "pkg", + fileName: "pkg.tar.gz", + fileContent: "payload-data", + fileEnv: "HOOK_FILE_PKG", + nameEnv: "HOOK_FILENAME_PKG", + }, + { + name: "special field name", + id: "keep-file-environment-special-name", + fieldName: "pkg-name", + fileName: "pkg-name.txt", + fileContent: "special-data", + fileEnv: "HOOK_FILE_PKG-NAME", + nameEnv: "HOOK_FILENAME_PKG-NAME", + }, + } + + for _, hookTmpl := range []string{"test/hooks.json.tmpl", "test/hooks.yaml.tmpl"} { + configPath, cleanupConfig := genConfig(t, hookecho, hookTmpl) + defer cleanupConfig() + + for _, tt := range tests { + t.Run(tt.name+"@"+hookTmpl, func(t *testing.T) { + cmd, _, baseURL := startWebhookServer(t, webhookBin, configPath) + defer killAndWait(cmd) + + status, body := doMultipartHookRequest(t, baseURL, tt.id, tt.fieldName, tt.fileName, []byte(tt.fileContent)) + if status != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", status, body) + } + + if !strings.Contains(body, "arg: cat-env-file="+tt.fileEnv) { + t.Fatalf("response did not contain file env arg: %s", body) + } + if !strings.Contains(body, tt.nameEnv+"="+tt.fileName) { + t.Fatalf("response did not contain filename env %s=%s: %s", tt.nameEnv, tt.fileName, body) + } + if !strings.Contains(body, "file: "+tt.fileEnv+"="+tt.fileContent) { + t.Fatalf("response did not contain file contents for %s: %s", tt.fileEnv, body) + } + + match := regexp.MustCompile(`(?m)^env: .*` + regexp.QuoteMeta(tt.fileEnv) + `=([^ ]+)`).FindStringSubmatch(body) + if len(match) != 2 { + t.Fatalf("could not extract temp file path from response: %s", body) + } + + if _, err := os.Stat(match[1]); !os.IsNotExist(err) { + t.Fatalf("expected temp file %s to be removed after execution, stat err=%v", match[1], err) + } + }) + } + } +} var hookHandlerTests = []struct { desc string id string From 531ca219fe00fe20f661068462110d7252cf9fda Mon Sep 17 00:00:00 2001 From: "jason.liao" Date: Tue, 28 Apr 2026 13:47:51 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin_store.go | 8 ++ adminui/assets/app.js | 19 +++- adminui/index.html | 8 ++ docs/Hook-Definition.md | 2 + docs/Webhook-Parameters.md | 6 ++ execution.go | 146 +++++++++++++++++++++++++++ internal/hook/hook.go | 81 ++++++++++++++- internal/hook/hook_test.go | 132 ++++++++++++++++++++++--- test/hooks.json.tmpl | 73 ++++++++++++++ test/hooks.yaml.tmpl | 49 ++++++++++ webhook.go | 39 ++++++-- webhook_test.go | 195 +++++++++++++++++++++++++++++++++++++ 12 files changed, 736 insertions(+), 22 deletions(-) create mode 100644 execution.go diff --git a/admin_store.go b/admin_store.go index f8694657..cb0d60ec 100644 --- a/admin_store.go +++ b/admin_store.go @@ -68,6 +68,10 @@ func cloneHook(src hook.Hook) hook.Hook { if src.HTTPMethods != nil { dst.HTTPMethods = append([]string(nil), src.HTTPMethods...) } + if src.MaxConcurrency != nil { + value := *src.MaxConcurrency + dst.MaxConcurrency = &value + } return dst } @@ -250,6 +254,7 @@ func normalizeAdminHook(current hook.Hook) hook.Hook { current.ID = strings.TrimSpace(current.ID) current.ExecuteCommand = strings.TrimSpace(current.ExecuteCommand) current.CommandWorkingDirectory = strings.TrimSpace(current.CommandWorkingDirectory) + current.CommandTimeout = strings.TrimSpace(current.CommandTimeout) current.ResponseMessage = strings.TrimSpace(current.ResponseMessage) if len(current.HTTPMethods) != 0 { @@ -275,6 +280,9 @@ func upsertHookInFile(path, currentID string, updated hook.Hook) error { if updated.ID == "" { return errors.New("hook id is required") } + if err := updated.ValidateExecutionSettings(); err != nil { + return err + } loadedHooksMu.Lock() defer loadedHooksMu.Unlock() diff --git a/adminui/assets/app.js b/adminui/assets/app.js index de7010d5..5b4012bd 100644 --- a/adminui/assets/app.js +++ b/adminui/assets/app.js @@ -62,11 +62,13 @@ hookId: byId("hookId"), executeCommand: byId("executeCommand"), commandWorkingDirectory: byId("commandWorkingDirectory"), + commandTimeout: byId("commandTimeout"), httpMethods: byId("httpMethods"), responseMessage: byId("responseMessage"), incomingPayloadContentType: byId("incomingPayloadContentType"), successHttpResponseCode: byId("successHttpResponseCode"), triggerRuleMismatchHttpResponseCode: byId("triggerRuleMismatchHttpResponseCode"), + maxConcurrency: byId("maxConcurrency"), captureCommandOutput: byId("captureCommandOutput"), captureCommandOutputOnError: byId("captureCommandOutputOnError"), triggerSignatureSoftFailures: byId("triggerSignatureSoftFailures"), @@ -183,6 +185,7 @@ id: "", "execute-command": "", "command-working-directory": "", + "command-timeout": "", "response-message": "", "http-methods": ["POST"] }); @@ -743,11 +746,13 @@ setFormValue(els.hookId, hook.id); setFormValue(els.executeCommand, hook["execute-command"]); setFormValue(els.commandWorkingDirectory, hook["command-working-directory"]); + setFormValue(els.commandTimeout, hook["command-timeout"]); setFormValue(els.httpMethods, normalizeArray(hook["http-methods"]).join(", ")); setFormValue(els.responseMessage, hook["response-message"]); setFormValue(els.incomingPayloadContentType, hook["incoming-payload-content-type"]); setFormValue(els.successHttpResponseCode, hook["success-http-response-code"] || ""); setFormValue(els.triggerRuleMismatchHttpResponseCode, hook["trigger-rule-mismatch-http-response-code"] || ""); + setFormValue(els.maxConcurrency, hook["max-concurrency"] === 0 ? "0" : (hook["max-concurrency"] || "")); els.captureCommandOutput.checked = !!hook["include-command-output-in-response"]; els.captureCommandOutputOnError.checked = !!hook["include-command-output-in-response-on-error"]; @@ -988,6 +993,11 @@ hook["command-working-directory"] = workingDirectory; } + var commandTimeout = inputValue(els.commandTimeout); + if (commandTimeout) { + hook["command-timeout"] = commandTimeout; + } + var methods = splitCSV(els.httpMethods.value); if (methods.length) { hook["http-methods"] = methods; @@ -1013,6 +1023,11 @@ hook["trigger-rule-mismatch-http-response-code"] = mismatchCode; } + var maxConcurrency = parseOptionalInt(els.maxConcurrency.value, "Max Concurrency", errors); + if (maxConcurrency !== null) { + hook["max-concurrency"] = maxConcurrency; + } + if (els.captureCommandOutput.checked) { hook["include-command-output-in-response"] = true; } @@ -1172,11 +1187,13 @@ els.hookId, els.executeCommand, els.commandWorkingDirectory, + els.commandTimeout, els.httpMethods, els.responseMessage, els.incomingPayloadContentType, els.successHttpResponseCode, - els.triggerRuleMismatchHttpResponseCode + els.triggerRuleMismatchHttpResponseCode, + els.maxConcurrency ].forEach(function (input) { input.addEventListener("input", updatePreview); }); diff --git a/adminui/index.html b/adminui/index.html index 4f888edb..f4e684e7 100644 --- a/adminui/index.html +++ b/adminui/index.html @@ -88,6 +88,10 @@

Basics

+
+ + +
@@ -117,6 +121,10 @@

Behavior

+
+ + +