From fe539f46b579c14f4c4759b60327f3853ee60a13 Mon Sep 17 00:00:00 2001 From: "jason.liao" Date: Tue, 25 Jul 2023 13:58:53 +0800 Subject: [PATCH 01/13] 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 02/13] =?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 03/13] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=85=20keep-file-en?= =?UTF-8?q?vironment=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 04/13] =?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

+
+ + +
暂无可管理的 hooks 文件。
+
+
+

程序版本

+ +
+
+
+
当前
+
-
+
+
+
最新
+
尚未检查
+
+
+
尚未检查更新。
+

Hooks

diff --git a/docs/Webhook-Parameters.md b/docs/Webhook-Parameters.md index a10c0f30..c8dd6250 100644 --- a/docs/Webhook-Parameters.md +++ b/docs/Webhook-Parameters.md @@ -57,6 +57,12 @@ Usage of webhook: show verbose output -version display webhook version and quit + -update-enabled + enable update checks in the admin API (default true) + -update-repository string + GitHub repository used for updates (default "xtulnx/webhook") + -update-state-dir string + directory for update state; defaults to the current working directory -x-request-id use X-Request-Id header, if present, as request ID -x-request-id-limit int diff --git a/internal/update/replace_unix.go b/internal/update/replace_unix.go new file mode 100644 index 00000000..f5031d48 --- /dev/null +++ b/internal/update/replace_unix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package update + +import "os" + +func replaceExecutable(source, target string) error { + return os.Rename(source, target) +} + +func replaceFile(source, target string) error { + return os.Rename(source, target) +} diff --git a/internal/update/replace_windows.go b/internal/update/replace_windows.go new file mode 100644 index 00000000..e30e9416 --- /dev/null +++ b/internal/update/replace_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package update + +import "os" + +func replaceExecutable(source, target string) error { + old := target + ".replacing" + _ = os.Remove(old) + if err := os.Rename(target, old); err != nil { + return err + } + if err := os.Rename(source, target); err != nil { + _ = os.Rename(old, target) + return err + } + return os.Remove(old) +} + +func replaceFile(source, target string) error { + if err := os.Remove(target); err != nil && !os.IsNotExist(err) { + return err + } + return os.Rename(source, target) +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 00000000..bb446611 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,729 @@ +package update + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +const ( + DefaultRepository = "xtulnx/webhook" + manifestName = "update-manifest.json" + signatureName = "update-manifest.json.sig" + maxManifestSize = 2 << 20 + maxArchiveSize = 512 << 20 + maxBinarySize = 256 << 20 +) + +var ErrSignatureUnavailable = errors.New("update signature verification is not configured") + +type Asset struct { + OS string `json:"os"` + Arch string `json:"arch"` + Name string `json:"name"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +type Manifest struct { + SchemaVersion int `json:"schemaVersion"` + Version string `json:"version"` + PublishedAt time.Time `json:"publishedAt"` + Commit string `json:"commit,omitempty"` + ReleaseURL string `json:"releaseURL,omitempty"` + Assets []Asset `json:"assets"` +} + +type Result struct { + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion,omitempty"` + Available bool `json:"available"` + Verified bool `json:"verified"` + PublishedAt time.Time `json:"publishedAt,omitempty"` + ReleaseURL string `json:"releaseURL,omitempty"` + CheckedAt time.Time `json:"checkedAt"` + Manifest Manifest `json:"-"` +} + +type State struct { + CurrentVersion string `json:"currentVersion"` + InstalledVersion string `json:"installedVersion"` + Target string `json:"target"` + Backup string `json:"backup"` + SHA256 string `json:"sha256"` + AppliedAt time.Time `json:"appliedAt"` + RolledBackAt time.Time `json:"rolledBackAt,omitempty"` +} + +type Client struct { + Repository string + Version string + PublicKey string + HTTPClient *http.Client + BaseURL string +} + +type ApplyOptions struct { + Version string + Target string + StateDir string + GOOS string + GOARCH string + SkipProbe bool + AllowUnsigned bool +} + +func (c *Client) Check(ctx context.Context, requestedVersion string) (Result, error) { + if err := validateRepository(c.repository()); err != nil { + return Result{}, err + } + if err := validateRequestedVersion(requestedVersion); err != nil { + return Result{}, err + } + + manifestURL := c.releaseAssetURL(requestedVersion, manifestName) + manifestBytes, err := c.download(ctx, manifestURL, maxManifestSize) + if err != nil { + return Result{}, fmt.Errorf("download update manifest: %w", err) + } + + verified := false + if strings.TrimSpace(c.PublicKey) != "" { + signatureBytes, err := c.download(ctx, c.releaseAssetURL(requestedVersion, signatureName), maxManifestSize) + if err != nil { + return Result{}, fmt.Errorf("download update manifest signature: %w", err) + } + if err := verifyManifestSignature(manifestBytes, signatureBytes, c.PublicKey); err != nil { + return Result{}, err + } + verified = true + } + + var manifest Manifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return Result{}, fmt.Errorf("decode update manifest: %w", err) + } + if err := validateManifest(manifest); err != nil { + return Result{}, err + } + if requestedVersion != "" && requestedVersion != "latest" && CompareVersions(manifest.Version, requestedVersion) != 0 { + return Result{}, fmt.Errorf("update manifest version %s does not match requested version %s", manifest.Version, requestedVersion) + } + + return Result{ + CurrentVersion: c.Version, + LatestVersion: manifest.Version, + Available: CompareVersions(manifest.Version, c.Version) > 0, + Verified: verified, + PublishedAt: manifest.PublishedAt, + ReleaseURL: manifest.ReleaseURL, + CheckedAt: time.Now().UTC(), + Manifest: manifest, + }, nil +} + +func (c *Client) Apply(ctx context.Context, options ApplyOptions) (State, error) { + if strings.TrimSpace(c.PublicKey) == "" && !options.AllowUnsigned { + return State{}, ErrSignatureUnavailable + } + + result, err := c.Check(ctx, options.Version) + if err != nil { + return State{}, err + } + if !result.Verified && !options.AllowUnsigned { + return State{}, ErrSignatureUnavailable + } + if options.Version == "" && !result.Available { + return State{}, fmt.Errorf("version %s is not newer than %s", result.LatestVersion, c.Version) + } + + goos := options.GOOS + if goos == "" { + goos = runtime.GOOS + } + goarch := options.GOARCH + if goarch == "" { + goarch = runtime.GOARCH + } + asset, err := findAsset(result.Manifest, goos, goarch) + if err != nil { + return State{}, err + } + + target, err := resolveTarget(options.Target) + if err != nil { + return State{}, err + } + stateDir := strings.TrimSpace(options.StateDir) + if stateDir == "" { + stateDir, err = os.Getwd() + if err != nil { + return State{}, fmt.Errorf("locate update state directory: %w", err) + } + } + stateDir, err = filepath.Abs(stateDir) + if err != nil { + return State{}, fmt.Errorf("resolve update state directory: %w", err) + } + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return State{}, fmt.Errorf("create update state directory: %w", err) + } + + archive, err := os.CreateTemp(stateDir, ".webhook-update-*.tar.gz") + if err != nil { + return State{}, fmt.Errorf("create update archive: %w", err) + } + archivePath := archive.Name() + defer os.Remove(archivePath) + + if err := c.downloadFile(ctx, c.releaseAssetURL(result.Manifest.Version, asset.Name), archive, maxArchiveSize); err != nil { + archive.Close() + return State{}, fmt.Errorf("download update archive: %w", err) + } + if err := archive.Close(); err != nil { + return State{}, fmt.Errorf("close update archive: %w", err) + } + if err := verifyFile(archivePath, asset); err != nil { + return State{}, err + } + + newBinary, err := extractBinary(archivePath, filepath.Dir(target), asset, goos, goarch) + if err != nil { + return State{}, err + } + defer os.Remove(newBinary) + + if info, statErr := os.Stat(target); statErr == nil { + if err := os.Chmod(newBinary, info.Mode().Perm()); err != nil { + return State{}, fmt.Errorf("set update binary permissions: %w", err) + } + } else if err := os.Chmod(newBinary, 0o755); err != nil { + return State{}, fmt.Errorf("set update binary permissions: %w", err) + } + if !options.SkipProbe { + if err := probeBinary(newBinary, result.Manifest.Version); err != nil { + return State{}, err + } + } + + backup := target + ".previous" + if err := copyFile(target, backup); err != nil { + return State{}, fmt.Errorf("backup current binary: %w", err) + } + if err := replaceExecutable(newBinary, target); err != nil { + return State{}, fmt.Errorf("replace executable: %w", err) + } + + state := State{ + CurrentVersion: c.Version, + InstalledVersion: result.Manifest.Version, + Target: target, + Backup: backup, + SHA256: strings.ToLower(asset.SHA256), + AppliedAt: time.Now().UTC(), + } + if err := writeState(stateDir, state); err != nil { + return state, fmt.Errorf("update installed but state could not be written: %w", err) + } + return state, nil +} + +func Rollback(target, stateDir string) (State, error) { + resolvedTarget, err := resolveTarget(target) + if err != nil { + return State{}, err + } + if strings.TrimSpace(stateDir) == "" { + stateDir, err = os.Getwd() + if err != nil { + return State{}, fmt.Errorf("locate update state directory: %w", err) + } + } + state, err := readState(stateDir) + if err != nil { + return State{}, err + } + if state.Target != resolvedTarget { + return State{}, fmt.Errorf("update state belongs to %s, not %s", state.Target, resolvedTarget) + } + if _, err := os.Stat(state.Backup); err != nil { + return State{}, fmt.Errorf("access rollback binary: %w", err) + } + + tmp, err := os.CreateTemp(filepath.Dir(resolvedTarget), ".webhook-rollback-*") + if err != nil { + return State{}, fmt.Errorf("create rollback file: %w", err) + } + tmpPath := tmp.Name() + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return State{}, err + } + defer os.Remove(tmpPath) + if err := copyFile(state.Backup, tmpPath); err != nil { + return State{}, fmt.Errorf("prepare rollback binary: %w", err) + } + if err := replaceExecutable(tmpPath, resolvedTarget); err != nil { + return State{}, fmt.Errorf("restore previous binary: %w", err) + } + + state.RolledBackAt = time.Now().UTC() + if err := writeState(stateDir, state); err != nil { + return state, fmt.Errorf("rollback completed but state could not be written: %w", err) + } + return state, nil +} + +func (c *Client) repository() string { + repository := strings.TrimSpace(c.Repository) + if repository == "" { + return DefaultRepository + } + return repository +} + +func (c *Client) baseURL() string { + if strings.TrimSpace(c.BaseURL) != "" { + return strings.TrimRight(c.BaseURL, "/") + } + return "https://github.com/" + c.repository() +} + +func (c *Client) releaseAssetURL(version, asset string) string { + version = strings.TrimSpace(version) + if version == "" || version == "latest" { + return c.baseURL() + "/releases/latest/download/" + asset + } + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + return c.baseURL() + "/releases/download/" + version + "/" + asset +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return &http.Client{Timeout: 30 * time.Second} +} + +func (c *Client) download(ctx context.Context, url string, limit int64) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", "webhook-updater/"+c.Version) + response, err := c.httpClient().Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status %s", response.Status) + } + data, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, fmt.Errorf("response exceeds %d bytes", limit) + } + return data, nil +} + +func (c *Client) downloadFile(ctx context.Context, url string, destination io.Writer, limit int64) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + request.Header.Set("User-Agent", "webhook-updater/"+c.Version) + response, err := c.httpClient().Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected HTTP status %s", response.Status) + } + if response.ContentLength > limit { + return fmt.Errorf("archive exceeds %d bytes", limit) + } + written, err := io.Copy(destination, io.LimitReader(response.Body, limit+1)) + if err != nil { + return err + } + if written > limit { + return fmt.Errorf("archive exceeds %d bytes", limit) + } + return nil +} + +func verifyManifestSignature(manifest, encodedSignature []byte, encodedPublicKey string) error { + publicKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encodedPublicKey)) + if err != nil || len(publicKey) != ed25519.PublicKeySize { + return errors.New("invalid update manifest public key") + } + signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(encodedSignature))) + if err != nil || len(signature) != ed25519.SignatureSize { + return errors.New("invalid update manifest signature encoding") + } + if !ed25519.Verify(ed25519.PublicKey(publicKey), manifest, signature) { + return errors.New("update manifest signature verification failed") + } + return nil +} + +func validateManifest(manifest Manifest) error { + if manifest.SchemaVersion != 1 { + return fmt.Errorf("unsupported update manifest schema %d", manifest.SchemaVersion) + } + if _, ok := parseVersion(manifest.Version); !ok { + return fmt.Errorf("invalid release version %q", manifest.Version) + } + if len(manifest.Assets) == 0 { + return errors.New("update manifest contains no assets") + } + for _, asset := range manifest.Assets { + if asset.OS == "" || asset.Arch == "" || filepath.Base(asset.Name) != asset.Name || asset.Size <= 0 { + return fmt.Errorf("invalid update asset %q", asset.Name) + } + decoded, err := hex.DecodeString(asset.SHA256) + if err != nil || len(decoded) != sha256.Size { + return fmt.Errorf("invalid SHA256 for update asset %q", asset.Name) + } + } + return nil +} + +func validateRepository(repository string) error { + parts := strings.Split(repository, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("invalid update repository %q", repository) + } + for _, part := range parts { + for _, ch := range part { + if !(ch >= 'a' && ch <= 'z') && !(ch >= 'A' && ch <= 'Z') && !(ch >= '0' && ch <= '9') && ch != '-' && ch != '_' && ch != '.' { + return fmt.Errorf("invalid update repository %q", repository) + } + } + } + return nil +} + +func validateRequestedVersion(version string) error { + version = strings.TrimSpace(version) + if version == "" || version == "latest" { + return nil + } + if _, ok := parseVersion(version); !ok { + return fmt.Errorf("invalid requested update version %q", version) + } + return nil +} + +func findAsset(manifest Manifest, goos, goarch string) (Asset, error) { + for _, asset := range manifest.Assets { + if asset.OS == goos && asset.Arch == goarch { + return asset, nil + } + } + return Asset{}, fmt.Errorf("release %s has no asset for %s/%s", manifest.Version, goos, goarch) +} + +func verifyFile(path string, asset Asset) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + hash := sha256.New() + size, err := io.Copy(hash, file) + if err != nil { + return err + } + if size != asset.Size { + return fmt.Errorf("update archive size mismatch: got %d, want %d", size, asset.Size) + } + actual := hex.EncodeToString(hash.Sum(nil)) + if !strings.EqualFold(actual, asset.SHA256) { + return errors.New("update archive SHA256 mismatch") + } + return nil +} + +func extractBinary(archivePath, destinationDir string, asset Asset, goos, goarch string) (string, error) { + archive, err := os.Open(archivePath) + if err != nil { + return "", err + } + defer archive.Close() + gzipReader, err := gzip.NewReader(archive) + if err != nil { + return "", fmt.Errorf("open update archive: %w", err) + } + defer gzipReader.Close() + + binaryName := "webhook" + if goos == "windows" { + binaryName += ".exe" + } + wanted := filepath.ToSlash("webhook-" + goos + "-" + goarch + "/" + binaryName) + tarReader := tar.NewReader(gzipReader) + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", fmt.Errorf("read update archive: %w", err) + } + cleanName := filepath.ToSlash(filepath.Clean(header.Name)) + if cleanName != wanted { + continue + } + if header.Typeflag != tar.TypeReg || header.Size <= 0 || header.Size > maxBinarySize { + return "", errors.New("update archive contains an invalid executable") + } + tmp, err := os.CreateTemp(destinationDir, ".webhook-new-*") + if err != nil { + return "", err + } + tmpPath := tmp.Name() + written, copyErr := io.Copy(tmp, io.LimitReader(tarReader, maxBinarySize+1)) + closeErr := tmp.Close() + if copyErr != nil || closeErr != nil || written != header.Size { + os.Remove(tmpPath) + if copyErr != nil { + return "", copyErr + } + if closeErr != nil { + return "", closeErr + } + return "", errors.New("update executable size mismatch") + } + return tmpPath, nil + } + return "", fmt.Errorf("update archive does not contain %s", wanted) +} + +func resolveTarget(target string) (string, error) { + var err error + if strings.TrimSpace(target) == "" { + target, err = os.Executable() + if err != nil { + return "", fmt.Errorf("locate current executable: %w", err) + } + } + target, err = filepath.Abs(target) + if err != nil { + return "", fmt.Errorf("resolve update target: %w", err) + } + if resolved, evalErr := filepath.EvalSymlinks(target); evalErr == nil { + target = resolved + } + return target, nil +} + +func probeBinary(path, expectedVersion string) error { + command := exec.Command(path, "-version") + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("run downloaded executable: %w", err) + } + if !strings.Contains(string(output), strings.TrimPrefix(expectedVersion, "v")) { + return fmt.Errorf("downloaded executable reported an unexpected version: %s", strings.TrimSpace(string(output))) + } + return nil +} + +func copyFile(source, destination string) error { + src, err := os.Open(source) + if err != nil { + return err + } + defer src.Close() + info, err := src.Stat() + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(destination), filepath.Base(destination)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(info.Mode().Perm()); err != nil { + tmp.Close() + return err + } + if _, err := io.Copy(tmp, src); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return replaceFile(tmpPath, destination) +} + +func statePath(stateDir string) string { + return filepath.Join(stateDir, ".webhook-update.json") +} + +func writeState(stateDir string, state State) error { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + tmp, err := os.CreateTemp(stateDir, ".webhook-update-state-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return replaceFile(tmpPath, statePath(stateDir)) +} + +func readState(stateDir string) (State, error) { + data, err := os.ReadFile(statePath(stateDir)) + if err != nil { + return State{}, fmt.Errorf("read update state: %w", err) + } + var state State + if err := json.Unmarshal(data, &state); err != nil { + return State{}, fmt.Errorf("decode update state: %w", err) + } + return state, nil +} + +type parsedVersion struct { + major int + minor int + patch int + pre []string +} + +func CompareVersions(left, right string) int { + l, lok := parseVersion(left) + r, rok := parseVersion(right) + if !lok && !rok { + return strings.Compare(left, right) + } + if !lok { + return -1 + } + if !rok { + return 1 + } + for _, pair := range [][2]int{{l.major, r.major}, {l.minor, r.minor}, {l.patch, r.patch}} { + if pair[0] < pair[1] { + return -1 + } + if pair[0] > pair[1] { + return 1 + } + } + if len(l.pre) == 0 && len(r.pre) != 0 { + return 1 + } + if len(l.pre) != 0 && len(r.pre) == 0 { + return -1 + } + for i := 0; i < len(l.pre) && i < len(r.pre); i++ { + if l.pre[i] == r.pre[i] { + continue + } + li, lerr := strconv.Atoi(l.pre[i]) + ri, rerr := strconv.Atoi(r.pre[i]) + switch { + case lerr == nil && rerr == nil: + if li < ri { + return -1 + } + return 1 + case lerr == nil: + return -1 + case rerr == nil: + return 1 + default: + return strings.Compare(l.pre[i], r.pre[i]) + } + } + if len(l.pre) < len(r.pre) { + return -1 + } + if len(l.pre) > len(r.pre) { + return 1 + } + return 0 +} + +func parseVersion(value string) (parsedVersion, bool) { + value = strings.TrimSpace(strings.TrimPrefix(value, "v")) + if buildIndex := strings.IndexByte(value, '+'); buildIndex >= 0 { + value = value[:buildIndex] + } + var pre []string + if preIndex := strings.IndexByte(value, '-'); preIndex >= 0 { + pre = strings.Split(value[preIndex+1:], ".") + value = value[:preIndex] + } + parts := strings.Split(value, ".") + if len(parts) != 3 { + return parsedVersion{}, false + } + numbers := make([]int, 3) + for i, part := range parts { + if part == "" || (len(part) > 1 && part[0] == '0') { + return parsedVersion{}, false + } + number, err := strconv.Atoi(part) + if err != nil || number < 0 { + return parsedVersion{}, false + } + numbers[i] = number + } + for _, identifier := range pre { + if identifier == "" { + return parsedVersion{}, false + } + } + return parsedVersion{major: numbers[0], minor: numbers[1], patch: numbers[2], pre: pre}, true +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 00000000..9036f7ea --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,210 @@ +package update + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestCompareVersions(t *testing.T) { + tests := []struct { + left string + right string + want int + }{ + {left: "v2.8.4", right: "2.8.3", want: 1}, + {left: "2.8.3", right: "v2.8.3", want: 0}, + {left: "2.8.3-rc.1", right: "2.8.3", want: -1}, + {left: "2.8.3-rc.2", right: "2.8.3-rc.1", want: 1}, + {left: "dev", right: "2.8.3", want: -1}, + } + for _, test := range tests { + if got := CompareVersions(test.left, test.right); normalizeComparison(got) != test.want { + t.Errorf("CompareVersions(%q, %q) = %d, want %d", test.left, test.right, got, test.want) + } + } +} + +func TestCheckVerifiesSignedManifest(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + manifest := testManifest(t, Asset{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Name: "webhook-test.tar.gz", + Size: 1, + SHA256: hex.EncodeToString(make([]byte, sha256.Size)), + }) + signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifest)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch filepath.Base(r.URL.Path) { + case manifestName: + _, _ = w.Write(manifest) + case signatureName: + _, _ = w.Write([]byte(signature)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := Client{ + Repository: DefaultRepository, + Version: "2.8.3", + PublicKey: base64.StdEncoding.EncodeToString(publicKey), + HTTPClient: server.Client(), + BaseURL: server.URL, + } + result, err := client.Check(context.Background(), "") + if err != nil { + t.Fatalf("Check: %v", err) + } + if !result.Verified || !result.Available || result.LatestVersion != "v2.8.4" { + t.Fatalf("unexpected check result: %+v", result) + } +} + +func TestApplyAndRollback(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test executable is a POSIX shell script") + } + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + binary := []byte("#!/bin/sh\necho 'webhook version 2.8.4'\n") + archive := testArchive(t, runtime.GOOS, runtime.GOARCH, binary) + digest := sha256.Sum256(archive) + asset := Asset{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Name: "webhook-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz", + Size: int64(len(archive)), + SHA256: hex.EncodeToString(digest[:]), + } + manifest := testManifest(t, asset) + signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifest)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch filepath.Base(r.URL.Path) { + case manifestName: + _, _ = w.Write(manifest) + case signatureName: + _, _ = w.Write([]byte(signature)) + case asset.Name: + _, _ = w.Write(archive) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + directory := t.TempDir() + target := filepath.Join(directory, "webhook") + oldBinary := []byte("#!/bin/sh\necho 'webhook version 2.8.3'\n") + if err := os.WriteFile(target, oldBinary, 0o755); err != nil { + t.Fatal(err) + } + + client := Client{ + Repository: DefaultRepository, + Version: "2.8.3", + PublicKey: base64.StdEncoding.EncodeToString(publicKey), + HTTPClient: server.Client(), + BaseURL: server.URL, + } + state, err := client.Apply(context.Background(), ApplyOptions{Target: target, StateDir: directory}) + if err != nil { + t.Fatalf("Apply: %v", err) + } + if state.InstalledVersion != "v2.8.4" { + t.Fatalf("installed version = %q", state.InstalledVersion) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, binary) { + t.Fatal("target does not contain the new binary") + } + + if _, err := Rollback(target, directory); err != nil { + t.Fatalf("Rollback: %v", err) + } + got, err = os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, oldBinary) { + t.Fatal("target does not contain the restored binary") + } +} + +func testManifest(t *testing.T, asset Asset) []byte { + t.Helper() + manifest := Manifest{ + SchemaVersion: 1, + Version: "v2.8.4", + PublishedAt: time.Date(2026, 8, 28, 0, 0, 0, 0, time.UTC), + Commit: "abc123", + ReleaseURL: "https://github.com/xtulnx/webhook/releases/tag/v2.8.4", + Assets: []Asset{asset}, + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + return data +} + +func testArchive(t *testing.T, goos, goarch string, binary []byte) []byte { + t.Helper() + var buffer bytes.Buffer + gzipWriter := gzip.NewWriter(&buffer) + tarWriter := tar.NewWriter(gzipWriter) + name := "webhook-" + goos + "-" + goarch + "/webhook" + if goos == "windows" { + name += ".exe" + } + if err := tarWriter.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(binary)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tarWriter.Write(binary); err != nil { + t.Fatal(err) + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func normalizeComparison(value int) int { + if value < 0 { + return -1 + } + if value > 0 { + return 1 + } + return 0 +} diff --git a/scripts/generate-update-key.sh b/scripts/generate-update-key.sh new file mode 100755 index 00000000..e9510e3d --- /dev/null +++ b/scripts/generate-update-key.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT INT TERM + +openssl genpkey -algorithm ED25519 -out "$tmp/private.pem" +openssl pkey -in "$tmp/private.pem" -pubout -outform DER -out "$tmp/public.der" + +echo "GitHub Actions secret UPDATE_SIGNING_PRIVATE_KEY:" +cat "$tmp/private.pem" +echo +echo "GitHub Actions variable UPDATE_MANIFEST_PUBLIC_KEY:" +tail -c 32 "$tmp/public.der" | base64 | tr -d '\n' +echo diff --git a/update_command.go b/update_command.go new file mode 100644 index 00000000..24920151 --- /dev/null +++ b/update_command.go @@ -0,0 +1,199 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + webhookupdate "github.com/adnanh/webhook/internal/update" +) + +var ( + updateEnabled = flag.Bool("update-enabled", true, "enable update checks in the admin API") + updateRepository = flag.String("update-repository", webhookupdate.DefaultRepository, "GitHub repository used for updates") + updateStateDir = flag.String("update-state-dir", "", "directory for update state; defaults to the executable directory") + + updateManifestPublicKey string +) + +func isUpdateCommand(args []string) bool { + return len(args) > 1 && args[1] == "update" +} + +func newUpdateClient(repository string) *webhookupdate.Client { + return &webhookupdate.Client{ + Repository: repository, + Version: version, + PublicKey: updateManifestPublicKey, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +func runUpdateCommand(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + if len(args) == 0 { + writeUpdateUsage(stderr) + return 2 + } + if args[0] == "-h" || args[0] == "--help" { + writeUpdateUsage(stdout) + return 0 + } + + switch args[0] { + case "check": + return runUpdateCheck(args[1:], stdout, stderr) + case "apply": + return runUpdateApply(args[1:], stdin, stdout, stderr) + case "rollback": + return runUpdateRollback(args[1:], stdout, stderr) + case "help": + writeUpdateUsage(stdout) + return 0 + default: + fmt.Fprintf(stderr, "unknown update command %q\n", args[0]) + writeUpdateUsage(stderr) + return 2 + } +} + +func runUpdateCheck(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("webhook update check", flag.ContinueOnError) + flags.SetOutput(stderr) + repository := flags.String("repository", webhookupdate.DefaultRepository, "GitHub repository") + requestedVersion := flags.String("version", "", "release version; defaults to latest") + jsonOutput := flags.Bool("json", false, "write JSON output") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintln(stderr, "update check does not accept positional arguments") + return 2 + } + + result, err := newUpdateClient(*repository).Check(context.Background(), *requestedVersion) + if err != nil { + fmt.Fprintln(stderr, "update check failed:", err) + return 1 + } + if *jsonOutput { + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(result); err != nil { + fmt.Fprintln(stderr, "encode update result:", err) + return 1 + } + return 0 + } + + fmt.Fprintf(stdout, "Current version: %s\n", result.CurrentVersion) + fmt.Fprintf(stdout, "Latest version: %s\n", result.LatestVersion) + if result.Available { + fmt.Fprintln(stdout, "Update available: yes") + } else { + fmt.Fprintln(stdout, "Update available: no") + } + if result.Verified { + fmt.Fprintln(stdout, "Manifest signature: verified") + } else { + fmt.Fprintln(stdout, "Manifest signature: unavailable in this build") + } + return 0 +} + +func runUpdateApply(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("webhook update apply", flag.ContinueOnError) + flags.SetOutput(stderr) + repository := flags.String("repository", webhookupdate.DefaultRepository, "GitHub repository") + requestedVersion := flags.String("version", "", "release version; defaults to latest") + target := flags.String("target", "", "executable to replace; defaults to the current executable") + stateDir := flags.String("state-dir", "", "update state directory; defaults to the current working directory") + yes := flags.Bool("yes", false, "apply without interactive confirmation") + allowUnsigned := flags.Bool("allow-unsigned", false, "allow applying a manifest without a configured signature key") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintln(stderr, "update apply does not accept positional arguments") + return 2 + } + + client := newUpdateClient(*repository) + result, err := client.Check(context.Background(), *requestedVersion) + if err != nil { + fmt.Fprintln(stderr, "update check failed:", err) + return 1 + } + if !result.Verified && !*allowUnsigned { + fmt.Fprintln(stderr, "update cannot be applied because this build has no trusted manifest public key") + return 1 + } + if *requestedVersion == "" && !result.Available { + fmt.Fprintf(stdout, "webhook %s is already up to date.\n", version) + return 0 + } + if !*yes { + fmt.Fprintf(stdout, "Replace webhook %s with %s? [y/N] ", version, result.LatestVersion) + answer, _ := bufio.NewReader(stdin).ReadString('\n') + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer != "y" && answer != "yes" { + fmt.Fprintln(stdout, "Update cancelled.") + return 0 + } + } + + state, err := client.Apply(context.Background(), webhookupdate.ApplyOptions{ + Version: result.LatestVersion, + Target: *target, + StateDir: *stateDir, + AllowUnsigned: *allowUnsigned, + }) + if err != nil { + fmt.Fprintln(stderr, "update failed:", err) + return 1 + } + fmt.Fprintf(stdout, "Updated webhook from %s to %s.\n", state.CurrentVersion, state.InstalledVersion) + fmt.Fprintln(stdout, "Restart the running webhook service to activate the new version.") + return 0 +} + +func runUpdateRollback(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("webhook update rollback", flag.ContinueOnError) + flags.SetOutput(stderr) + target := flags.String("target", "", "executable to restore; defaults to the current executable") + stateDir := flags.String("state-dir", "", "update state directory; defaults to the current working directory") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintln(stderr, "update rollback does not accept positional arguments") + return 2 + } + + state, err := webhookupdate.Rollback(*target, *stateDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + fmt.Fprintln(stderr, "rollback is unavailable: no previous update state was found") + } else { + fmt.Fprintln(stderr, "rollback failed:", err) + } + return 1 + } + fmt.Fprintf(stdout, "Restored webhook %s over %s.\n", state.CurrentVersion, state.InstalledVersion) + fmt.Fprintln(stdout, "Restart the running webhook service to activate the restored version.") + return 0 +} + +func writeUpdateUsage(output io.Writer) { + fmt.Fprintln(output, "Usage:") + fmt.Fprintln(output, " webhook update check [--repository owner/name] [--version vX.Y.Z] [--json]") + fmt.Fprintln(output, " webhook update apply [--repository owner/name] [--version vX.Y.Z] [--state-dir path] [--yes] [--allow-unsigned]") + fmt.Fprintln(output, " webhook update rollback [--state-dir path]") +} diff --git a/webhook.go b/webhook.go index 85d0ec96..fc3a0ea9 100644 --- a/webhook.go +++ b/webhook.go @@ -23,7 +23,9 @@ import ( "github.com/gorilla/mux" ) -var version = "2.8.3" +var ( + version = "2.8.3" +) var ( ip = flag.String("ip", "0.0.0.0", "ip the webhook should serve hooks on") @@ -92,6 +94,10 @@ func lenLoadedHooks() int { } func main() { + if isUpdateCommand(os.Args) { + os.Exit(runUpdateCommand(os.Args[2:], os.Stdin, os.Stdout, os.Stderr)) + } + flag.Var(&hooksFiles, "hooks", "path to the json file containing defined hooks the webhook should serve, use multiple times to load from different files") flag.Var(&hooksDirectories, "hooks-dir", "directory containing JSON or YAML hooks files; use multiple times to load and watch multiple directories") flag.Var(&responseHeaders, "header", "response header to return, specified in format name=value, use multiple times to set multiple headers") diff --git a/webhook.yaml.example b/webhook.yaml.example index e959a57a..965d8838 100644 --- a/webhook.yaml.example +++ b/webhook.yaml.example @@ -72,6 +72,12 @@ hotreload: true # 服务状态接口路径,默认可通过 GET /status 查看运行时间、已加载 hook 和最近加载时间 # status-path: status +# Update checks use xtulnx/webhook by default. State files use the current +# working directory unless update-state-dir is set. +update-enabled: true +update-repository: xtulnx/webhook +# update-state-dir: /var/lib/webhook/update + # 将 hook 文件作为 Go template 解析 # 默认 false # template: false From 5e2c416716e87d4050da02642a298c67cc230729 Mon Sep 17 00:00:00 2001 From: "jason.liao" Date: Tue, 1 Sep 2026 18:54:13 +0800 Subject: [PATCH 13/13] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E6=8E=A7=E5=88=B6=E4=B8=8E=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Webhook-Parameters.md | 12 ++++++ internal/hook/hook.go | 35 ++++++++++------ realip.go | 81 +++++++++++++++++++++++++++++--------- realip_test.go | 80 +++++++++++++++++++++++++++++++++++++ webhook.go | 40 +++++++++++++++++-- webhook.yaml.example | 7 ++++ 6 files changed, 222 insertions(+), 33 deletions(-) create mode 100644 realip_test.go diff --git a/docs/Webhook-Parameters.md b/docs/Webhook-Parameters.md index c8dd6250..73b77cc9 100644 --- a/docs/Webhook-Parameters.md +++ b/docs/Webhook-Parameters.md @@ -1,6 +1,10 @@ # Webhook parameters ``` Usage of webhook: + -access-blacklist string + comma-separated list of client IPs or CIDRs denied from accessing the service + -access-whitelist string + comma-separated list of client IPs or CIDRs allowed to access the service -cert string path to the HTTPS certificate pem file (default "cert.pem") -cipher-suites string @@ -29,6 +33,8 @@ Usage of webhook: send log output to a file; implicitly enables verbose logging -max-concurrency int default maximum number of concurrent executions per hook; 0 disables the limit + -max-body-size int + maximum webhook request body size in bytes (0 disables the limit) (default 10485760) -max-multipart-mem int maximum memory in bytes for parsing multipart form data before disk caching (default 1048576) -nopanic @@ -73,6 +79,12 @@ Use any of the above specified flags to override their default behavior. `-command-timeout` and `-max-concurrency` act as defaults for all hooks. Individual hooks can override them using the `command-timeout` and `max-concurrency` hook properties. Within a hook definition, `0` explicitly disables the inherited limit. +## Access control and request limits + +`-access-whitelist` and `-access-blacklist` apply to every HTTP endpoint, including hooks, status, and the admin API. Values may be IPv4/IPv6 addresses or CIDR ranges, separated by commas. Configure only one; when a whitelist is configured, all other addresses are denied. IP matching uses the resolved client address, and only a request from a `-trusted-proxies` address may supply `-real-ip-header`. + +`-max-body-size` limits non-multipart and multipart request bodies before parsing. It defaults to 10 MiB; set it to `0` only when an unlimited body is explicitly required. + # Live reloading hooks If you are running an OS that supports the HUP or USR1 signal, you can use it to trigger hooks reload from hooks file, without restarting the webhook instance. ```bash diff --git a/internal/hook/hook.go b/internal/hook/hook.go index f87dce40..bff97f57 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -281,26 +281,37 @@ func CheckScalrSignature(r *Request, signingKey string, checkDate bool) (bool, e // CheckIPWhitelist makes sure the provided remote address (of the form IP:port) falls within the provided IP range // (in CIDR form or a single IP address). func CheckIPWhitelist(remoteAddr, ipRange string) (bool, error) { - // Extract IP address from remote address. - - // IPv6 addresses will likely be surrounded by []. - ip := strings.Trim(remoteAddr, " []") - - if i := strings.LastIndex(ip, ":"); i != -1 { - ip = ip[:i] - ip = strings.Trim(ip, " []") + addr := strings.TrimSpace(remoteAddr) + var ipText string + if strings.HasPrefix(addr, "[") { + if end := strings.IndexByte(addr, ']'); end >= 0 { + ipText = strings.TrimSpace(addr[1:end]) + if host, _, err := net.SplitHostPort(ipText); err == nil { + ipText = host + } + } } - - parsedIP := net.ParseIP(ip) + if ipText == "" { + if host, _, err := net.SplitHostPort(addr); err == nil { + ipText = host + } else { + ipText = strings.Trim(addr, " []") + } + } + parsedIP := net.ParseIP(strings.TrimSpace(ipText)) if parsedIP == nil { return false, fmt.Errorf("invalid IP address found in remote address '%s'", remoteAddr) } - for _, r := range strings.Fields(ipRange) { + for _, r := range strings.FieldsFunc(ipRange, func(ch rune) bool { return ch == ',' || ch == ' ' || ch == '\t' || ch == '\n' }) { // Extract IP range in CIDR form. If a single IP address is provided, turn it into CIDR form. if !strings.Contains(r, "/") { - r = r + "/32" + if net.ParseIP(r).To4() != nil { + r += "/32" + } else { + r += "/128" + } } _, cidr, err := net.ParseCIDR(r) diff --git a/realip.go b/realip.go index bca60e63..2b725d87 100644 --- a/realip.go +++ b/realip.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "net" "net/http" "strings" @@ -8,35 +9,79 @@ import ( // parsedTrustedProxies holds the parsed CIDR networks from the --trusted-proxies flag. var parsedTrustedProxies []*net.IPNet +var parsedAccessWhitelist []*net.IPNet +var parsedAccessBlacklist []*net.IPNet // initTrustedProxies parses the --trusted-proxies flag value into CIDR networks. // Must be called after flag.Parse(). -func initTrustedProxies() { - if *trustedProxies == "" { - return - } - - for _, entry := range strings.Split(*trustedProxies, ",") { - entry = strings.TrimSpace(entry) - if entry == "" { - continue - } - - // If it's a plain IP without CIDR notation, add /32 (IPv4) or /128 (IPv6). +func parseIPNetworks(value, name string) ([]*net.IPNet, error) { + var networks []*net.IPNet + for _, entry := range strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) { if !strings.Contains(entry, "/") { - if strings.Contains(entry, ":") { - entry += "/128" - } else { + ip := net.ParseIP(entry) + if ip == nil { + return nil, fmt.Errorf("invalid %s entry %q", name, entry) + } + if ip.To4() != nil { entry += "/32" + } else { + entry += "/128" } } - _, cidr, err := net.ParseCIDR(entry) if err != nil { - continue + return nil, fmt.Errorf("invalid %s entry %q: %w", name, entry, err) + } + networks = append(networks, cidr) + } + return networks, nil +} + +func initTrustedProxies() error { + var err error + parsedTrustedProxies, err = parseIPNetworks(*trustedProxies, "trusted-proxies") + return err +} + +func initAccessControl() error { + if strings.TrimSpace(*accessWhitelist) != "" && strings.TrimSpace(*accessBlacklist) != "" { + return fmt.Errorf("access-whitelist and access-blacklist cannot be used together") + } + var err error + parsedAccessWhitelist, err = parseIPNetworks(*accessWhitelist, "access-whitelist") + if err != nil { + return err + } + parsedAccessBlacklist, err = parseIPNetworks(*accessBlacklist, "access-blacklist") + return err +} + +func accessControlMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := extractIP(resolveRealIP(r)) + allowed := true + if len(parsedAccessWhitelist) > 0 { + allowed = ip != nil && containsIP(parsedAccessWhitelist, ip) + } else if len(parsedAccessBlacklist) > 0 { + allowed = ip != nil && !containsIP(parsedAccessBlacklist, ip) + } + if !allowed { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("Access denied.")) + return + } + next.ServeHTTP(w, r) + }) +} + +func containsIP(networks []*net.IPNet, ip net.IP) bool { + for _, network := range networks { + if network.Contains(ip) { + return true } - parsedTrustedProxies = append(parsedTrustedProxies, cidr) } + return false } // isTrustedProxy checks whether the given remote address (IP:port) belongs to diff --git a/realip_test.go b/realip_test.go new file mode 100644 index 00000000..41743bc5 --- /dev/null +++ b/realip_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestInitAccessControl(t *testing.T) { + savedWhitelist, savedBlacklist := *accessWhitelist, *accessBlacklist + savedWL, savedBL := parsedAccessWhitelist, parsedAccessBlacklist + defer func() { + *accessWhitelist, *accessBlacklist = savedWhitelist, savedBlacklist + parsedAccessWhitelist, parsedAccessBlacklist = savedWL, savedBL + }() + + *accessWhitelist, *accessBlacklist = "10.0.0.0/8", "" + if err := initAccessControl(); err != nil { + t.Fatal(err) + } + if len(parsedAccessWhitelist) != 1 { + t.Fatalf("whitelist entries = %d, want 1", len(parsedAccessWhitelist)) + } + + *accessWhitelist, *accessBlacklist = "10.0.0.1", "192.0.2.1" + if err := initAccessControl(); err == nil { + t.Fatal("expected whitelist/blacklist conflict") + } +} + +func TestAccessControlMiddleware(t *testing.T) { + savedWhitelist, savedBlacklist := *accessWhitelist, *accessBlacklist + savedWL, savedBL := parsedAccessWhitelist, parsedAccessBlacklist + defer func() { + *accessWhitelist, *accessBlacklist = savedWhitelist, savedBlacklist + parsedAccessWhitelist, parsedAccessBlacklist = savedWL, savedBL + }() + + *accessWhitelist, *accessBlacklist = "192.0.2.0/24", "" + if err := initAccessControl(); err != nil { + t.Fatal(err) + } + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) + handler := accessControlMiddleware(next) + + allowed := httptest.NewRequest(http.MethodGet, "/", nil) + allowed.RemoteAddr = "192.0.2.10:1234" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, allowed) + if rec.Code != http.StatusNoContent { + t.Fatalf("allowed status = %d", rec.Code) + } + + denied := httptest.NewRequest(http.MethodGet, "/", nil) + denied.RemoteAddr = "198.51.100.10:1234" + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, denied) + if rec.Code != http.StatusForbidden { + t.Fatalf("denied status = %d, want 403", rec.Code) + } +} + +func TestResolveRealIPOnlyTrustedProxy(t *testing.T) { + savedHeader, savedTrusted := *realIPHeader, parsedTrustedProxies + defer func() { *realIPHeader, parsedTrustedProxies = savedHeader, savedTrusted }() + *realIPHeader = "X-Real-IP" + parsedTrustedProxies, _ = parseIPNetworks("127.0.0.1", "trusted-proxies") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "127.0.0.1:80" + req.Header.Set("X-Real-IP", "203.0.113.7") + if got := resolveRealIP(req); got != "203.0.113.7" { + t.Fatalf("trusted real IP = %q", got) + } + + req.RemoteAddr = "198.51.100.1:80" + if got := resolveRealIP(req); got != req.RemoteAddr { + t.Fatalf("untrusted real IP = %q, want %q", got, req.RemoteAddr) + } +} diff --git a/webhook.go b/webhook.go index fc3a0ea9..5f614540 100644 --- a/webhook.go +++ b/webhook.go @@ -52,6 +52,9 @@ var ( pidPath = flag.String("pidfile", "", "create PID file at the given path") realIPHeader = flag.String("real-ip-header", "", "header to extract real client IP from when behind a reverse proxy (e.g. X-Real-Ip)") trustedProxies = flag.String("trusted-proxies", "", "comma-separated list of trusted proxy IPs or CIDRs; required for real-ip-header to take effect") + accessWhitelist = flag.String("access-whitelist", "", "comma-separated list of client IPs or CIDRs allowed to access the service") + accessBlacklist = flag.String("access-blacklist", "", "comma-separated list of client IPs or CIDRs denied from accessing the service") + maxBodySize = flag.Int64("max-body-size", 10<<20, "maximum webhook request body size in bytes (0 disables the limit)") responseHeaders hook.ResponseHeaders hooksFiles hook.HooksFiles @@ -116,7 +119,18 @@ func main() { flag.Parse() - initTrustedProxies() + if err := initTrustedProxies(); err != nil { + fmt.Println("error:", err) + os.Exit(1) + } + if err := initAccessControl(); err != nil { + fmt.Println("error:", err) + os.Exit(1) + } + if *maxBodySize < 0 { + fmt.Println("error: max-body-size must be zero or greater") + os.Exit(1) + } if err := initExecutionSettings(); err != nil { fmt.Println("error:", err) @@ -190,12 +204,17 @@ func main() { } if *logPath != "" { - file, err := os.OpenFile(*logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) + file, err := os.OpenFile(*logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { logQueue = append(logQueue, fmt.Sprintf("error opening log file %q: %v", *logPath, err)) // we'll bail out below } else { - log.SetOutput(file) + if err := file.Chmod(0o600); err != nil { + logQueue = append(logQueue, fmt.Sprintf("error securing log file %q: %v", *logPath, err)) + _ = file.Close() + } else { + log.SetOutput(file) + } } } @@ -262,6 +281,7 @@ func main() { )) r.Use(middleware.NewLogger()) r.Use(chimiddleware.Recoverer) + r.Use(accessControlMiddleware) if *debug { r.Use(middleware.Dumper(log.Writer())) @@ -317,6 +337,10 @@ func main() { } func hookHandler(w http.ResponseWriter, r *http.Request) { + if *maxBodySize > 0 { + r.Body = http.MaxBytesReader(w, r.Body, *maxBodySize) + } + req := &hook.Request{ ID: middleware.GetReqID(r.Context()), RawRequest: r, @@ -386,6 +410,11 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { req.Body, err = ioutil.ReadAll(r.Body) if err != nil { log.Printf("[%s] error reading the request body: %+v\n", req.ID, err) + if *maxBodySize > 0 { + w.WriteHeader(http.StatusRequestEntityTooLarge) + fmt.Fprint(w, "Request body too large.") + return + } } } @@ -416,6 +445,11 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { if err != nil { msg := fmt.Sprintf("[%s] error parsing multipart form: %+v\n", req.ID, err) log.Println(msg) + if _, ok := err.(*http.MaxBytesError); ok { + w.WriteHeader(http.StatusRequestEntityTooLarge) + fmt.Fprint(w, "Request body too large.") + return + } w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, "Error occurred while parsing multipart form.") return diff --git a/webhook.yaml.example b/webhook.yaml.example index 965d8838..0d8b0740 100644 --- a/webhook.yaml.example +++ b/webhook.yaml.example @@ -24,6 +24,10 @@ urlprefix: wh # 留空表示允许所有方法 # http-methods: "POST" +# 全局访问控制(二选一;支持 IP 或 CIDR,逗号分隔) +# access-whitelist: "203.0.113.0/24" +# access-blacklist: "198.51.100.10" + # ============================================================ # 反向代理 / 真实 IP # ============================================================ @@ -86,6 +90,9 @@ update-repository: xtulnx/webhook # 默认 1048576 (1MB) # max-multipart-mem: 1048576 +# 单个请求体最大字节数,默认 10 MiB;设为 0 表示不限制 +# max-body-size: 10485760 + # ============================================================ # 日志设置 # ============================================================