diff --git a/Dockerfile b/Dockerfile index 04975bc..7e75229 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,14 +10,18 @@ WORKDIR /go/src/github.com/play-with-docker/play-with-docker RUN go get -v -d ./... +RUN ssh-keygen -N "" -t rsa -f /etc/ssh/ssh_host_rsa_key >/dev/null + RUN CGO_ENABLED=0 go build -a -installsuffix nocgo -o /go/bin/play-with-docker . + FROM alpine RUN apk --update add ca-certificates RUN mkdir -p /app/pwd COPY --from=0 /go/bin/play-with-docker /app/play-with-docker +COPY --from=0 /etc/ssh/ssh_host_rsa_key /etc/ssh/ssh_host_rsa_key COPY ./www /app/www WORKDIR /app diff --git a/Dockerfile.dind b/Dockerfile.dind index bba9a2d..d768cb4 100644 --- a/Dockerfile.dind +++ b/Dockerfile.dind @@ -1,7 +1,7 @@ ARG VERSION=docker:17.05.0-ce-dind FROM ${VERSION} -RUN apk add --no-cache git tmux py2-pip apache2-utils vim build-base gettext-dev curl bash-completion bash util-linux jq +RUN apk add --no-cache git tmux py2-pip apache2-utils vim build-base gettext-dev curl bash-completion bash util-linux jq openssh # Compile and install httping @@ -19,9 +19,11 @@ RUN pip install docker-compose==${COMPOSE_VERSION} RUN curl -L https://github.com/docker/machine/releases/download/${MACHINE_VERSION}/docker-machine-Linux-x86_64 \ -o /usr/bin/docker-machine && chmod +x /usr/bin/docker-machine -# Add bash completion -RUN mkdir /etc/bash_completion.d && curl https://raw.githubusercontent.com/docker/docker/master/contrib/completion/bash/docker -o /etc/bash_completion.d/docker - +# Add bash completion and set bash as default shell +RUN mkdir /etc/bash_completion.d \ + && curl https://raw.githubusercontent.com/docker/cli/master/contrib/completion/bash/docker -o /etc/bash_completion.d/docker \ + && sed -i "s/ash/bash/" /etc/passwd + # Replace modprobe with a no-op to get rid of spurious warnings # (note: we can't just symlink to /bin/true because it might be busybox) RUN rm /sbin/modprobe && echo '#!/bin/true' >/sbin/modprobe && chmod +x /sbin/modprobe @@ -51,6 +53,8 @@ CMD cat /etc/hosts >/etc/hosts.bak && \ sed -i "s/\DOCKER_TLSCERT/$DOCKER_TLSCERT/" /etc/docker/daemon.json && \ sed -i "s/\DOCKER_TLSKEY/$DOCKER_TLSKEY/" /etc/docker/daemon.json && \ umount /var/lib/docker && mount -t securityfs none /sys/kernel/security && \ + echo "root:root" | chpasswd &> /dev/null && ssh-keygen -N "" -t rsa -f /etc/ssh/ssh_host_rsa_key >/dev/null && \ + /usr/sbin/sshd -o PermitRootLogin=yes -o PrintMotd=no 2>/dev/null && \ dockerd &>/docker.log & \ while true ; do script -q -c "/bin/bash -l" /dev/null ; done # ... and then put a shell in the foreground, restarting it if it exits diff --git a/api.go b/api.go index 995034b..313e2bf 100644 --- a/api.go +++ b/api.go @@ -111,6 +111,8 @@ func main() { log.Fatal(httpServer.ListenAndServe()) }() + go handlers.ListenSSHProxy("0.0.0.0:1022") + // Now listen for TLS connections that need to be proxied handlers.StartTLSProxy(config.SSLPortNumber) } diff --git a/docker-compose.yml b/docker-compose.yml index e779202..8bd8bf3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: # use the latest golang image image: golang # go to the right place and starts the app - command: /bin/sh -c 'cd /go/src/github.com/play-with-docker/play-with-docker; go run api.go -save /pwd/sessions1 -name pwd1 -cname host1' + command: /bin/sh -c 'ssh-keygen -N "" -t rsa -f /etc/ssh/ssh_host_rsa_key >/dev/null; cd /go/src/github.com/play-with-docker/play-with-docker; go run api.go -save /pwd/sessions1 -name pwd1 -cname host1' volumes: # since this app creates networks and launches containers, we need to talk to docker daemon - /var/run/docker.sock:/var/run/docker.sock @@ -24,13 +24,15 @@ services: - sessions:/pwd environment: GOOGLE_RECAPTCHA_DISABLED: "true" + ports: + - "1022:1022" pwd2: # pwd daemon container always needs to be named this way container_name: pwd2 # use the latest golang image image: golang # go to the right place and starts the app - command: /bin/sh -c 'cd /go/src/github.com/play-with-docker/play-with-docker; go run api.go -save /pwd/sessions2 -name pwd2 -cname host2' + command: /bin/sh -c 'ssh-keygen -N "" -t rsa -f /etc/ssh/ssh_host_rsa_key >/dev/null; cd /go/src/github.com/play-with-docker/play-with-docker; go run api.go -save /pwd/sessions2 -name pwd2 -cname host2' volumes: # since this app creates networks and launches containers, we need to talk to docker daemon - /var/run/docker.sock:/var/run/docker.sock @@ -39,6 +41,8 @@ services: - sessions:/pwd environment: GOOGLE_RECAPTCHA_DISABLED: "true" + ports: + - "1023:1022" prometheus: container_name: prometheus image: prom/prometheus diff --git a/handlers/reverseproxy.go b/handlers/reverseproxy.go index 98ad132..ed5a72e 100644 --- a/handlers/reverseproxy.go +++ b/handlers/reverseproxy.go @@ -60,6 +60,14 @@ func (p *tcpProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { logFunc = p.ErrorLog.Printf } + vars := mux.Vars(r) + instanceIP := vars["node"] + + if i := core.InstanceFindByIP(strings.Replace(instanceIP, "-", ".", -1)); i == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + outreq := new(http.Request) // shallow copying *outreq = *r diff --git a/handlers/sshproxy.go b/handlers/sshproxy.go new file mode 100644 index 0000000..68d9d8f --- /dev/null +++ b/handlers/sshproxy.go @@ -0,0 +1,206 @@ +package handlers + +import ( + "fmt" + "io" + "io/ioutil" + "log" + "net" + "strings" + "sync" + + "golang.org/x/crypto/ssh" +) + +var sshConfig = &ssh.ServerConfig{ + PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { + user := c.User() + chunks := strings.Split(user, "-") + ip := strings.Join(chunks[:4], ".") + sessionPrefix := chunks[4] + + log.Println(ip, sessionPrefix) + + return nil, nil + }, +} + +func ListenSSHProxy(laddr string) { + privateBytes, err := ioutil.ReadFile("/etc/ssh/ssh_host_rsa_key") + if err != nil { + log.Fatal("Failed to load private key: ", err) + } + + private, err := ssh.ParsePrivateKey(privateBytes) + if err != nil { + log.Fatal("Failed to parse private key: ", err) + } + + sshConfig.AddHostKey(private) + + listener, err := net.Listen("tcp", laddr) + if err != nil { + log.Fatal("failed to listen for connection: ", err) + } + for { + nConn, err := listener.Accept() + if err != nil { + log.Fatal("failed to accept incoming connection: ", err) + } + + go handle(nConn) + } +} + +func handle(c net.Conn) { + sshCon, chans, reqs, err := ssh.NewServerConn(c, sshConfig) + if err != nil { + c.Close() + return + } + + user := sshCon.User() + chunks := strings.Split(user, "-") + ip := strings.Join(chunks[:4], ".") + sessionPrefix := chunks[4] + + i := core.InstanceFindByIPAndSession(sessionPrefix, ip) + if i == nil { + log.Printf("Couldn't find instance with ip [%s] in session [%s]\n", ip, sessionPrefix) + c.Close() + return + } + + // The incoming Request channel must be serviced. + go ssh.DiscardRequests(reqs) + + newChannel := <-chans + if newChannel == nil { + sshCon.Close() + return + } + + if newChannel.ChannelType() != "session" { + newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") + return + } + + channel, requests, err := newChannel.Accept() + if err != nil { + log.Fatalf("Could not accept channel: %v", err) + } + + stderr := channel.Stderr() + + fmt.Fprintf(stderr, "Connecting to %s\r\n", ip) + + clientConfig := &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ + ssh.Password("root"), + }, + HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { + return nil + }, + } + + client, err := ssh.Dial("tcp", fmt.Sprintf("%s:22", ip), clientConfig) + if err != nil { + fmt.Fprintf(stderr, "Connect failed: %v\r\n", err) + channel.Close() + return + } + + go func() { + for newChannel = range chans { + if newChannel == nil { + return + } + + channel2, reqs2, err := client.OpenChannel(newChannel.ChannelType(), newChannel.ExtraData()) + if err != nil { + x, ok := err.(*ssh.OpenChannelError) + if ok { + newChannel.Reject(x.Reason, x.Message) + } else { + newChannel.Reject(ssh.Prohibited, "remote server denied channel request") + } + continue + } + + channel, reqs, err := newChannel.Accept() + if err != nil { + channel2.Close() + continue + } + go proxy(reqs, reqs2, channel, channel2) + } + }() + + // Forward the session channel + channel2, reqs2, err := client.OpenChannel("session", []byte{}) + if err != nil { + fmt.Fprintf(stderr, "Remote session setup failed: %v\r\n", err) + channel.Close() + return + } + + maskedReqs := make(chan *ssh.Request, 1) + go func() { + for req := range requests { + if req.Type == "auth-agent-req@openssh.com" { + continue + } + maskedReqs <- req + } + }() + proxy(maskedReqs, reqs2, channel, channel2) +} + +func proxy(reqs1, reqs2 <-chan *ssh.Request, channel1, channel2 ssh.Channel) { + var closer sync.Once + closeFunc := func() { + channel1.Close() + channel2.Close() + } + + defer closer.Do(closeFunc) + + closerChan := make(chan bool, 1) + + go func() { + io.Copy(channel1, channel2) + closerChan <- true + }() + + go func() { + io.Copy(channel2, channel1) + closerChan <- true + }() + + for { + select { + case req := <-reqs1: + if req == nil { + return + } + b, err := channel2.SendRequest(req.Type, req.WantReply, req.Payload) + if err != nil { + return + } + req.Reply(b, nil) + + case req := <-reqs2: + if req == nil { + return + } + b, err := channel1.SendRequest(req.Type, req.WantReply, req.Payload) + if err != nil { + return + } + req.Reply(b, nil) + case <-closerChan: + return + } + } +} diff --git a/pwd/instance.go b/pwd/instance.go index 6f14aed..d9751a1 100644 --- a/pwd/instance.go +++ b/pwd/instance.go @@ -97,6 +97,16 @@ func (p *pwd) InstanceFindByIP(ip string) *types.Instance { return i } +func (p *pwd) InstanceFindByIPAndSession(sessionPrefix, ip string) *types.Instance { + defer observeAction("InstanceFindByIPAndSession", time.Now()) + i, err := p.storage.InstanceFindByIPAndSession(sessionPrefix, ip) + if err != nil { + return nil + } + + return i +} + func (p *pwd) InstanceFindByAlias(sessionPrefix, alias string) *types.Instance { defer observeAction("InstanceFindByAlias", time.Now()) i, err := p.storage.InstanceFindByAlias(sessionPrefix, alias) diff --git a/pwd/pwd.go b/pwd/pwd.go index f0c3a1c..1abba0c 100644 --- a/pwd/pwd.go +++ b/pwd/pwd.go @@ -63,6 +63,7 @@ type PWDApi interface { InstanceGet(session *types.Session, name string) *types.Instance InstanceFindByIP(ip string) *types.Instance InstanceFindByAlias(sessionPrefix, alias string) *types.Instance + InstanceFindByIPAndSession(sessionPrefix, ip string) *types.Instance InstanceDelete(session *types.Session, instance *types.Instance) error InstanceWriteToTerminal(instance *types.Instance, data string) InstanceAllowedImages() []string diff --git a/pwd/storage_mock_test.go b/pwd/storage_mock_test.go index d9d5d73..7074c21 100644 --- a/pwd/storage_mock_test.go +++ b/pwd/storage_mock_test.go @@ -3,14 +3,15 @@ package pwd import "github.com/play-with-docker/play-with-docker/pwd/types" type mockStorage struct { - sessionGet func(sessionId string) (*types.Session, error) - sessionPut func(s *types.Session) error - sessionCount func() (int, error) - sessionDelete func(sessionId string) error - instanceFindByAlias func(sessionPrefix, alias string) (*types.Instance, error) - instanceFindByIP func(ip string) (*types.Instance, error) - instanceCount func() (int, error) - clientCount func() (int, error) + sessionGet func(sessionId string) (*types.Session, error) + sessionPut func(s *types.Session) error + sessionCount func() (int, error) + sessionDelete func(sessionId string) error + instanceFindByAlias func(sessionPrefix, alias string) (*types.Instance, error) + instanceFindByIP func(ip string) (*types.Instance, error) + instanceFindByIPAndSession func(sessionPrefix, ip string) (*types.Instance, error) + instanceCount func() (int, error) + clientCount func() (int, error) } func (m *mockStorage) SessionGet(sessionId string) (*types.Session, error) { @@ -49,6 +50,12 @@ func (m *mockStorage) InstanceFindByIP(ip string) (*types.Instance, error) { } return nil, nil } +func (m *mockStorage) InstanceFindByIPAndSession(sessionPrefix, ip string) (*types.Instance, error) { + if m.instanceFindByIPAndSession != nil { + return m.instanceFindByIPAndSession(sessionPrefix, ip) + } + return nil, nil +} func (m *mockStorage) InstanceCount() (int, error) { if m.instanceCount != nil { return m.instanceCount() diff --git a/storage/file.go b/storage/file.go index 7b3dfa8..0697ec7 100644 --- a/storage/file.go +++ b/storage/file.go @@ -51,6 +51,23 @@ func (store *storage) InstanceFindByIP(ip string) (*types.Instance, error) { return nil, fmt.Errorf("%s", notFound) } +func (store *storage) InstanceFindByIPAndSession(sessionPrefix, ip string) (*types.Instance, error) { + store.rw.Lock() + defer store.rw.Unlock() + + for id, s := range store.db { + if strings.HasPrefix(id, sessionPrefix) { + for _, i := range s.Instances { + if i.IP == ip { + return i, nil + } + } + } + } + + return nil, fmt.Errorf("%s", notFound) +} + func (store *storage) InstanceFindByAlias(sessionPrefix, alias string) (*types.Instance, error) { store.rw.Lock() defer store.rw.Unlock() diff --git a/storage/file_test.go b/storage/file_test.go index 47fa848..993f041 100644 --- a/storage/file_test.go +++ b/storage/file_test.go @@ -109,6 +109,49 @@ func TestInstanceFindByIP(t *testing.T) { assert.Nil(t, foundInstance) } +func TestInstanceFindByIPAndSession(t *testing.T) { + tmpfile, err := ioutil.TempFile("", "pwd") + if err != nil { + log.Fatal(err) + } + tmpfile.Close() + os.Remove(tmpfile.Name()) + defer os.Remove(tmpfile.Name()) + + storage, err := NewFileStorage(tmpfile.Name()) + + assert.Nil(t, err) + + i1 := &types.Instance{Name: "i1", IP: "10.0.0.1"} + i2 := &types.Instance{Name: "i2", IP: "10.1.0.1"} + s1 := &types.Session{Id: "session1", Instances: map[string]*types.Instance{"i1": i1}} + s2 := &types.Session{Id: "session2", Instances: map[string]*types.Instance{"i2": i2}} + err = storage.SessionPut(s1) + assert.Nil(t, err) + err = storage.SessionPut(s2) + assert.Nil(t, err) + + foundInstance, err := storage.InstanceFindByIPAndSession("session1", "10.0.0.1") + assert.Nil(t, err) + assert.Equal(t, i1, foundInstance) + + foundInstance, err = storage.InstanceFindByIPAndSession("session2", "10.1.0.1") + assert.Nil(t, err) + assert.Equal(t, i2, foundInstance) + + foundInstance, err = storage.InstanceFindByIPAndSession("session3", "10.1.0.1") + assert.True(t, NotFound(err)) + assert.Nil(t, foundInstance) + + foundInstance, err = storage.InstanceFindByIPAndSession("session1", "10.1.0.1") + assert.True(t, NotFound(err)) + assert.Nil(t, foundInstance) + + foundInstance, err = storage.InstanceFindByIPAndSession("session1", "192.168.0.1") + assert.True(t, NotFound(err)) + assert.Nil(t, foundInstance) +} + func TestInstanceFindByAlias(t *testing.T) { tmpfile, err := ioutil.TempFile("", "pwd") if err != nil { diff --git a/storage/storage.go b/storage/storage.go index 2cd7e96..4ae72c6 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -17,6 +17,7 @@ type StorageApi interface { InstanceFindByAlias(sessionPrefix, alias string) (*types.Instance, error) // Should have the session id too, soon InstanceFindByIP(ip string) (*types.Instance, error) + InstanceFindByIPAndSession(sessionPrefix, ip string) (*types.Instance, error) InstanceCount() (int, error) ClientCount() (int, error)