Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sshforward: implement ssh socket forwarding #608

Merged
merged 4 commits into from
Sep 11, 2018
Merged
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
205 changes: 205 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ package client
import (
"archive/tar"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"io/ioutil"
Expand All @@ -27,12 +31,14 @@ import (
"github.com/moby/buildkit/identity"
"github.com/moby/buildkit/session"
"github.com/moby/buildkit/session/secrets/secretsprovider"
"github.com/moby/buildkit/session/sshforward/sshprovider"
"github.com/moby/buildkit/util/testutil"
"github.com/moby/buildkit/util/testutil/httpserver"
"github.com/moby/buildkit/util/testutil/integration"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/sync/errgroup"
)

Expand Down Expand Up @@ -72,13 +78,170 @@ func TestClientIntegration(t *testing.T) {
testExtraHosts,
testNetworkMode,
testFrontendMetadataReturn,
testSSHMount,
})
}

func newContainerd(cdAddress string) (*containerd.Client, error) {
return containerd.New(cdAddress, containerd.WithTimeout(60*time.Second))
}

func testSSHMount(t *testing.T, sb integration.Sandbox) {
t.Parallel()

c, err := New(context.TODO(), sb.Address())
require.NoError(t, err)
defer c.Close()

a := agent.NewKeyring()

k, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)

err = a.Add(agent.AddedKey{PrivateKey: k})
require.NoError(t, err)

sockPath, clean, err := makeSSHAgentSock(a)
require.NoError(t, err)
defer clean()

ssh, err := sshprovider.NewSSHAgentProvider([]sshprovider.AgentConfig{{
Paths: []string{sockPath},
}})
require.NoError(t, err)

// no ssh exposed
st := llb.Image("busybox:latest").Run(llb.Shlex(`nosuchcmd`), llb.AddSSHSocket())
def, err := st.Marshal()
require.NoError(t, err)

_, err = c.Solve(context.TODO(), def, SolveOpt{}, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "no ssh forwarded from the client")

// custom ID not exposed
st = llb.Image("busybox:latest").Run(llb.Shlex(`nosuchcmd`), llb.AddSSHSocket(llb.SSHID("customID")))
def, err = st.Marshal()
require.NoError(t, err)

_, err = c.Solve(context.TODO(), def, SolveOpt{
Session: []session.Attachable{ssh},
}, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "unset ssh forward key customID")

// missing custom ID ignored on optional
st = llb.Image("busybox:latest").Run(llb.Shlex(`ls`), llb.AddSSHSocket(llb.SSHID("customID"), llb.SSHOptional))
def, err = st.Marshal()
require.NoError(t, err)

_, err = c.Solve(context.TODO(), def, SolveOpt{
Session: []session.Attachable{ssh},
}, nil)
require.NoError(t, err)

// valid socket
st = llb.Image("alpine:latest").
Run(llb.Shlex(`apk add --no-cache openssh`)).
Run(llb.Shlex(`sh -c 'echo -n $SSH_AUTH_SOCK > /out/sock && ssh-add -l > /out/out'`),
llb.AddSSHSocket())

out := st.AddMount("/out", llb.Scratch())
def, err = out.Marshal()
require.NoError(t, err)

destDir, err := ioutil.TempDir("", "buildkit")
require.NoError(t, err)
defer os.RemoveAll(destDir)

_, err = c.Solve(context.TODO(), def, SolveOpt{
Exporter: ExporterLocal,
ExporterOutputDir: destDir,
Session: []session.Attachable{ssh},
}, nil)
require.NoError(t, err)

dt, err := ioutil.ReadFile(filepath.Join(destDir, "sock"))
require.NoError(t, err)
require.Equal(t, "/run/buildkit/ssh_agent.0", string(dt))

dt, err = ioutil.ReadFile(filepath.Join(destDir, "out"))
require.NoError(t, err)
require.Contains(t, string(dt), "2048")
require.Contains(t, string(dt), "(RSA)")

// forbidden command
st = llb.Image("alpine:latest").
Run(llb.Shlex(`apk add --no-cache openssh`)).
Run(llb.Shlex(`sh -c 'ssh-keygen -f /tmp/key -N "" && ssh-add -k /tmp/key 2> /out/out || true'`),
llb.AddSSHSocket())

out = st.AddMount("/out", llb.Scratch())
def, err = out.Marshal()
require.NoError(t, err)

require.NoError(t, err)

_, err = c.Solve(context.TODO(), def, SolveOpt{
Exporter: ExporterLocal,
ExporterOutputDir: destDir,
Session: []session.Attachable{ssh},
}, nil)
require.NoError(t, err)

dt, err = ioutil.ReadFile(filepath.Join(destDir, "out"))
require.NoError(t, err)
require.Contains(t, string(dt), "agent refused operation")

// valid socket from key on disk
st = llb.Image("alpine:latest").
Run(llb.Shlex(`apk add --no-cache openssh`)).
Run(llb.Shlex(`sh -c 'ssh-add -l > /out/out'`),
llb.AddSSHSocket())

out = st.AddMount("/out", llb.Scratch())
def, err = out.Marshal()
require.NoError(t, err)

k, err = rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err)

dt = pem.EncodeToMemory(
&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(k),
},
)

tmpDir, err := ioutil.TempDir("", "buildkit")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

err = ioutil.WriteFile(filepath.Join(tmpDir, "key"), dt, 0600)
require.NoError(t, err)

ssh, err = sshprovider.NewSSHAgentProvider([]sshprovider.AgentConfig{{
Paths: []string{filepath.Join(tmpDir, "key")},
}})
require.NoError(t, err)

destDir, err = ioutil.TempDir("", "buildkit")
require.NoError(t, err)
defer os.RemoveAll(destDir)

_, err = c.Solve(context.TODO(), def, SolveOpt{
Exporter: ExporterLocal,
ExporterOutputDir: destDir,
Session: []session.Attachable{ssh},
}, nil)
require.NoError(t, err)

dt, err = ioutil.ReadFile(filepath.Join(destDir, "out"))
require.NoError(t, err)
require.Contains(t, string(dt), "1024")
require.Contains(t, string(dt), "(RSA)")
}

func testExtraHosts(t *testing.T, sb integration.Sandbox) {
t.Parallel()

Expand Down Expand Up @@ -1665,3 +1828,45 @@ func tmpdir(appliers ...fstest.Applier) (string, error) {
}
return tmpdir, nil
}

func makeSSHAgentSock(agent agent.Agent) (p string, cleanup func() error, err error) {
tmpDir, err := ioutil.TempDir("", "buildkit")
if err != nil {
return "", nil, err
}
defer func() {
if err != nil {
os.RemoveAll(tmpDir)
}
}()

sockPath := filepath.Join(tmpDir, "ssh_auth_sock")

l, err := net.Listen("unix", sockPath)
if err != nil {
return "", nil, err
}

s := &server{l: l}
go s.run(agent)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the error is ignored

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An error on a single connection shouldn't fail the build. If it causes process to exit then that will become the error.


return sockPath, func() error {
l.Close()
return os.RemoveAll(tmpDir)
}, nil
}

type server struct {
l net.Listener
}

func (s *server) run(a agent.Agent) error {
for {
c, err := s.l.Accept()
if err != nil {
return err
}

go agent.ServeAgent(a, c)
}
}
79 changes: 79 additions & 0 deletions client/llb/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package llb

import (
_ "crypto/sha256"
"fmt"
"net"
"sort"

Expand Down Expand Up @@ -61,6 +62,7 @@ type ExecOp struct {
constraints Constraints
isValidated bool
secrets []SecretInfo
ssh []SSHInfo
}

func (e *ExecOp) AddMount(target string, source Output, opt ...MountOption) Output {
Expand Down Expand Up @@ -130,6 +132,17 @@ func (e *ExecOp) Marshal(c *Constraints) (digest.Digest, []byte, *pb.OpMetadata,
return e.mounts[i].target < e.mounts[j].target
})

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 _, ok := e.meta.Env.Get("SSH_AUTH_SOCK"); !ok {
e.meta.Env = e.meta.Env.AddOrReplace("SSH_AUTH_SOCK", e.ssh[0].Target)
}
}

meta := &pb.Meta{
Args: e.meta.Args,
Env: e.meta.Env.ToArray(),
Expand Down Expand Up @@ -264,6 +277,21 @@ func (e *ExecOp) Marshal(c *Constraints) (digest.Digest, []byte, *pb.OpMetadata,
peo.Mounts = append(peo.Mounts, pm)
}

for _, s := range e.ssh {
pm := &pb.Mount{
Dest: s.Target,
MountType: pb.MountType_SSH,
SSHOpt: &pb.SSHOpt{
ID: s.ID,
Uid: uint32(s.UID),
Gid: uint32(s.GID),
Mode: uint32(s.Mode),
Optional: s.Optional,
},
}
peo.Mounts = append(peo.Mounts, pm)
}

dt, err := pop.Marshal()
if err != nil {
return "", nil, nil, err
Expand Down Expand Up @@ -432,6 +460,56 @@ func AddMount(dest string, mountState State, opts ...MountOption) RunOption {
})
}

func AddSSHSocket(opts ...SSHOption) RunOption {
return runOptionFunc(func(ei *ExecInfo) {
s := &SSHInfo{
Mode: 0600,
}
for _, opt := range opts {
opt.SetSSHOption(s)
}
ei.SSH = append(ei.SSH, *s)
})
}

type SSHOption interface {
SetSSHOption(*SSHInfo)
}

type sshOptionFunc func(*SSHInfo)

func (fn sshOptionFunc) SetSSHOption(si *SSHInfo) {
fn(si)
}

func SSHID(id string) SSHOption {
return sshOptionFunc(func(si *SSHInfo) {
si.ID = id
})
}

func SSHSocketOpt(target string, uid, gid, mode int) SSHOption {
return sshOptionFunc(func(si *SSHInfo) {
si.Target = target
si.UID = uid
si.GID = gid
si.Mode = mode
})
}

var SSHOptional = sshOptionFunc(func(si *SSHInfo) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why make this writable?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

si.Optional = true
})

type SSHInfo struct {
ID string
Target string
Mode int
UID int
GID int
Optional bool
}

func AddSecret(dest string, opts ...SecretOption) RunOption {
return runOptionFunc(func(ei *ExecInfo) {
s := &SecretInfo{ID: dest, Target: dest, Mode: 0400}
Expand Down Expand Up @@ -498,6 +576,7 @@ type ExecInfo struct {
ReadonlyRootFS bool
ProxyEnv *ProxyEnv
Secrets []SecretInfo
SSH []SSHInfo
}

type MountInfo struct {
Expand Down
1 change: 1 addition & 0 deletions client/llb/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ func (s State) Run(ro ...RunOption) ExecState {
exec.AddMount(m.Target, m.Source, m.Opts...)
}
exec.secrets = ei.Secrets
exec.ssh = ei.SSH

return ExecState{
State: s.WithOutput(exec.Output()),
Expand Down
Loading