Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions client/client_ssh_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package client

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"

"github.com/moby/buildkit/client/llb"
"github.com/moby/buildkit/session"
"github.com/moby/buildkit/session/sshforward/sshprovider"
"github.com/moby/buildkit/util/testutil/integration"
"github.com/moby/buildkit/util/testutil/sshutil"
"github.com/moby/buildkit/util/testutil/sshutil/probe"
"github.com/stretchr/testify/require"
)

func init() {
allTests = append(allTests, testSSHMountWindows)
}

func testSSHMountWindows(t *testing.T, sb integration.Sandbox) {
c, err := New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()

workers, err := c.ListWorkers(sb.Context())
require.NoError(t, err)
require.NotEmpty(t, workers)
require.NotEmpty(t, workers[0].Platforms)
target := workers[0].Platforms[0]
require.Equal(t, "windows", target.OS, "SSH tests require a Windows worker")
binary := sshutil.BuildProbe(t, target.Architecture)
base := llb.Image("nanoserver:latest", llb.Platform(target)).
File(llb.Mkfile("/sshprobe.exe", 0755, binary)).
User("ContainerAdministrator")
for _, tc := range []struct {
name string
provider bool
id string
exposeID bool
optional bool
keyFile bool
mutate bool
connections int
wantError string
}{
{name: "required-no-provider", wantError: "no SSH key "},
{name: "required-missing-id", provider: true, id: "customID", wantError: "unset ssh forward key customID"},
{name: "optional-no-provider", optional: true},
{name: "optional-missing-id", provider: true, id: "customID", optional: true},
{name: "agent-identity", provider: true, connections: 1},
{name: "key-file-identity", provider: true, keyFile: true, connections: 1},
{name: "custom-id-identity", provider: true, id: "customID", exposeID: true, connections: 1},
{name: "agent-read-only", provider: true, mutate: true, connections: 1},
{name: "connection-lifecycle", provider: true, connections: 5},
} {
t.Run(tc.name, func(t *testing.T) {
a := sshutil.NewAgent(t)
if tc.connections > 1 {
a.RequireSequentialConnections()
}
var attachables []session.Attachable
if tc.provider {
path := a.Endpoint
if tc.keyFile {
path = a.KeyFile
}
configs := []sshprovider.AgentConfig{{Paths: []string{path}}}
if tc.exposeID {
// A different default key makes selecting the wrong ID observable.
other := sshutil.NewAgent(t)
configs = []sshprovider.AgentConfig{
{Paths: []string{other.Endpoint}},
{ID: tc.id, Paths: []string{path}},
}
}
provider, err := sshprovider.NewSSHAgentProvider(configs)
require.NoError(t, err)
attachables = []session.Attachable{provider}
}
opts := []llb.SSHOption{llb.SSHID(tc.id)}
if tc.optional {
opts = append(opts, llb.SSHOptional)
}
args := []string{`C:\sshprobe.exe`, "-output", `C:\ssh-report.json`}
if tc.optional {
args = append(args, "-absent")
} else {
args = append(args, "-expected", a.PublicKey, "-cycles", fmt.Sprint(tc.connections))
}
if tc.mutate {
args = append(args, "-mutate")
}
run := base.Run(llb.Args(args), llb.AddSSHSocket(opts...), llb.IgnoreCache)
out := llb.Scratch().File(llb.Copy(run.Root(), "/ssh-report.json", "/ssh-report.json"))
def, err := out.Marshal(sb.Context())
require.NoError(t, err)
dest := sshutil.WorkDir(t)
solve := func() error {
_, err := c.Solve(sb.Context(), def, SolveOpt{
Session: attachables,
Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: dest}},
}, nil)
return err
}
err = solve()
if tc.wantError != "" {
require.ErrorContains(t, err, tc.wantError)
require.NotContains(t, err.Error(), "did not complete successfully")
return
}
require.NoError(t, err)
dt, err := os.ReadFile(filepath.Join(dest, "ssh-report.json"))
require.NoError(t, err)
var report probe.Report
require.NoError(t, json.Unmarshal(dt, &report))
require.Equal(t, tc.optional, report.Absent)
require.Equal(t, tc.connections, report.Connections)
if !tc.optional {
require.Equal(t, []string{a.PublicKey}, report.Keys)
require.Equal(t, tc.mutate, report.AddRejected)
require.Equal(t, tc.mutate, report.RemoveAllRejected)
}
a.CheckKey(t)
if tc.provider && !tc.keyFile && !tc.optional {
a.WaitIdle(t, tc.connections)
if tc.connections > 1 {
require.NoError(t, solve(), "connections must still work after disconnect")
a.WaitIdle(t, 2*tc.connections)
}
}
})
}
}
54 changes: 41 additions & 13 deletions client/llb/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import (
"github.com/pkg/errors"
)

// windowsSSHAgentPipe is the fixed named pipe that Windows OpenSSH uses to
// reach the SSH agent. It is the default SSH mount target on Windows.
const windowsSSHAgentPipe = `\\.\pipe\openssh-ssh-agent`

func NewExecOp(base State, proxyEnv *ProxyEnv, readOnly bool, c Constraints) *ExecOp {
e := &ExecOp{base: base, constraints: c, proxyEnv: proxyEnv}
root := base.Output()
Expand Down Expand Up @@ -131,6 +135,18 @@ func (e *ExecOp) Validate(ctx context.Context, c *Constraints) error {
return nil
}

// marshalOS returns the target OS for this exec, preferring the marshal-time
// constraints platform, then the op's own platform, and defaulting to linux.
func (e *ExecOp) marshalOS(c *Constraints) string {
if c.Platform != nil {
return c.Platform.OS
}
if e.constraints.Platform != nil {
return e.constraints.Platform.OS
}
return "linux"
}

func (e *ExecOp) Marshal(ctx context.Context, c *Constraints) (digest.Digest, []byte, *pb.OpMetadata, []*SourceLocation, error) {
cache := e.cache.Acquire()
defer cache.Release()
Expand All @@ -153,25 +169,37 @@ func (e *ExecOp) Marshal(ctx context.Context, c *Constraints) (digest.Digest, []
}

if len(e.ssh) > 0 {
for i, s := range e.ssh {
if s.Target == "" {
e.ssh[i].Target = fmt.Sprintf("/run/buildkit/ssh_agent.%d", i)
if e.marshalOS(c) == "windows" {
// Windows OpenSSH always connects to the fixed named pipe
// \\.\pipe\openssh-ssh-agent and ignores SSH_AUTH_SOCK. Every mount
// shares that single destination (there is no per-mount default like
// the Unix ssh_agent.N sockets), so default empty targets to the pipe
// and reject duplicates that would otherwise silently collide.
seen := make(map[string]struct{}, len(e.ssh))
for i := range e.ssh {
if e.ssh[i].Target == "" {
e.ssh[i].Target = windowsSSHAgentPipe
}
if _, ok := seen[e.ssh[i].Target]; ok {
return "", nil, nil, nil, errors.Errorf("multiple SSH mounts target the same Windows pipe %q; specify a distinct target for each", e.ssh[i].Target)
}
seen[e.ssh[i].Target] = struct{}{}
}
} else {
for i, s := range e.ssh {
if s.Target == "" {
e.ssh[i].Target = fmt.Sprintf("/run/buildkit/ssh_agent.%d", i)
}
}
if _, ok := env.Get("SSH_AUTH_SOCK"); !ok {
env = env.AddOrReplace("SSH_AUTH_SOCK", e.ssh[0].Target)
}
}
if _, ok := env.Get("SSH_AUTH_SOCK"); !ok {
env = env.AddOrReplace("SSH_AUTH_SOCK", e.ssh[0].Target)
}
}
if c.Caps != nil {
if err := c.Caps.Supports(pb.CapExecMetaSetsDefaultPath); err != nil {
os := "linux"
if c.Platform != nil {
os = c.Platform.OS
} else if e.constraints.Platform != nil {
os = e.constraints.Platform.OS
}
// don't set PATH on Windows. #5445
if os != "windows" {
if os := e.marshalOS(c); os != "windows" {
env = env.SetDefault("PATH", system.DefaultPathEnv(os))
}
} else {
Expand Down
54 changes: 54 additions & 0 deletions client/llb/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

"github.com/moby/buildkit/solver/pb"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -171,3 +172,56 @@ func TestExecOpMarshalConsistency(t *testing.T) {
prevDef = def.Def
}
}

var windowsPlatform = Platform(ocispecs.Platform{OS: "windows", Architecture: "amd64"})

// TestSSHWindowsDuplicateTargetError verifies that on Windows two SSH mounts
// that resolve to the same named-pipe destination (e.g. both defaulting to the
// OpenSSH agent pipe) are rejected at marshal time rather than silently
// colliding.
func TestSSHWindowsDuplicateTargetError(t *testing.T) {
t.Parallel()

st := Image("foo").Run(Shlex("args"), AddSSHSocket(), AddSSHSocket()).Root()
_, err := st.Marshal(t.Context(), windowsPlatform)
require.Error(t, err)
require.Contains(t, err.Error(), "same Windows pipe")
}

// TestSSHWindowsExplicitDuplicateTargetError verifies the guard also catches
// two mounts explicitly configured with the same target.
func TestSSHWindowsExplicitDuplicateTargetError(t *testing.T) {
t.Parallel()

st := Image("foo").Run(Shlex("args"),
AddSSHSocket(SSHSocketTarget(`\\.\pipe\openssh-ssh-agent`)),
AddSSHSocket(SSHSocketTarget(`\\.\pipe\openssh-ssh-agent`)),
).Root()
_, err := st.Marshal(t.Context(), windowsPlatform)
require.Error(t, err)
require.Contains(t, err.Error(), "same Windows pipe")
}

// TestSSHWindowsDistinctTargetsOK verifies distinct targets marshal cleanly on
// Windows.
func TestSSHWindowsDistinctTargetsOK(t *testing.T) {
t.Parallel()

st := Image("foo").Run(Shlex("args"),
AddSSHSocket(),
AddSSHSocket(SSHSocketTarget(`\\.\pipe\custom-agent`)),
).Root()
_, err := st.Marshal(t.Context(), windowsPlatform)
require.NoError(t, err)
}

// TestSSHMultipleDefaultsNonWindowsOK verifies the duplicate guard is
// Windows-only: on other platforms multiple default SSH mounts get distinct
// per-index socket targets and marshal without error.
func TestSSHMultipleDefaultsNonWindowsOK(t *testing.T) {
t.Parallel()

st := Image("foo").Run(Shlex("args"), AddSSHSocket(), AddSSHSocket()).Root()
_, err := st.Marshal(t.Context(), LinuxAmd64)
require.NoError(t, err)
}
5 changes: 5 additions & 0 deletions cmd/buildkitd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,11 @@ func newGRPCListeners(cfg config.GRPCConfig) ([]net.Listener, error) {
}
}

// Apply the operator's explicitly-configured security descriptor to
// forwarded SSH agent pipes as well. When unset, the agent pipe keeps its
// container-reachable default rather than the host-facing control-pipe ACL.
applyAgentPipeSecurityDescriptor(cfg.SecurityDescriptor)

listeners := make([]net.Listener, 0, len(addrs))
for _, addr := range addrs {
l, err := getListener(addr, *cfg.UID, *cfg.GID, sd, tlsConfig, true)
Expand Down
5 changes: 5 additions & 0 deletions cmd/buildkitd/main_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,8 @@ func getLocalListener(listenerPath, _ string) (net.Listener, error) {
func groupToSecurityDescriptor(_ string) (string, error) {
return "", nil
}

// applyAgentPipeSecurityDescriptor is a no-op on non-Windows platforms, where
// SSH agents are forwarded over UNIX sockets guarded by chown/chmod instead of
// a security descriptor.
func applyAgentPipeSecurityDescriptor(_ string) {}
8 changes: 8 additions & 0 deletions cmd/buildkitd/main_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@ import (
"strings"

"github.com/Microsoft/go-winio"
"github.com/moby/buildkit/session/sshforward"
_ "github.com/moby/buildkit/solver/llbsolver/ops"
_ "github.com/moby/buildkit/util/system/getuserinfo"
"github.com/pkg/errors"
)

// applyAgentPipeSecurityDescriptor forwards an operator-configured security
// descriptor to the SSH agent named pipe so a locked-down daemon applies the
// same ACL to forwarded agent pipes. An empty descriptor keeps the default.
func applyAgentPipeSecurityDescriptor(sd string) {
sshforward.SetAgentPipeSecurityDescriptor(sd)
}

const socketScheme = "npipe://"

func listenFD(_ string, _ *tls.Config) (net.Listener, error) {
Expand Down
14 changes: 14 additions & 0 deletions executor/oci/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,20 @@ func GenerateSpec(ctx context.Context, meta executor.Meta, mounts []executor.Mou
}
releasers = append(releasers, release)
for _, mount := range mounts {
// Windows named pipes (e.g. a forwarded SSH agent) are handed
// straight to HCS: they cannot be locally mounted by the
// snapshotter and their destination (e.g. \\.\pipe\openssh-ssh-agent)
// must be preserved verbatim rather than rooted to C:\.
if isNamedPipeMount(mount) {
s.Mounts = append(s.Mounts, specs.Mount{
Destination: filepath.FromSlash(m.Dest),
Type: normalizeMountType(mount.Type),
Source: mount.Source,
Options: mount.Options,
})
continue
}

mount, release, err := compactLongOverlayMount(mount, m.Readonly)
if err != nil {
releaseAll()
Expand Down
7 changes: 7 additions & 0 deletions executor/oci/spec_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

package oci

import "github.com/containerd/containerd/v2/core/mount"

// no effect for non-Windows
func normalizeMountType(mType string) string {
return mType
}

// isNamedPipeMount is always false on non-Windows platforms.
func isNamedPipeMount(_ mount.Mount) bool {
return false
}
12 changes: 12 additions & 0 deletions executor/oci/spec_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ import (

const (
tracingSocketPath = "//./pipe/otel-grpc"

// NamedPipeMountType marks a mount whose source is a Windows named pipe
// (e.g. a forwarded SSH agent). Such mounts are passed straight through to
// HCS as a pipe and must not go through the local snapshotter mount path or
// have their destination rooted to C:\.
NamedPipeMountType = "npipe"
)

func withProcessArgs(args ...string) oci.SpecOpts {
Expand Down Expand Up @@ -257,3 +263,9 @@ func normalizeMountType(_ string) string {
// for the mount.
return ""
}

// isNamedPipeMount reports whether the mount source is a Windows named pipe
// that should be forwarded directly to HCS rather than mounted locally.
func isNamedPipeMount(m mount.Mount) bool {
return m.Type == NamedPipeMountType
}
Loading