From 7b7523a1d42dbd3abe28dc99960228295cb5d23f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 16 Oct 2026 04:07:50 -0400 Subject: [PATCH] fix(wfctl): bound Docker daemon identity requests --- cmd/wfctl/pipeline_docker_unix.go | 157 +++- cmd/wfctl/pipeline_docker_unix_test.go | 683 +++++++++++++++++- .../pipeline_record_diagnostic_unix_test.go | 13 +- .../testdata/pipeline-record/docker/main.go | 49 +- .../pipeline-record/docker/transport.go | 2 +- .../pipeline-record/docker/transport_test.go | 47 ++ docs/WFCTL.md | 17 + go.mod | 2 +- 8 files changed, 934 insertions(+), 36 deletions(-) diff --git a/cmd/wfctl/pipeline_docker_unix.go b/cmd/wfctl/pipeline_docker_unix.go index 55485c3f..aca65fa8 100644 --- a/cmd/wfctl/pipeline_docker_unix.go +++ b/cmd/wfctl/pipeline_docker_unix.go @@ -3,6 +3,7 @@ package main import ( + "bufio" "bytes" "context" "crypto/sha256" @@ -11,6 +12,9 @@ import ( "errors" "fmt" "io" + "net" + "net/http" + "net/textproto" "net/url" "os" "os/exec" @@ -23,6 +27,7 @@ import ( "time" "github.com/GoCodeAlone/workflow/sandbox" + "golang.org/x/net/idna" "golang.org/x/sys/unix" ) @@ -196,7 +201,7 @@ func resolvePipelineDockerClient(ctx context.Context) (_ *pipelineDockerClient, } } phase = "docker_resolve_identity_metadata" - serverID, err := c.serverID(pipelineDockerResolutionContext(ctx, "resolve_identity")) + serverID, err := c.serverID(pipelineDockerResolutionContext(ctx, "resolve_identity"), endpoint.Host) if err != nil { return nil, err } @@ -290,14 +295,128 @@ func (c *pipelineDockerClient) validateSnapshot() error { return nil } -func (c *pipelineDockerClient) serverID(ctx context.Context) (string, error) { - result, err := c.raw(ctx, pipelineDockerCallTimeout, "info", "--format", "{{json .ID}}") +// The identity request deliberately avoids `docker info`, which enumerates +// unrelated client-plugin metadata even with an ID-only format. Keep Docker's +// pinned transport/TLS handling, and bound the entire helper to the same five +// seconds. Client initialization may still ping before dial-stdio opens a stream. +func (c *pipelineDockerClient) serverID(ctx context.Context, endpoint string) (string, error) { + request, err := pipelineDockerIdentityRequest(endpoint) + if err != nil { + return "", fmt.Errorf("pipeline Docker daemon identity request invalid") + } + args := slices.Clone(c.args) + endpointURL, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("pipeline Docker daemon identity request invalid") + } + hostname := endpointURL.Hostname() + if endpointURL.Scheme == "tcp" && !strings.Contains(hostname, ":") && strings.IndexFunc(hostname, func(r rune) bool { return r > 127 }) >= 0 { + // Unlike Info's HTTP transport, Docker's raw dialer does not apply + // IDNA Lookup. Its DNS policy differs from Request.Write's Punycode + // Host policy. Preserve ASCII authorities and IPv6 routing scopes; + // normalize only this identity invocation's non-ASCII DNS target. + ascii, err := idna.Lookup.ToASCII(hostname) + hostIndex := slices.Index(args, "--host") + if err != nil || hostIndex < 0 || hostIndex+1 >= len(args) { + return "", fmt.Errorf("pipeline Docker daemon identity request invalid") + } + args[hostIndex+1] = "tcp://" + net.JoinHostPort(ascii, endpointURL.Port()) + } + result, err := runPipelineDockerCLIWithInput(ctx, c.binary, c.env, pipelineDockerCallTimeout, request, append(args, "system", "dial-stdio")...) if err != nil { return "", fmt.Errorf("pipeline Docker daemon identity unavailable") } + return parsePipelineDockerIdentityResponse(result.Stdout) +} + +func pipelineDockerIdentityRequest(endpoint string) ([]byte, error) { + if err := validatePipelineDockerEndpoint(endpoint); err != nil { + return nil, fmt.Errorf("pipeline Docker identity endpoint invalid") + } + u, _ := url.Parse(endpoint) // Validated above; preserve the selected TCP authority. + host := "api.moby.localhost" // Docker SDK DummyHost for Unix sockets. + if u.Scheme == "tcp" { + host = u.Host + } + request := &http.Request{ + Method: http.MethodGet, URL: &url.URL{Scheme: "http", Host: host, Path: "/info"}, + Host: host, Header: http.Header{"User-Agent": {""}}, Close: true, + } + var wire bytes.Buffer + if err := request.Write(&wire); err != nil || wire.Len() > 512 { + return nil, fmt.Errorf("pipeline Docker identity request invalid") + } + return wire.Bytes(), nil +} + +func parsePipelineDockerIdentityResponse(output string) (string, error) { + invalid := func() (string, error) { return "", fmt.Errorf("pipeline Docker daemon server ID invalid") } + if len(output) > sandbox.MaxOutputBytes { + return invalid() + } + // ReadResponse normalizes away Content-Length when chunked. Reject ambiguous + // framing before that normalization; no other HTTP implementation is needed. + headers := textproto.NewReader(bufio.NewReader(strings.NewReader(output))) + if _, err := headers.ReadLine(); err != nil { + return invalid() + } + h, err := headers.ReadMIMEHeader() + if err != nil || len(h.Values("Content-Length")) > 1 || (len(h.Values("Content-Length")) > 0 && len(h.Values("Transfer-Encoding")) > 0) { + return invalid() + } + reader := bufio.NewReader(strings.NewReader(output)) + response, err := http.ReadResponse(reader, &http.Request{Method: http.MethodGet}) + if err != nil { + return invalid() + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK || response.ProtoMajor != 1 || response.ProtoMinor > 1 { + return invalid() + } + for _, encoding := range response.Header.Values("Content-Encoding") { + if encoding != "" && !strings.EqualFold(strings.TrimSpace(encoding), "identity") { + return invalid() + } + } + body, err := io.ReadAll(response.Body) + if err != nil { + return invalid() + } + if _, err := reader.Peek(1); err != io.EOF { + return invalid() + } + decoder := json.NewDecoder(bytes.NewReader(body)) + if token, err := decoder.Token(); err != nil || token != json.Delim('{') { + return invalid() + } var id string - if json.Unmarshal([]byte(result.Stdout), &id) != nil || len(id) == 0 || len(id) > 256 { - return "", fmt.Errorf("pipeline Docker daemon server ID invalid") + found := false + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return invalid() + } + name, ok := key.(string) + if !ok { + return invalid() + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return invalid() + } + if strings.EqualFold(name, "ID") { + if name != "ID" || found || json.Unmarshal(value, &id) != nil { + return invalid() + } + found = true + } + } + if token, err := decoder.Token(); err != nil || token != json.Delim('}') { + return invalid() + } + var extra json.RawMessage + if decoder.Decode(&extra) != io.EOF || !found || len(id) == 0 || len(id) > 256 { + return invalid() } for _, char := range id { if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && !strings.ContainsRune("-_:.", char) { @@ -369,7 +488,7 @@ func (c *pipelineDockerClient) Run(ctx context.Context, args ...string) (*sandbo } ctx, cancel := context.WithTimeout(ctx, sandbox.RecordMaxTimeout) defer cancel() - id, err := c.serverID(ctx) + id, err := c.serverID(ctx, c.identity.Endpoint) if err != nil { return nil, err } @@ -400,6 +519,10 @@ func (w *pipelineDockerOutput) Write(data []byte) (int, error) { } func runPipelineDockerCLI(ctx context.Context, binary string, env []string, timeout time.Duration, args ...string) (*sandbox.ExecResult, error) { + return runPipelineDockerCLIWithInput(ctx, binary, env, timeout, nil, args...) +} + +func runPipelineDockerCLIWithInput(ctx context.Context, binary string, env []string, timeout time.Duration, input []byte, args ...string) (*sandbox.ExecResult, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() process, grouped := ctx.Value(pipelineDockerProcessGroupKey{}).(pipelineProcessIdentity) @@ -413,6 +536,28 @@ func runPipelineDockerCLI(ctx context.Context, binary string, env []string, time argv := append([]string{"-c", `umask 077; exec "$@"`, "wfctl-docker", binary}, args...) cmd := exec.CommandContext(ctx, "/bin/sh", argv...) // #nosec G204,G702 -- fixed shell program; values remain positional argv. cmd.Env = env + if len(input) > 0 { + if len(input) > 512 { + observePipelineDockerResolutionFailure(ctx, "command", -1) + return nil, fmt.Errorf("pipeline Docker input exceeded request limit") + } + read, write, err := os.Pipe() + if err != nil { + observePipelineDockerResolutionFailure(ctx, "command", -1) + return nil, fmt.Errorf("pipeline Docker input unavailable") + } + defer read.Close() + defer write.Close() + // A new empty pipe accepts <=512 bytes (POSIX minimum PIPE_BUF) without + // waiting for a reader. Keep its writer open until Run returns: stdin EOF + // would make dial-stdio half-close TLS before the response. A direct file + // avoids an os/exec stdin-copy goroutine; cancellation still kills custody. + if n, err := write.Write(input); err != nil || n != len(input) { + observePipelineDockerResolutionFailure(ctx, "command", -1) + return nil, fmt.Errorf("pipeline Docker input unavailable") + } + cmd.Stdin = read + } cmd.WaitDelay = 250 * time.Millisecond // Broker helpers belong to recorded child custody; discovery/cleanup helpers // get a temporary group. Never kill the recorded group from a CLI cancel. diff --git a/cmd/wfctl/pipeline_docker_unix_test.go b/cmd/wfctl/pipeline_docker_unix_test.go index 42e2576f..feae73e5 100644 --- a/cmd/wfctl/pipeline_docker_unix_test.go +++ b/cmd/wfctl/pipeline_docker_unix_test.go @@ -3,9 +3,23 @@ package main import ( + "bufio" + "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" "encoding/json" + "encoding/pem" "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/url" "os" "os/exec" "path/filepath" @@ -23,30 +37,38 @@ import ( const pipelineDockerTestLabel = "wfctl-0123456789abcdef0123456789abcdef" type pipelineDockerFixture struct { - Context string `json:"context"` - ServerID string `json:"server_id"` - Containers map[string]string `json:"containers"` - Fail string `json:"fail"` - Sleep string `json:"sleep"` - Flood string `json:"flood"` - Keep bool `json:"keep"` - ListOverride string `json:"list_override"` - InspectOverride string `json:"inspect_override"` - UnavailableHost string `json:"unavailable_host"` - Helper string `json:"helper"` - HelperBlocks bool `json:"helper_blocks"` - Group pipelineProcessIdentity `json:"group"` - LateCreate bool `json:"late_create"` - WaitStatus string `json:"wait_status,omitempty"` + IdentityDialAddresses map[string]string `json:"identity_dial_addresses,omitempty"` + Context string `json:"context"` + ServerID string `json:"server_id"` + Containers map[string]string `json:"containers"` + Fail string `json:"fail"` + Sleep string `json:"sleep"` + Flood string `json:"flood"` + Keep bool `json:"keep"` + ListOverride string `json:"list_override"` + InspectOverride string `json:"inspect_override"` + UnavailableHost string `json:"unavailable_host"` + Helper string `json:"helper"` + HelperBlocks bool `json:"helper_blocks"` + Group pipelineProcessIdentity `json:"group"` + LateCreate bool `json:"late_create"` + WaitStatus string `json:"wait_status,omitempty"` + IdentityResponse string `json:"identity_response,omitempty"` + IdentityProxy bool `json:"identity_proxy,omitempty"` + RequireOpenInput bool `json:"require_open_input,omitempty"` } type pipelineDockerInvocation struct { - Args []string `json:"args"` - Env []string `json:"env"` - Files map[string]string `json:"files"` - Config string `json:"config"` - PID int `json:"pid"` - PGID int `json:"pgid"` + Args []string `json:"args"` + Env []string `json:"env"` + Files map[string]string `json:"files"` + Config string `json:"config"` + PID int `json:"pid"` + PGID int `json:"pgid"` + RequestHost string `json:"request_host,omitempty"` + RequestMethod string `json:"request_method,omitempty"` + RequestTarget string `json:"request_target,omitempty"` + RequestClose bool `json:"request_close,omitempty"` } // The helper substitutes only the Docker CLI dependency. Resolution, execution, @@ -95,6 +117,7 @@ func TestPipelineDockerCLIHelper(t *testing.T) { invocation.PID, invocation.PGID = os.Getpid(), syscall.Getpgrp() commandArgs := args host := "unix:///daemon-b.sock" + tlsEnabled, verifyTLS := false, false for len(commandArgs) > 0 && strings.HasPrefix(commandArgs[0], "--") { flag, value, hasValue := strings.Cut(commandArgs[0], "=") commandArgs = commandArgs[1:] @@ -107,6 +130,10 @@ func TestPipelineDockerCLIHelper(t *testing.T) { switch flag { case "--host": host = value + case "--tls": + tlsEnabled = value == "true" + case "--tlsverify": + verifyTLS = value == "true" case "--config": config, _ := os.ReadFile(filepath.Join(value, "config.json")) invocation.Config = string(config) @@ -115,6 +142,24 @@ func TestPipelineDockerCLIHelper(t *testing.T) { invocation.Files[flag] = string(material) } } + + identityStream := slices.Equal(commandArgs, []string{"system", "dial-stdio"}) + var request *http.Request + var input *bufio.Reader + if identityStream { + input = bufio.NewReader(os.Stdin) + request, err = http.ReadRequest(input) + if err != nil || request.Method != http.MethodGet || request.RequestURI != "/info" || !request.Close || request.ContentLength != 0 || len(request.TransferEncoding) != 0 { + os.Exit(94) + } + invocation.RequestHost, invocation.RequestMethod, invocation.RequestTarget, invocation.RequestClose = request.Host, request.Method, request.RequestURI, request.Close + if fixture.RequireOpenInput { + fds := []unix.PollFd{{Fd: int32(os.Stdin.Fd()), Events: unix.POLLIN}} + if _, err := unix.Poll(fds, 0); err != nil || fds[0].Revents&unix.POLLHUP != 0 { + os.Exit(94) + } + } + } log, _ := os.OpenFile(filepath.Join(dir, "calls.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) _ = json.NewEncoder(log).Encode(invocation) _ = log.Close() @@ -122,6 +167,9 @@ func TestPipelineDockerCLIHelper(t *testing.T) { os.Exit(93) } command := commandArgs[0] + if identityStream { + command = "info" // Existing logical identity fault; invocation retains actual argv. + } if fixture.Helper == command { binary, _ := os.Executable() helperArgs := []string{"-test.run=^TestPipelineDockerCLIHelper$", "--", "--pipeline-docker-inherited-fd"} @@ -160,7 +208,23 @@ func TestPipelineDockerCLIHelper(t *testing.T) { case "context": fmt.Print(fixture.Context) case "info": - _ = json.NewEncoder(os.Stdout).Encode(fixture.ServerID) + if !identityStream { + _ = json.NewEncoder(os.Stdout).Encode(fixture.ServerID) + break + } + if fixture.IdentityProxy { + if err := proxyPipelineDockerIdentity(request, input, host, tlsEnabled, verifyTLS, invocation.Files, fixture.IdentityDialAddresses); err != nil { + os.Exit(7) + } + } else if fixture.IdentityResponse != "" { + fmt.Print(fixture.IdentityResponse) + } else { + body, _ := json.Marshal(map[string]any{"ID": fixture.ServerID, "Containers": 0, "Unrelated": map[string]any{"field": true}}) + fmt.Print(pipelineDockerIdentityWire(string(body))) + } + if fixture.Fail == "identity-after-response" { + os.Exit(7) + } case "ps": if fixture.ListOverride != "" { fmt.Print(fixture.ListOverride) @@ -1029,3 +1093,578 @@ func TestPipelineDockerCreateArgs(t *testing.T) { t.Fatal("builder must apply the redacted record config policy") } } + +func pipelineDockerIdentityWire(body string) string { + return fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body) +} + +func proxyPipelineDockerIdentity(request *http.Request, input io.Reader, endpoint string, enabled, verify bool, files map[string]string, addresses map[string]string) error { + u, err := url.Parse(endpoint) + if err != nil { + return err + } + network, address := "tcp", u.Host + if u.Scheme == "unix" { + network, address = "unix", u.Path + } + if network == "tcp" && addresses != nil { + // A bounded fixture DNS table replaces only external lookup. The + // production resolver must actually pass its canonical dial authority. + mapped, exists := addresses[address] + if !exists { + return fmt.Errorf("fixture DNS authority unavailable") + } + address = mapped + } + connection, err := net.DialTimeout(network, address, time.Second) + if err != nil { + return err + } + defer connection.Close() + if enabled { + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM([]byte(files["--tlscacert"])) { + return fmt.Errorf("fixture CA invalid") + } + secure := tls.Client(connection, &tls.Config{RootCAs: roots, ServerName: u.Hostname(), MinVersion: tls.VersionTLS12, InsecureSkipVerify: !verify}) // #nosec G402 -- models the explicit CLI TLS policy in isolated fixtures. + if err := secure.HandshakeContext(context.Background()); err != nil { + return err + } + connection = secure + } + if err := request.Write(connection); err != nil { + return err + } + go func() { + _, _ = io.Copy(connection, input) + if half, ok := connection.(interface{ CloseWrite() error }); ok { + _ = half.CloseWrite() + } + }() + _, err = io.Copy(os.Stdout, connection) + return err +} + +func TestPipelineDockerIdentityRequestAuthorityAndBound(t *testing.T) { + for _, test := range []struct{ endpoint, host string }{ + {"unix:///unused-fixture.sock", "api.moby.localhost"}, + {"tcp://selected.example:2376", "selected.example:2376"}, + {"tcp://[::1]:2376", "[::1]:2376"}, + {"tcp://[fe80::1%25en0]:2376", "[fe80::1]:2376"}, + } { + t.Run(test.endpoint, func(t *testing.T) { + wire, err := pipelineDockerIdentityRequest(test.endpoint) + if err != nil { + t.Fatal(err) + } + request, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(wire))) + if err != nil || request.Method != http.MethodGet || request.RequestURI != "/info" || request.Host != test.host || !request.Close || request.ContentLength != 0 { + t.Fatalf("identity request changed method/path/authority/close: %v, %v", request, err) + } + if request.Header.Get("User-Agent") != "" { + t.Fatal("identity added an unrelated client header") + } + }) + } + wire, err := pipelineDockerIdentityRequest("tcp://" + strings.Repeat("a", 461) + ":1") + if err != nil || len(wire) != 512 { + t.Fatalf("512-byte boundary rejected: %d, %v", len(wire), err) + } + if _, err := pipelineDockerIdentityRequest("tcp://" + strings.Repeat("a", 462) + ":1"); err == nil { + t.Fatal("513-byte request exceeded safe prestart pipe bound") + } + for _, endpoint := range []string{"ssh://private.invalid", "tcp://user:private-canary@host:2376", "tcp://host:2376\r\nX: private-canary"} { + if _, err := pipelineDockerIdentityRequest(endpoint); err == nil || strings.Contains(err.Error(), "private-canary") { + t.Fatal("invalid endpoint accepted or reflected") + } + } +} + +func TestPipelineDockerIdentityInputAndStartFailures(t *testing.T) { + for _, test := range []struct { + name string + input []byte + env []string + }{ + {"oversize-input", bytes.Repeat([]byte("x"), 513), nil}, + {"invalid-exec-environment", []byte("GET /info HTTP/1.1\r\nHost: api.moby.localhost\r\nConnection: close\r\n\r\n"), []string{"private-start-canary=\x00"}}, + } { + t.Run(test.name, func(t *testing.T) { + diagnostic := newPipelineRecordDiagnostic() + ctx := pipelineDockerResolutionContext(context.WithValue(t.Context(), pipelineRecordDiagnosticKey{}, diagnostic), "resolve_identity") + result, err := runPipelineDockerCLIWithInput(ctx, "private-binary-canary", test.env, pipelineDockerCallTimeout, test.input, "system", "dial-stdio") + if err == nil || (result != nil && result.ExitCode != -1) { + t.Fatal("input/start failure appeared successful") + } + report, output := diagnosticTestReport(t, diagnostic) + if report.Phase != "docker_resolve_identity_command" || report.Operation != "resolve_identity" || report.DockerExit != -1 { + t.Fatal("prestart failure lost its actual unknown exit") + } + if bytes.Contains(output, []byte("private-start-canary")) || bytes.Contains(output, []byte("private-binary-canary")) { + t.Fatal("input/start failure reflected private input") + } + }) + } +} + +func TestPipelineDockerIdentityFramingAndMetadata(t *testing.T) { + body := "{\"ID\":\"daemon-A\",\"Containers\":3,\"Unrelated\":{\"nested\":[1,true,null]}}\n" + chunked := "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" + fmt.Sprintf("%x\r\n%s\r\n0\r\n\r\n", len(body), body) + for _, test := range []struct { + name, wire string + valid bool + }{ + {"content-length", pipelineDockerIdentityWire(body), true}, + {"chunked", chunked, true}, + {"eof-delimited", "HTTP/1.0 200 OK\r\n\r\n" + body, true}, + {"missing-ID", pipelineDockerIdentityWire("{\"Containers\":0}"), false}, + {"empty-ID", pipelineDockerIdentityWire("{\"ID\":\"\"}"), false}, + {"null-ID", pipelineDockerIdentityWire("{\"ID\":null}"), false}, + {"number-ID", pipelineDockerIdentityWire("{\"ID\":123}"), false}, + {"duplicate-ID", pipelineDockerIdentityWire("{\"ID\":\"daemon-A\",\"ID\":\"other\"}"), false}, + {"escaped-duplicate-ID", pipelineDockerIdentityWire("{\"ID\":\"daemon-A\",\"I\\u0044\":\"other\"}"), false}, + {"case-alias-ID", pipelineDockerIdentityWire("{\"ID\":\"daemon-A\",\"id\":\"other\"}"), false}, + {"only-case-alias", pipelineDockerIdentityWire("{\"id\":\"daemon-A\"}"), false}, + {"unsafe-ID", pipelineDockerIdentityWire("{\"ID\":\"private-canary\\nsecret\"}"), false}, + {"overlong-ID", pipelineDockerIdentityWire("{\"ID\":\"" + strings.Repeat("a", 257) + "\"}"), false}, + {"non-object", pipelineDockerIdentityWire("[{\"ID\":\"daemon-A\"}]"), false}, + {"trailing-JSON", pipelineDockerIdentityWire(body + "{}"), false}, + {"trailing-wire", pipelineDockerIdentityWire(body) + "private-canary", false}, + {"second-response", pipelineDockerIdentityWire(body) + pipelineDockerIdentityWire(body), false}, + {"redirect", strings.Replace(pipelineDockerIdentityWire(body), "200 OK", "302 Found", 1), false}, + {"daemon-error", strings.Replace(pipelineDockerIdentityWire(body), "200 OK", "500 Error", 1), false}, + {"compressed", strings.Replace(pipelineDockerIdentityWire(body), "Connection: close", "Content-Encoding: gzip\r\nConnection: close", 1), false}, + {"ambiguous-length-and-chunked", strings.Replace(chunked, "Connection: close", "Content-Length: 1\r\nConnection: close", 1), false}, + {"duplicate-length", strings.Replace(pipelineDockerIdentityWire(body), "Connection: close", fmt.Sprintf("Content-Length: %d\r\nConnection: close", len(body)), 1), false}, + {"truncated-length", pipelineDockerIdentityWire(body)[:len(pipelineDockerIdentityWire(body))-2], false}, + {"truncated-chunked", chunked[:len(chunked)-5], false}, + {"malformed-chunked", strings.Replace(chunked, fmt.Sprintf("%x\r\n", len(body)), "z\r\n", 1), false}, + {"invalid-status", "private-canary\r\n\r\n" + body, false}, + {"http2-text", strings.Replace(pipelineDockerIdentityWire(body), "HTTP/1.1", "HTTP/2.0", 1), false}, + } { + t.Run(test.name, func(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + fixture.IdentityResponse, fixture.RequireOpenInput = test.wire, true + writePipelineDockerFixture(t, dir, fixture) + client, err := resolvePipelineDockerClient(t.Context()) + if test.valid { + if err != nil || client == nil { + t.Fatalf("valid framed identity rejected: %v", err) + } + defer client.Close() + if client.Identity.ServerID != "daemon-A" { + t.Fatal("wrong identity consumed") + } + } else { + if client != nil { + _ = client.Close() + t.Fatal("invalid response established a client") + } + if err == nil || strings.Contains(err.Error(), "private-canary") || strings.Contains(err.Error(), dir) { + t.Fatal("invalid framing or identity accepted/reflected") + } + } + calls := pipelineDockerCalls(t, dir) + if len(calls) != 2 || !slices.Contains(calls[1].Args, "dial-stdio") || slices.Contains(calls[1].Args, "info") || + calls[1].RequestHost != "api.moby.localhost" || calls[1].RequestMethod != "GET" || calls[1].RequestTarget != "/info" || !calls[1].RequestClose { + t.Fatal("test did not cross the real resolver/helper stdin protocol boundary exactly once") + } + if !test.valid { + for i, arg := range calls[1].Args { + if arg == "--config" { + if _, err := os.Lstat(calls[1].Args[i+1]); !os.IsNotExist(err) { + t.Fatal("failed resolver retained private config") + } + } + } + } + }) + } +} + +// The selected helper models the upstream stream copier, not Docker's binary. +// A natural hosted live test remains required for actual Docker compatibility. +func TestPipelineDockerIdentitySelectedTransport(t *testing.T) { + for _, transport := range []string{"unix", "tcp", "tls12", "tls13"} { + t.Run(transport, func(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + requests := make(chan string, 8) + expectedHost := "" + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- r.Method + " " + r.RequestURI + " " + r.Host + id := "wrong-http-host-backend" + if r.Method == "GET" && r.RequestURI == "/info" && r.Host == expectedHost && r.Close { + id = "daemon-A" + } + w.Header().Set("Connection", "close") + _ = json.NewEncoder(w).Encode(map[string]any{"ID": id, "Containers": 0}) + })) + if transport == "unix" { + if err := server.Listener.Close(); err != nil { + t.Fatal(err) + } + short, err := os.MkdirTemp("/tmp", "wf-id-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(short) }) + server.Listener, err = net.Listen("unix", filepath.Join(short, "daemon.sock")) + if err != nil { + t.Fatal(err) + } + fixture.Context = fmt.Sprintf("[{\"Name\":\"default\",\"Endpoints\":{\"docker\":{\"Host\":%q}}}]", "unix://"+server.Listener.Addr().String()) + expectedHost = "api.moby.localhost" + } else { + expectedHost = server.Listener.Addr().String() + fixture.Context = fmt.Sprintf("[{\"Name\":\"default\",\"Endpoints\":{\"docker\":{\"Host\":%q}}}]", "tcp://"+expectedHost) + } + if strings.HasPrefix(transport, "tls") { + version := uint16(tls.VersionTLS12) + if transport == "tls13" { + version = tls.VersionTLS13 + } + server.TLS = &tls.Config{MinVersion: version, MaxVersion: version} + server.StartTLS() + material := filepath.Join(dir, "tls") + if err := os.Mkdir(material, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(material, "ca.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("DOCKER_TLS_VERIFY", "1") + t.Setenv("DOCKER_CERT_PATH", material) + } else { + server.Start() + } + t.Cleanup(server.Close) + fixture.IdentityProxy, fixture.RequireOpenInput = true, true + writePipelineDockerFixture(t, dir, fixture) + client := resolvePipelineDockerFixture(t) + if client.Identity.ServerID != "daemon-A" { + t.Fatal("identity used different HTTP Host/backend") + } + if _, err := client.Run(t.Context(), "wait", strings.Repeat("a", 64)); err != nil { + t.Fatal(err) + } + if len(requests) != 2 { + t.Fatalf("want initial and operation identity revalidation; got%d", len(requests)) + } + for range 2 { + if got := <-requests; got != "GET /info "+expectedHost { + t.Fatalf("identity crossed a different request boundary: %q", got) + } + } + for _, call := range pipelineDockerCalls(t, dir) { + if slices.Contains(call.Args, "info") { + t.Fatal("identity re-entered client-plugin info reporting") + } + } + }) + } +} + +func TestPipelineDockerIdentityIncompletePeerAndCLIExit(t *testing.T) { + for _, mode := range []string{"stalled", "partial", "complete-but-open", "successful-bytes-failed-CLI", "capture-overflow"} { + t.Run(mode, func(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + diagnostic := newPipelineRecordDiagnostic() + outer := context.WithValue(t.Context(), pipelineRecordDiagnosticKey{}, diagnostic) + ctx := outer + switch mode { + case "successful-bytes-failed-CLI": + fixture.Fail = "identity-after-response" + case "capture-overflow": + fixture.Flood = "info" + default: + ready := make(chan struct{}, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Connection", "close") + body := "{\"ID\":\"daemon-A\"}" + if mode != "stalled" { + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + if mode == "partial" { + body = body[:5] + } + _, _ = io.WriteString(w, body) + w.(http.Flusher).Flush() + } + ready <- struct{}{} + <-r.Context().Done() + })) + t.Cleanup(server.Close) + fixture.Context = fmt.Sprintf("[{\"Name\":\"default\",\"Endpoints\":{\"docker\":{\"Host\":%q}}}]", "tcp://"+server.Listener.Addr().String()) + fixture.IdentityProxy = true + defer func() { + select { + case <-ready: + default: + t.Error("failure never reached the fixture peer") + } + }() + } + writePipelineDockerFixture(t, dir, fixture) + start := time.Now() + client, err := resolvePipelineDockerClient(ctx) + if client != nil { + _ = client.Close() + t.Fatal("failed/incomplete helper established identity") + } + if err == nil || time.Since(start) > 12*time.Second { + t.Fatal("peer/CLI failure did not fail within unchanged bounds") + } + report, _ := diagnosticTestReport(t, diagnostic) + phase := "docker_resolve_identity_deadline" + if mode == "successful-bytes-failed-CLI" { + phase = "docker_resolve_identity_command" + } + if mode == "capture-overflow" { + phase = "docker_resolve_identity_capture" + } + if report.Phase != phase || report.Operation != "resolve_identity" || report.ChildObserved { + t.Fatal("failure precedence changed after plausible identity bytes") + } + }) + } +} + +func TestPipelineDockerIdentityInheritedFDInitialResolution(t *testing.T) { + for _, blocks := range []bool{false, true} { + t.Run(strconv.FormatBool(blocks), func(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + fixture.Helper, fixture.HelperBlocks = "info", blocks + writePipelineDockerFixture(t, dir, fixture) + diagnostic := newPipelineRecordDiagnostic() + outer := context.WithValue(t.Context(), pipelineRecordDiagnosticKey{}, diagnostic) + started := time.Now() + client, err := resolvePipelineDockerClient(outer) + if client != nil { + _ = client.Close() + t.Fatal("inherited output fd was accepted as successful identity") + } + if err == nil || time.Since(started) > 12*time.Second { + t.Fatal("initial inherited-fd failure exceeded helper plus drain bounds") + } + report, output := diagnosticTestReport(t, diagnostic) + phase, exit := "docker_resolve_identity_command", 0 + if blocks { + phase, exit = "docker_resolve_identity_deadline", -1 + } + if report.Phase != phase || report.Operation != "resolve_identity" || report.DockerExit != exit || report.Deadline || report.Cancelled || outer.Err() != nil { + t.Fatal("initial helper exit/drain and inner deadline were confused") + } + if bytes.Contains(output, []byte(dir)) || bytes.Contains(output, []byte("daemon-A")) { + t.Fatal("private fixture identity leaked") + } + data, err := os.ReadFile(filepath.Join(dir, "helper.pid")) + if err != nil { + t.Fatal("control did not launch an inherited-fd helper") + } + pid, err := strconv.Atoi(string(data)) + if err != nil || pid <= 0 { + t.Fatal("invalid owned helper PID") + } + t.Cleanup(func() { _ = unix.Kill(pid, unix.SIGKILL) }) + deadline := time.Now().Add(time.Second) + for unix.Kill(pid, 0) == nil && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if unix.Kill(pid, 0) == nil { + t.Fatal("owned initial helper survived group cleanup") + } + }) + } +} + +func TestPipelineDockerIdentityDNSDialAuthority(t *testing.T) { + for _, endpoint := range []struct{ name, original, dial, wire string }{ + {"lowercase-IDN", "bücher.example", "xn--bcher-kva.example", "xn--bcher-kva.example"}, + {"uppercase-IDN", "BÜCHER.example", "xn--bcher-kva.example", "xn--BCHER-2pa.example"}, + {"ASCII-verbatim", "Docker.EXAMPLE", "Docker.EXAMPLE", "Docker.EXAMPLE"}, + } { + for _, mode := range []string{"tcp", "verified-TLS", "wrong-TLS-name"} { + t.Run(endpoint.name+"/"+mode, func(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + requests := make(chan string, 8) + serverNames := make(chan string, 8) + expectedHost := "" + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- r.Host + id := "wrong-host-backend" + if r.Method == "GET" && r.RequestURI == "/info" && r.Host == expectedHost && r.Close { + id = "daemon-A" + } + w.Header().Set("Connection", "close") + _ = json.NewEncoder(w).Encode(map[string]any{"ID": id}) + })) + _, port, err := net.SplitHostPort(server.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + expectedHost = net.JoinHostPort(endpoint.wire, port) + expectedDial := net.JoinHostPort(endpoint.dial, port) + original := "tcp://" + net.JoinHostPort(endpoint.original, port) + fixture.Context = fmt.Sprintf("[{\"Name\":\"default\",\"Endpoints\":{\"docker\":{\"Host\":%q}}}]", original) + fixture.IdentityProxy, fixture.RequireOpenInput = true, true + fixture.IdentityDialAddresses = map[string]string{expectedDial: server.Listener.Addr().String()} + if mode != "tcp" { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + name := endpoint.dial + if mode == "wrong-TLS-name" { + name = "other.invalid" + } + certificate := &x509.Certificate{ + SerialNumber: big.NewInt(1), DNSNames: []string{name}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IsCA: true, BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + server.TLS = &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, + GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + serverNames <- hello.ServerName + return nil, nil + }, + } + server.StartTLS() + material := filepath.Join(dir, "tls") + if err := os.Mkdir(material, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(material, "ca.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("DOCKER_TLS_VERIFY", "1") + t.Setenv("DOCKER_CERT_PATH", material) + } else { + server.Start() + } + t.Cleanup(server.Close) + writePipelineDockerFixture(t, dir, fixture) + client, err := resolvePipelineDockerClient(t.Context()) + if mode == "wrong-TLS-name" { + if client != nil { + _ = client.Close() + t.Fatal("wrong TLS server name established identity") + } + if err == nil || strings.Contains(err.Error(), "x509") || strings.Contains(err.Error(), endpoint.dial) { + t.Fatal("TLS rejection was omitted or exposed private transport error") + } + if len(requests) != 0 || len(serverNames) != 1 || <-serverNames != endpoint.dial { + t.Fatal("negative did not reach the selected canonical TLS name before rejection") + } + return + } + if err != nil { + t.Fatal(err) + } + defer client.Close() + if client.Identity.Endpoint != original || client.Identity.ServerID != "daemon-A" { + t.Fatal("canonical dialing changed the frozen identity URI or selected backend") + } + if _, err := client.Run(t.Context(), "wait", strings.Repeat("a", 64)); err != nil { + t.Fatal(err) + } + if len(requests) != 2 { + t.Fatal("initial/operation identity did not cross the actual fixture socket") + } + for range 2 { + if <-requests != expectedHost { + t.Fatal("wire Host did not retain its independent Punycode policy") + } + } + if mode == "verified-TLS" { + if len(serverNames) != 2 { + t.Fatal("TLS revalidation did not reach both selected handshakes") + } + for range 2 { + if <-serverNames != endpoint.dial { + t.Fatal("TLS SNI retained a noncanonical DNS name") + } + } + } + calls := pipelineDockerCalls(t, dir) + if len(calls) != 4 { + t.Fatal("DNS normalization added or omitted CLI operations") + } + for _, call := range calls[1:] { + at := slices.Index(call.Args, "--host") + if at < 0 || at+1 >= len(call.Args) { + t.Fatal("selected transport was not explicit") + } + want := original + if slices.Contains(call.Args, "dial-stdio") { + want = "tcp://" + expectedDial + } + if call.Args[at+1] != want { + t.Fatal("identity DNS normalization changed another operation's frozen endpoint") + } + if mode == "verified-TLS" && !slices.Contains(call.Args, "--tlsverify=true") { + t.Fatal("DNS normalization weakened the pinned TLS policy") + } + } + }) + } + } +} + +func TestPipelineDockerIdentityInvalidDNSLookupFailsBeforeDial(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + const endpoint = "tcp://a\u200db.example:2376" + // Punycode can serialize this name, but Lookup rejects the joiner's context. + // A partial Lookup result must never reach the selected CLI transport. + if _, err := pipelineDockerIdentityRequest(endpoint); err != nil { + t.Fatal("control did not reach the DNS Lookup boundary") + } + fixture.Context = fmt.Sprintf("[{\"Name\":\"default\",\"Endpoints\":{\"docker\":{\"Host\":%q}}}]", endpoint) + writePipelineDockerFixture(t, dir, fixture) + client, err := resolvePipelineDockerClient(t.Context()) + if client != nil { + _ = client.Close() + t.Fatal("invalid DNS lookup established an identity") + } + if err == nil || strings.Contains(err.Error(), endpoint) || strings.Contains(err.Error(), "idna") { + t.Fatal("DNS lookup rejection was omitted or exposed private transport detail") + } + calls := pipelineDockerCalls(t, dir) + if len(calls) != 1 || !slices.Contains(calls[0].Args, "context") { + t.Fatal("invalid or partial DNS Lookup result reached a daemon command") + } +} + +func TestPipelineDockerIdentityDNSNormalizationPreservesIPv6Scope(t *testing.T) { + const endpoint = "tcp://[fe80::1%25en0]:2376" + dir, fixture := newPipelineDockerFixture(t) + fixture.Context = fmt.Sprintf("[{\"Name\":\"default\",\"Endpoints\":{\"docker\":{\"Host\":%q}}}]", endpoint) + fixture.RequireOpenInput = true + writePipelineDockerFixture(t, dir, fixture) + client := resolvePipelineDockerFixture(t) + if client.Identity.Endpoint != endpoint { + t.Fatal("recorded IPv6 identity URI changed") + } + if _, err := client.Run(t.Context(), "wait", strings.Repeat("a", 64)); err != nil { + t.Fatal(err) + } + calls := pipelineDockerCalls(t, dir) + if len(calls) != 4 { + t.Fatal("IPv6 scope control missed initial/operation revalidation") + } + for _, call := range calls[1:] { + at := slices.Index(call.Args, "--host") + if at < 0 || at+1 >= len(call.Args) || call.Args[at+1] != endpoint { + t.Fatal("HTTP Host normalization stripped the actual IPv6 routing scope") + } + if slices.Contains(call.Args, "dial-stdio") && call.RequestHost != "[fe80::1]:2376" { + t.Fatal("IPv6 routing zone leaked into HTTP Host") + } + } +} diff --git a/cmd/wfctl/pipeline_record_diagnostic_unix_test.go b/cmd/wfctl/pipeline_record_diagnostic_unix_test.go index 02c044d7..d665d50f 100644 --- a/cmd/wfctl/pipeline_record_diagnostic_unix_test.go +++ b/cmd/wfctl/pipeline_record_diagnostic_unix_test.go @@ -757,7 +757,7 @@ func TestPipelineRecordDiagnosticsResolverCancellationAndDeadline(t *testing.T) var call pipelineDockerInvocation if json.Unmarshal(line, &call) == nil { for _, arg := range call.Args { - if arg == stage.command { + if arg == stage.command || (stage.command == "info" && arg == "dial-stdio") { cancel() observed <- true return @@ -842,10 +842,17 @@ func TestPipelineRecordDiagnosticsResolverFirstFailureAndConcurrency(t *testing. for i := 0; i < 8; i++ { workers.Go(func() { operation, args, exit := "resolve_context", []string{"context", "inspect"}, 7 + var input []byte if i%2 == 1 { - operation, args, exit = "resolve_identity", []string{"info", "--format", "{{json .ID}}"}, 0 + operation, args, exit = "resolve_identity", []string{"system", "dial-stdio"}, 0 + var err error + input, err = pipelineDockerIdentityRequest("unix:///daemon-a.sock") + if err != nil { + outcomes <- false + return + } } - result, err := runPipelineDockerCLI(pipelineDockerResolutionContext(ctx, operation), filepath.Join(directory, "docker"), []string{"PATH=" + directory, "HOME=" + directory, "LANG=C", "LC_ALL=C"}, pipelineDockerCallTimeout, args...) + result, err := runPipelineDockerCLIWithInput(pipelineDockerResolutionContext(ctx, operation), filepath.Join(directory, "docker"), []string{"PATH=" + directory, "HOME=" + directory, "LANG=C", "LC_ALL=C"}, pipelineDockerCallTimeout, input, args...) outcomes <- err != nil && result != nil && result.ExitCode == exit }) } diff --git a/cmd/wfctl/testdata/pipeline-record/docker/main.go b/cmd/wfctl/testdata/pipeline-record/docker/main.go index 3ee85bc9..a287f37c 100644 --- a/cmd/wfctl/testdata/pipeline-record/docker/main.go +++ b/cmd/wfctl/testdata/pipeline-record/docker/main.go @@ -3,8 +3,13 @@ package main import ( + "bufio" + "bytes" "encoding/json" "fmt" + "io" + "net/http" + "net/url" "os" "strings" ) @@ -15,14 +20,18 @@ func main() { return } args := os.Args[1:] + host := "unix:///record-fixture-daemon.sock" for len(args) > 0 && strings.HasPrefix(args[0], "-") { - flag, _, inline := strings.Cut(args[0], "=") + flag, value, inline := strings.Cut(args[0], "=") args = args[1:] if !inline && flag != "--tls" && flag != "--tlsverify" { if len(args) == 0 { os.Exit(64) } - args = args[1:] + value, args = args[0], args[1:] + } + if flag == "--host" { + host = value } } if len(args) == 0 { @@ -34,6 +43,10 @@ func main() { // Only daemon identity and empty lifecycle queries are substituted. A // container operation is never simulated by this fixture. switch args[0] { + case "system": + if len(args) != 2 || args[1] != "dial-stdio" || serveRecordDockerIdentity(host, os.Stdin, os.Stdout) != nil { + os.Exit(64) + } case "context": if len(args) < 2 { os.Exit(64) @@ -57,7 +70,37 @@ func main() { case "ps": // This test dependency has no containers, including cleanup-label matches. default: - fmt.Fprintln(os.Stderr, "fixture Docker supports only context, info, and empty ps queries") + fmt.Fprintln(os.Stderr, "fixture Docker supports only context, identity, and empty ps queries") os.Exit(64) } } + +func serveRecordDockerIdentity(endpoint string, input io.Reader, output io.Writer) error { + u, err := url.Parse(endpoint) + if err != nil { + return fmt.Errorf("fixture endpoint invalid") + } + host := "api.moby.localhost" + if u.Scheme == "tcp" { + host = u.Host + } + // Request.Write applies Docker's HTTP authority rules, including stripping + // IPv6 zones and converting international hostnames to their wire form. + var authority bytes.Buffer + probe := &http.Request{Method: http.MethodGet, URL: &url.URL{Scheme: "http", Host: host, Path: "/info"}, Host: host} + if probe.Write(&authority) != nil { + return fmt.Errorf("fixture authority invalid") + } + canonical, err := http.ReadRequest(bufio.NewReader(&authority)) + if err != nil { + return fmt.Errorf("fixture authority invalid") + } + host = canonical.Host + request, err := http.ReadRequest(bufio.NewReader(input)) + if err != nil || request.Method != http.MethodGet || request.RequestURI != "/info" || request.Host != host || !request.Close || request.ContentLength != 0 || len(request.TransferEncoding) != 0 { + return fmt.Errorf("fixture identity request invalid") + } + body := "{\"ID\":\"record-fixture-daemon\",\"Containers\":0}\n" + _, err = fmt.Fprintf(output, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body) + return err +} diff --git a/cmd/wfctl/testdata/pipeline-record/docker/transport.go b/cmd/wfctl/testdata/pipeline-record/docker/transport.go index f7da11b1..809dc362 100644 --- a/cmd/wfctl/testdata/pipeline-record/docker/transport.go +++ b/cmd/wfctl/testdata/pipeline-record/docker/transport.go @@ -52,7 +52,7 @@ func runRecordDockerTransport(args []string) bool { if args[0] == "context" { return false } - if args[0] == "info" { + if args[0] == "info" || slices.Equal(args, []string{"system", "dial-stdio"}) { if cfg.Unavailable { fmt.Fprintln(os.Stderr, "fixture-unavailable-daemon-private-canary") os.Exit(7) diff --git a/cmd/wfctl/testdata/pipeline-record/docker/transport_test.go b/cmd/wfctl/testdata/pipeline-record/docker/transport_test.go index 9bf4d3b9..d629221a 100644 --- a/cmd/wfctl/testdata/pipeline-record/docker/transport_test.go +++ b/cmd/wfctl/testdata/pipeline-record/docker/transport_test.go @@ -3,11 +3,58 @@ package main import ( + "bufio" + "bytes" + "encoding/json" + "net/http" "os" "path/filepath" "testing" ) +func TestTransportIdentityConsumesFramedRequest(t *testing.T) { + for _, test := range []struct { + endpoint, request string + valid bool + }{ + {"unix:///fixture.sock", "GET /info HTTP/1.1\r\nHost: api.moby.localhost\r\nConnection: close\r\n\r\n", true}, + {"tcp://selected.example:2376", "GET /info HTTP/1.1\r\nHost: selected.example:2376\r\nConnection: close\r\n\r\n", true}, + {"tcp://[::1]:2376", "GET /info HTTP/1.1\r\nHost: [::1]:2376\r\nConnection: close\r\n\r\n", true}, + {"tcp://[fe80::1%25en0]:2376", "GET /info HTTP/1.1\r\nHost: [fe80::1]:2376\r\nConnection: close\r\n\r\n", true}, + {"tcp://[fe80::1%25en0]:2376", "GET /info HTTP/1.1\r\nHost: [fe80::1%en0]:2376\r\nConnection: close\r\n\r\n", false}, + {"tcp://bücher.example:2376", "GET /info HTTP/1.1\r\nHost: xn--bcher-kva.example:2376\r\nConnection: close\r\n\r\n", true}, + {"unix:///fixture.sock", "GET /info HTTP/1.1\r\nHost: wrong-backend\r\nConnection: close\r\n\r\n", false}, + {"unix:///fixture.sock", "POST /info HTTP/1.1\r\nHost: api.moby.localhost\r\nConnection: close\r\n\r\n", false}, + {"unix:///fixture.sock", "GET /other HTTP/1.1\r\nHost: api.moby.localhost\r\nConnection: close\r\n\r\n", false}, + {"unix:///fixture.sock", "GET /info HTTP/1.1\r\nHost: api.moby.localhost\r\n\r\n", false}, + {"unix:///fixture.sock", "GET /info HTTP/1.1\r\nHost: api.moby.localhost\r\nConnection: close\r\nContent-Length: 1\r\n\r\nx", false}, + {"unix:///fixture.sock", "malformed", false}, + } { + t.Run(test.request, func(t *testing.T) { + var output bytes.Buffer + err := serveRecordDockerIdentity(test.endpoint, bytes.NewBufferString(test.request), &output) + if !test.valid { + if err == nil || output.Len() != 0 { + t.Fatal("invalid request produced an identity response") + } + return + } + if err != nil { + t.Fatal(err) + } + response, err := http.ReadResponse(bufio.NewReader(&output), &http.Request{Method: "GET"}) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var body map[string]any + if response.StatusCode != 200 || json.NewDecoder(response.Body).Decode(&body) != nil || body["ID"] != "record-fixture-daemon" { + t.Fatal("fixture omitted its framed daemon identity") + } + }) + } +} + func TestTransportCreateWritesOwnedStateAndCID(t *testing.T) { root := t.TempDir() cidfile := filepath.Join(root, "fixture.cid") diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 4a37dd62..6bf6b200 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -1400,6 +1400,23 @@ are mode 0600. Cancellation escalates through graceful shutdown, TERM, and KILL. Failed cleanup or daemon drift retains the journal and blocks new work; do not manually discard retained records to bypass reconciliation. +Daemon identity uses the pinned Docker CLI's system dial-stdio transport +with one HTTP GET /info, rather than docker info client-plugin reporting. +Docker may also ping during CLI initialization. The request preserves the +selected TCP HTTP authority or Docker's Unix dummy host, and keeps stdin open +until the response connection closes. The existing five-second helper bound +applies to every identity recheck. Serialized requests over 512 bytes, full +responses (headers included) over 1 MiB, incomplete or ambiguous framing, +non-200 replies, invalid IDs, and nonclosing peers fail closed. No fallback +or retry establishes identity from partial output. + +For TCP DNS endpoints, the identity CLI invocation uses the same ASCII DNS +lookup policy as Docker's HTTP transport. HTTP Host uses its separate Punycode +policy; uppercase internationalized names can produce a different wire Host. +ASCII authorities, retained identity state, and other CLI operations keep the +original endpoint URI. IPv6 routing scopes remain in the dial target and are +omitted only from the HTTP Host header. + `step.sandbox_exec` in record mode is brokered by the parent. It requires the strict nonroot profile (`65532:65532`), read-only root, dropped capabilities, no-new-privileges, bounded resources, and no host mounts. Commands and env diff --git a/go.mod b/go.mod index 982d23ce..6bd435be 100644 --- a/go.mod +++ b/go.mod @@ -256,7 +256,7 @@ require ( go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/arch v0.28.0 // indirect - golang.org/x/net v0.59.0 // indirect + golang.org/x/net v0.59.0 golang.org/x/sys v0.48.0 golang.org/x/term v0.46.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect