Files
explorer-monorepo/backend/api/track4/operator_scripts.go

259 lines
6.8 KiB
Go
Raw Normal View History

package track4
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
fix(auth): typed context keys and real sentinel errors backend/api/middleware/context.go (new): - Introduces an unexported ctxKey type and three constants (ctxKeyUserAddress, ctxKeyUserTrack, ctxKeyAuthenticated) that replace the bare string keys 'user_address', 'user_track', and 'authenticated'. Bare strings trigger go vet's SA1029 and collide with keys from any other package that happens to share the name. - Helpers: ContextWithAuth, UserAddress, UserTrack, IsAuthenticated. - Sentinel: ErrMissingAuthorization replaces the misuse of http.ErrMissingFile as an auth-missing signal. (http.ErrMissingFile belongs to multipart form parsing and was semantically wrong.) backend/api/middleware/auth.go: - RequireAuth, OptionalAuth, RequireTrack now all read/write via the helpers; no more string literals for context keys in this file. - extractAuth returns ErrMissingAuthorization instead of http.ErrMissingFile. - Dropped now-unused 'context' import. backend/api/track4/operator_scripts.go, backend/api/track4/endpoints.go, backend/api/rest/features.go: - Read user address / track via middleware.UserAddress() and middleware.UserTrack() instead of a raw context lookup with a bare string key. - Import 'github.com/explorer/backend/api/middleware'. backend/api/track4/operator_scripts_test.go: - Four test fixtures updated to seed the request context through middleware.ContextWithAuth (track 4, authenticated) instead of context.WithValue with a bare 'user_address' string. This is the load-bearing change that proves typed keys are required: a bare string key no longer wakes up the middleware helpers. backend/api/middleware/context_test.go (new): - Round-trip test for ContextWithAuth + UserAddress + UserTrack + IsAuthenticated. - Defaults: UserTrack=1, UserAddress="", IsAuthenticated=false on a bare context. - TestContextKeyIsolation: an outside caller that inserts 'user_address' as a bare string key must NOT be visible to UserAddress; proves the type discipline. - ErrMissingAuthorization sentinel smoke test. Verification: - go build ./... clean. - go vet ./... clean (removes SA1029 on the old bare keys). - go test ./api/middleware/... ./api/track4/... ./api/rest/... PASS. Advances completion criterion 3 (Auth correctness).
2026-04-18 19:05:24 +00:00
"github.com/explorer/backend/api/middleware"
)
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
}
fix(auth): typed context keys and real sentinel errors backend/api/middleware/context.go (new): - Introduces an unexported ctxKey type and three constants (ctxKeyUserAddress, ctxKeyUserTrack, ctxKeyAuthenticated) that replace the bare string keys 'user_address', 'user_track', and 'authenticated'. Bare strings trigger go vet's SA1029 and collide with keys from any other package that happens to share the name. - Helpers: ContextWithAuth, UserAddress, UserTrack, IsAuthenticated. - Sentinel: ErrMissingAuthorization replaces the misuse of http.ErrMissingFile as an auth-missing signal. (http.ErrMissingFile belongs to multipart form parsing and was semantically wrong.) backend/api/middleware/auth.go: - RequireAuth, OptionalAuth, RequireTrack now all read/write via the helpers; no more string literals for context keys in this file. - extractAuth returns ErrMissingAuthorization instead of http.ErrMissingFile. - Dropped now-unused 'context' import. backend/api/track4/operator_scripts.go, backend/api/track4/endpoints.go, backend/api/rest/features.go: - Read user address / track via middleware.UserAddress() and middleware.UserTrack() instead of a raw context lookup with a bare string key. - Import 'github.com/explorer/backend/api/middleware'. backend/api/track4/operator_scripts_test.go: - Four test fixtures updated to seed the request context through middleware.ContextWithAuth (track 4, authenticated) instead of context.WithValue with a bare 'user_address' string. This is the load-bearing change that proves typed keys are required: a bare string key no longer wakes up the middleware helpers. backend/api/middleware/context_test.go (new): - Round-trip test for ContextWithAuth + UserAddress + UserTrack + IsAuthenticated. - Defaults: UserTrack=1, UserAddress="", IsAuthenticated=false on a bare context. - TestContextKeyIsolation: an outside caller that inserts 'user_address' as a bare string key must NOT be visible to UserAddress; proves the type discipline. - ErrMissingAuthorization sentinel smoke test. Verification: - go build ./... clean. - go vet ./... clean (removes SA1029 on the old bare keys). - go test ./api/middleware/... ./api/track4/... ./api/rest/... PASS. Advances completion criterion 3 (Auth correctness).
2026-04-18 19:05:24 +00:00
operatorAddr := middleware.UserAddress(r.Context())
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
}