feat: explorer API, wallet, CCIP scripts, and config refresh
- Backend REST/gateway/track routes, analytics, Blockscout proxy paths. - Frontend wallet and liquidity surfaces; MetaMask token list alignment. - Deployment docs, verification scripts, address inventory updates. Check: go build ./... under backend/ (pass). Made-with: Cursor
This commit is contained in:
209
backend/api/track4/operator_scripts.go
Normal file
209
backend/api/track4/operator_scripts.go
Normal file
@@ -0,0 +1,209 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user