- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation. - Changed default base URL for Playwright tests and updated security headers to reflect the new branding. - Enhanced README and API documentation to include new authentication endpoints and product access details. This refactor aligns the project branding and improves clarity in the API documentation.
257 lines
6.8 KiB
Go
257 lines
6.8 KiB
Go
package track4
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type runScriptRequest struct {
|
|
Script string `json:"script"`
|
|
Args []string `json:"args"`
|
|
}
|
|
|
|
const maxOperatorScriptOutputBytes = 64 << 10
|
|
|
|
type cappedBuffer struct {
|
|
buf bytes.Buffer
|
|
maxBytes int
|
|
truncated bool
|
|
}
|
|
|
|
func (c *cappedBuffer) Write(p []byte) (int, error) {
|
|
if len(p) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
remaining := c.maxBytes - c.buf.Len()
|
|
if remaining > 0 {
|
|
if len(p) > remaining {
|
|
_, _ = c.buf.Write(p[:remaining])
|
|
c.truncated = true
|
|
return len(p), nil
|
|
}
|
|
_, _ = c.buf.Write(p)
|
|
return len(p), nil
|
|
}
|
|
|
|
c.truncated = true
|
|
return len(p), nil
|
|
}
|
|
|
|
func (c *cappedBuffer) String() string {
|
|
if !c.truncated {
|
|
return c.buf.String()
|
|
}
|
|
return fmt.Sprintf("%s\n[truncated after %d bytes]", c.buf.String(), c.maxBytes)
|
|
}
|
|
|
|
func (c *cappedBuffer) Len() int {
|
|
return c.buf.Len()
|
|
}
|
|
|
|
// 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)
|
|
relPath = filepath.Clean(filepath.ToSlash(relPath))
|
|
allowed := false
|
|
for _, a := range allow {
|
|
normalizedAllow := filepath.Clean(filepath.ToSlash(a))
|
|
if normalizedAllow == 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 cappedBuffer
|
|
stdout.maxBytes = maxOperatorScriptOutputBytes
|
|
stderr.maxBytes = maxOperatorScriptOutputBytes
|
|
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(),
|
|
"stdout_truncated": stdout.truncated,
|
|
"stderr_truncated": stderr.truncated,
|
|
}, 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,
|
|
"stdout_truncated": stdout.truncated,
|
|
"stderr_truncated": stderr.truncated,
|
|
},
|
|
}
|
|
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
|
|
}
|