package track4 import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "time" ) type runScriptRequest struct { Script string `json:"script"` Args []string `json:"args"` } // HandleRunScript handles POST /api/v1/track4/operator/run-script // Requires Track 4 auth, IP whitelist, OPERATOR_SCRIPTS_ROOT, and OPERATOR_SCRIPT_ALLOWLIST. func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") return } operatorAddr, _ := r.Context().Value("user_address").(string) if operatorAddr == "" { writeError(w, http.StatusUnauthorized, "unauthorized", "Operator address required") return } ipAddr := clientIPAddress(r) if whitelisted, _ := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr); !whitelisted { writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted") return } root := strings.TrimSpace(os.Getenv("OPERATOR_SCRIPTS_ROOT")) if root == "" { writeError(w, http.StatusServiceUnavailable, "service_unavailable", "OPERATOR_SCRIPTS_ROOT not configured") return } rootAbs, err := filepath.Abs(root) if err != nil || rootAbs == "" { writeError(w, http.StatusInternalServerError, "internal_error", "invalid OPERATOR_SCRIPTS_ROOT") return } allowRaw := strings.TrimSpace(os.Getenv("OPERATOR_SCRIPT_ALLOWLIST")) if allowRaw == "" { writeError(w, http.StatusServiceUnavailable, "service_unavailable", "OPERATOR_SCRIPT_ALLOWLIST not configured") return } var allow []string for _, p := range strings.Split(allowRaw, ",") { p = strings.TrimSpace(p) if p != "" { allow = append(allow, p) } } if len(allow) == 0 { writeError(w, http.StatusServiceUnavailable, "service_unavailable", "OPERATOR_SCRIPT_ALLOWLIST empty") return } var reqBody runScriptRequest dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(&reqBody); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON body") return } script := strings.TrimSpace(reqBody.Script) if script == "" || strings.Contains(script, "..") { writeError(w, http.StatusBadRequest, "bad_request", "invalid script path") return } if len(reqBody.Args) > 24 { writeError(w, http.StatusBadRequest, "bad_request", "too many args (max 24)") return } for _, a := range reqBody.Args { if strings.Contains(a, "\x00") { writeError(w, http.StatusBadRequest, "bad_request", "invalid arg") return } } candidate := filepath.Join(rootAbs, filepath.Clean(script)) if rel, err := filepath.Rel(rootAbs, candidate); err != nil || strings.HasPrefix(rel, "..") { writeError(w, http.StatusForbidden, "forbidden", "script outside OPERATOR_SCRIPTS_ROOT") return } relPath, _ := filepath.Rel(rootAbs, candidate) allowed := false base := filepath.Base(relPath) for _, a := range allow { if a == relPath || a == base || filepath.Clean(a) == relPath { allowed = true break } } if !allowed { writeError(w, http.StatusForbidden, "forbidden", "script not in OPERATOR_SCRIPT_ALLOWLIST") return } st, err := os.Stat(candidate) if err != nil || st.IsDir() { writeError(w, http.StatusNotFound, "not_found", "script not found") return } isShell := strings.HasSuffix(strings.ToLower(candidate), ".sh") if !isShell && st.Mode()&0o111 == 0 { writeError(w, http.StatusForbidden, "forbidden", "refusing to run non-executable file (use .sh or chmod +x)") return } timeout := 120 * time.Second if v := strings.TrimSpace(os.Getenv("OPERATOR_SCRIPT_TIMEOUT_SEC")); v != "" { if sec, err := parsePositiveInt(v); err == nil && sec > 0 && sec < 600 { timeout = time.Duration(sec) * time.Second } } ctx, cancel := context.WithTimeout(r.Context(), timeout) defer cancel() s.roleMgr.LogOperatorEvent(r.Context(), "operator_script_run", &s.chainID, operatorAddr, "operator/run-script", "execute", map[string]interface{}{ "script": relPath, "argc": len(reqBody.Args), }, ipAddr, r.UserAgent()) var cmd *exec.Cmd if isShell { args := append([]string{candidate}, reqBody.Args...) cmd = exec.CommandContext(ctx, "/bin/bash", args...) } else { cmd = exec.CommandContext(ctx, candidate, reqBody.Args...) } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr runErr := cmd.Run() exit := 0 timedOut := errors.Is(ctx.Err(), context.DeadlineExceeded) if runErr != nil { var ee *exec.ExitError if errors.As(runErr, &ee) { exit = ee.ExitCode() } else if timedOut { exit = -1 } else { writeError(w, http.StatusInternalServerError, "internal_error", runErr.Error()) return } } status := "ok" if timedOut { status = "timed_out" } else if exit != 0 { status = "nonzero_exit" } s.roleMgr.LogOperatorEvent(r.Context(), "operator_script_result", &s.chainID, operatorAddr, "operator/run-script", status, map[string]interface{}{ "script": relPath, "argc": len(reqBody.Args), "exit_code": exit, "timed_out": timedOut, "stdout_bytes": stdout.Len(), "stderr_bytes": stderr.Len(), }, ipAddr, r.UserAgent()) resp := map[string]interface{}{ "data": map[string]interface{}{ "script": relPath, "exit_code": exit, "stdout": strings.TrimSpace(stdout.String()), "stderr": strings.TrimSpace(stderr.String()), "timed_out": timedOut, }, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } func parsePositiveInt(s string) (int, error) { var n int for _, c := range s { if c < '0' || c > '9' { return 0, errors.New("not digits") } n = n*10 + int(c-'0') if n > 1e6 { return 0, errors.New("too large") } } if n == 0 { return 0, errors.New("zero") } return n, nil }