319 lines
8.4 KiB
Go
319 lines
8.4 KiB
Go
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]
|
|
}
|