package main import ( "context" "encoding/binary" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strings" "time" ) // dockerSocketPath ruta del socket docker. Override via env DOCKER_HOST si fuera necesario en el futuro. var dockerSocketPath = "/var/run/docker.sock" // dockerHTTPClient devuelve un *http.Client cuyo Transport dial-tea via unix socket. // hostOverride opcional: usar otro path (tests con httptest.NewServer sobre unix). func dockerHTTPClient(sock string) *http.Client { if sock == "" { sock = dockerSocketPath } return &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { d := net.Dialer{Timeout: 5 * time.Second} return d.DialContext(ctx, "unix", sock) }, }, } } // dockerHostForReq host falso para HTTP sobre unix. const dockerHost = "http://localhost" // dockerGet hace GET path con query y devuelve body bytes. func dockerGet(sock, path string, qs url.Values) ([]byte, int, error) { cli := dockerHTTPClient(sock) u := dockerHost + path if len(qs) > 0 { u += "?" + qs.Encode() } resp, err := cli.Get(u) if err != nil { return nil, -1, fmt.Errorf("docker GET %s: %w", path, err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return body, resp.StatusCode, nil } // dockerPost hace POST path con body JSON (puede ser nil). func dockerPost(sock, path string, body any) ([]byte, int, error) { cli := dockerHTTPClient(sock) var rdr io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return nil, -1, fmt.Errorf("marshal: %w", err) } rdr = strings.NewReader(string(b)) } req, err := http.NewRequest("POST", dockerHost+path, rdr) if err != nil { return nil, -1, err } req.Header.Set("Content-Type", "application/json") resp, err := cli.Do(req) if err != nil { return nil, -1, fmt.Errorf("docker POST %s: %w", path, err) } defer resp.Body.Close() rb, _ := io.ReadAll(resp.Body) return rb, resp.StatusCode, nil } // runDockerList wraps GET /containers/json. func runDockerList(cap *Capability, args map[string]any) (any, int, error) { _ = cap qs := url.Values{} if v, ok := args["all"]; ok { if b, ok := v.(bool); ok && b { qs.Set("all", "true") } } if v, ok := args["filters"]; ok && v != nil { // JSON-encoded filters per docker API if filtersStr, ok := v.(string); ok { qs.Set("filters", filtersStr) } else { b, _ := json.Marshal(v) qs.Set("filters", string(b)) } } body, status, err := dockerGet(dockerSocketPath, "/containers/json", qs) if err != nil { return nil, -1, err } if status >= 400 { return nil, status, fmt.Errorf("docker api status=%d: %s", status, string(body)) } var containers []map[string]any if err := json.Unmarshal(body, &containers); err != nil { return nil, -1, fmt.Errorf("decode containers: %w", err) } // Slim para no devolver mega payload slim := []map[string]any{} for _, c := range containers { entry := map[string]any{ "id": truncString(getStr(c, "Id"), 12), "image": getStr(c, "Image"), "state": getStr(c, "State"), "status": getStr(c, "Status"), } if names, ok := c["Names"].([]any); ok && len(names) > 0 { entry["name"] = strings.TrimPrefix(fmt.Sprintf("%v", names[0]), "/") } slim = append(slim, entry) } return map[string]any{ "containers": slim, "count": len(slim), }, 0, nil } // runDockerLogs GET /containers/{id}/logs?stdout=1&stderr=1&tail=N func runDockerLogs(cap *Capability, args map[string]any) (any, int, error) { _ = cap container := mapStringField(args, "container") if container == "" { return nil, -1, fmt.Errorf("container required") } tail := mapIntField(args, "tail", 100) since := mapStringField(args, "since") qs := url.Values{} qs.Set("stdout", "1") qs.Set("stderr", "1") if tail > 0 { qs.Set("tail", fmt.Sprintf("%d", tail)) } if since != "" { qs.Set("since", since) } body, status, err := dockerGet(dockerSocketPath, "/containers/"+container+"/logs", qs) if err != nil { return nil, -1, err } if status >= 400 { return nil, status, fmt.Errorf("docker logs status=%d: %s", status, string(body)) } // Body es multiplexed con frame 8-byte header (stream_type|0|0|0|size_be32). stdout, stderr := demuxDockerStream(body) // Trunca a 256KB cada uno por defensa const maxOut = 256 * 1024 if len(stdout) > maxOut { stdout = stdout[len(stdout)-maxOut:] } if len(stderr) > maxOut { stderr = stderr[len(stderr)-maxOut:] } return map[string]any{ "container": container, "stdout": stdout, "stderr": stderr, "lines": strings.Split(stdout, "\n"), "exit_code": 0, }, 0, nil } // runDockerExec docker exec con whitelist binaries. // Flujo: POST /containers/{id}/exec -> exec id. POST /exec/{id}/start (Detach=false, Tty=false) -> hijacked stream. func runDockerExec(cap *Capability, args map[string]any) (any, int, error) { container := mapStringField(args, "container") if container == "" { return nil, -1, fmt.Errorf("container required") } var argv []string if raw, ok := args["argv"]; ok && raw != nil { if arr, ok := raw.([]any); ok { for _, v := range arr { if s, ok := v.(string); ok { argv = append(argv, s) } } } } if len(argv) == 0 { return nil, -1, fmt.Errorf("argv required (non-empty array)") } if len(cap.BinariesAllowed) == 0 { return nil, -1, fmt.Errorf("no binaries whitelisted for docker.container.exec") } bin := argv[0] allowed := false for _, b := range cap.BinariesAllowed { if b == bin || strings.HasSuffix(bin, "/"+b) { allowed = true break } } if !allowed { return nil, -1, fmt.Errorf("binary %q not in whitelist %v", bin, cap.BinariesAllowed) } // 1. Crear exec createBody := map[string]any{ "Cmd": argv, "AttachStdout": true, "AttachStderr": true, "Tty": false, } body, status, err := dockerPost(dockerSocketPath, "/containers/"+container+"/exec", createBody) if err != nil { return nil, -1, err } if status >= 400 { return nil, status, fmt.Errorf("docker exec create status=%d: %s", status, string(body)) } var created map[string]any if err := json.Unmarshal(body, &created); err != nil { return nil, -1, fmt.Errorf("decode exec create: %w", err) } execID, _ := created["Id"].(string) if execID == "" { return nil, -1, fmt.Errorf("exec create returned empty id") } // 2. Start exec con Detach=false hijacks la conexion. startBody := map[string]any{"Detach": false, "Tty": false} streamBody, status, err := dockerPost(dockerSocketPath, "/exec/"+execID+"/start", startBody) if err != nil { return nil, -1, err } if status >= 400 { return nil, status, fmt.Errorf("docker exec start status=%d: %s", status, string(streamBody)) } stdout, stderr := demuxDockerStream(streamBody) // 3. Inspect para obtener exit_code body2, status, err := dockerGet(dockerSocketPath, "/exec/"+execID+"/json", nil) exitCode := -1 if err == nil && status < 400 { var inspect map[string]any if json.Unmarshal(body2, &inspect) == nil { if v, ok := inspect["ExitCode"].(float64); ok { exitCode = int(v) } } } // Trunca outputs const maxOut = 256 * 1024 if len(stdout) > maxOut { stdout = stdout[:maxOut] } if len(stderr) > maxOut { stderr = stderr[:maxOut] } return map[string]any{ "container": container, "argv": argv, "stdout": stdout, "stderr": stderr, "exit_code": exitCode, }, exitCode, nil } // demuxDockerStream parsea stream multiplexed de docker (8-byte header + payload). // frame: [stream_type][0][0][0][size_be32][payload]. stream_type: 1=stdout 2=stderr. // Si no parece multiplexed (sin header valido), devuelve todo como stdout. func demuxDockerStream(b []byte) (string, string) { var so, se strings.Builder for len(b) >= 8 { typ := b[0] // size big-endian uint32 at offset 4 size := binary.BigEndian.Uint32(b[4:8]) if int(size) > len(b)-8 { // frame corrupto, asumimos plain stdout so.Write(b[8:]) return so.String(), se.String() } payload := b[8 : 8+size] switch typ { case 1: so.Write(payload) case 2: se.Write(payload) default: // stdin (0) o stream desconocido -> stdout fallback so.Write(payload) } b = b[8+size:] } if len(b) > 0 { so.Write(b) } return so.String(), se.String() } func getStr(m map[string]any, k string) string { if v, ok := m[k]; ok { if s, ok := v.(string); ok { return s } } return "" } func truncString(s string, n int) string { if len(s) <= n { return s } return s[:n] }