├── .goxc.json ├── Dockerfile ├── httpserver.go ├── .gitignore ├── make.sh ├── LICENSE.txt ├── main.go ├── config.go ├── server.go ├── client.go └── README.md /.goxc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ArtifactsDest": "./pkg", 3 | "Tasks": [ 4 | "interpolate-source", 5 | "go-install", 6 | "xc", 7 | "copy-resources", 8 | "archive-zip", 9 | "archive-tar-gz", 10 | "rmbin" 11 | ], 12 | "Arch": "amd64", 13 | "BuildConstraints": "linux", 14 | "PackageVersion": "0.5.2", 15 | "ConfigVersion": "0.9" 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.9 as builder 2 | ADD . /go/src/docker-ssh-exec 3 | ENV CGO_ENABLED=0 GOOS=linux 4 | WORKDIR /go/src/docker-ssh-exec 5 | RUN go build -ldflags '-w -s' -a -installsuffix cgo -o /docker-ssh-exec 6 | 7 | FROM scratch as runtime 8 | COPY --from=builder /docker-ssh-exec /docker-ssh-exec 9 | ENV HOME=/root 10 | EXPOSE 80 11 | ENTRYPOINT ["/docker-ssh-exec"] 12 | -------------------------------------------------------------------------------- /httpserver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | func serveHTTP(keyData *[]byte, port int) { 10 | fmt.Printf("Starting on port %d:...\n", port) 11 | http.HandleFunc("/", 12 | func(w http.ResponseWriter, r *http.Request) { 13 | w.Write([]byte(fmt.Sprintf("%s\n", *keyData))) 14 | }) 15 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), nil)) 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # program output from databag-env 2 | *.env 3 | 4 | # goxc build output and local config 5 | pkg/ 6 | *.goxc.local.json 7 | 8 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 9 | *.o 10 | *.a 11 | *.so 12 | 13 | # Folders 14 | _obj 15 | _test 16 | 17 | # Architecture specific extensions/prefixes 18 | *.[568vq] 19 | [568vq].out 20 | 21 | *.cgo1.go 22 | *.cgo2.c 23 | _cgo_defun.c 24 | _cgo_gotypes.go 25 | _cgo_export.* 26 | 27 | _testmain.go 28 | 29 | *.exe 30 | *.test 31 | 32 | tmp/ 33 | -------------------------------------------------------------------------------- /make.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # runs goxc in each product directory 3 | set -e 4 | 5 | goxc 6 | echo "Building static linux binary for docker-ssh-exec..." 7 | mkdir -p pkg 8 | buildcmd='CGO_ENABLED=0 go build -a --installsuffix cgo --ldflags="-s" -o pkg/docker-ssh-exec' 9 | docker run --rm -it -v "$GOPATH":/gopath -v "$(pwd)":/app -e "GOPATH=/gopath" \ 10 | -w /app golang:1.9 sh -c "$buildcmd" 11 | 12 | echo "Building docker image for docker-ssh-exec..." 13 | docker build --no-cache=true --tag mdsol/docker-ssh-exec . 14 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | OSI MIT License 2 | 3 | Copyright (c) 2015 Medidata Solutions, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | ) 8 | 9 | const ( 10 | SERVER_RECV_PORT = 1067 11 | CLIENT_TIMEOUT = 3 // seconds 12 | KEY_REQUEST_TEXT = `Key, plz!` 13 | UDP_MSG_SIZE = 4096 // the effective max key size 14 | KEY_DATA_ENV_VAR = `DOCKER-SSH-KEY` 15 | ) 16 | 17 | // Package version & timestamp - interpolated by goxc 18 | const VERSION = "0.5.2" 19 | const SOURCE_DATE = "2017-10-13T16:40:18-07:00" 20 | 21 | func main() { 22 | config := newConfig() 23 | if config.Server { 24 | server(config) 25 | } else { 26 | client(config) 27 | } 28 | } 29 | 30 | // Opens a UDP read socket at UDPAddr, and returns the connection object. 31 | // mode should be "r" or "w" 32 | // Exits and prints the error if one occurs. 33 | func openUDPSocket(mode string, addr net.UDPAddr) (socket *net.UDPConn) { 34 | var err error 35 | if mode == `w` { 36 | socket, err = net.DialUDP("udp4", nil, &addr) 37 | } else { 38 | socket, err = net.ListenUDP("udp4", &addr) 39 | } 40 | if err != nil { 41 | fmt.Println("Error opening receive port: ", err) 42 | os.Exit(0) 43 | } 44 | return socket 45 | } 46 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | const DEFAULT_KEYPATH = `~/.ssh/id_rsa` 11 | const DEFAULT_PWD = `$RSA_KEY_PWD` 12 | 13 | // Represents this app's possible configuration values 14 | type Config struct { 15 | KeyPath string 16 | Pwd string 17 | Server bool 18 | UDPPort int 19 | HTTPPort int 20 | Wait int 21 | } 22 | 23 | // Generates and returns a new Config based on the command-line 24 | func newConfig() Config { 25 | var ( 26 | keyArg = flag.String("key", DEFAULT_KEYPATH, "path to key file") 27 | print_v = flag.Bool("version", false, "print version and exit") 28 | server = flag.Bool("server", false, "run key server instead of command") 29 | udpPort = flag.Int("port", SERVER_RECV_PORT, "server UDP receiving port") 30 | httpPort = flag.Int("http", 80, "server HTTP server port") 31 | wait = flag.Int("wait", CLIENT_TIMEOUT, "client timeout, in seconds") 32 | pwd = flag.String("pwd", DEFAULT_PWD, "password for encrypted RSA key") 33 | ) 34 | flag.Parse() 35 | if *print_v { 36 | fmt.Printf("docker-ssh-exec version %s, built %s\n", VERSION, SOURCE_DATE) 37 | os.Exit(0) 38 | } 39 | // check arguments for validity 40 | if (len(flag.Args()) < 1) && (*server == false) { 41 | fmt.Println("ERROR: A command to execute is required:", 42 | " docker-ssh-exec [options] [command]") 43 | os.Exit(1) 44 | } 45 | keyPath := *keyArg 46 | if keyPath == DEFAULT_KEYPATH { 47 | home := os.Getenv(`HOME`) 48 | if home == `` { 49 | home = `/root` 50 | } 51 | keyPath = filepath.Join(home, `.ssh`, `id_rsa`) 52 | } 53 | rsaPasswd := *pwd 54 | if *pwd == DEFAULT_PWD { 55 | rsaPasswd = os.Getenv(`RSA_KEY_PWD`) 56 | } 57 | return Config{ 58 | Server: *server, 59 | KeyPath: keyPath, 60 | Pwd: rsaPasswd, 61 | UDPPort: *udpPort, 62 | HTTPPort: *httpPort, 63 | Wait: *wait, 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/pem" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net" 10 | "os" 11 | "regexp" 12 | ) 13 | 14 | func server(config Config) { 15 | 16 | // open receive port 17 | readSocket := openUDPSocket(`r`, net.UDPAddr{ 18 | IP: net.IPv4(0, 0, 0, 0), 19 | Port: config.UDPPort, 20 | }) 21 | keyData := readKeyData(&config) 22 | go serveHTTP(keyData, config.HTTPPort) 23 | fmt.Printf("Listening on UDP port %d...\n", config.UDPPort) 24 | defer readSocket.Close() 25 | 26 | // main loop 27 | for { 28 | data := make([]byte, UDP_MSG_SIZE) 29 | size, clientAddr, err := readSocket.ReadFromUDP(data) 30 | if err != nil { 31 | fmt.Println("Error reading from receive port: ", err) 32 | } 33 | clientMsg := data[0:size] 34 | if string(clientMsg) == KEY_REQUEST_TEXT { 35 | fmt.Printf("Received key request from %s, sending key.\n", 36 | clientAddr.IP) 37 | // reply to the client on the same port 38 | writeSocket := openUDPSocket(`w`, net.UDPAddr{ 39 | IP: clientAddr.IP, 40 | Port: clientAddr.Port + 1, 41 | }) 42 | _, err = writeSocket.Write(*keyData) 43 | if err != nil { 44 | fmt.Printf("ERROR writing data to socket:%s!\n", err) 45 | } 46 | writeSocket.Close() 47 | } 48 | } 49 | } 50 | 51 | func readKeyData(config *Config) *[]byte { 52 | // var keyData []byte 53 | var err error 54 | keyData := []byte(os.Getenv(KEY_DATA_ENV_VAR)) 55 | if len(keyData) == 0 { 56 | fmt.Printf("Reading file: %s...\n", config.KeyPath) 57 | keyData, err = ioutil.ReadFile(config.KeyPath) 58 | if err != nil { 59 | log.Fatalf("ERROR reading keyfile %s: %s!\n", config.KeyPath, err) 60 | } 61 | } 62 | pemBlock, _ := pem.Decode(keyData) 63 | if pemBlock != nil { 64 | if x509.IsEncryptedPEMBlock(pemBlock) { 65 | fmt.Println("Decrypting private key with passphrase...") 66 | decoded, err := x509.DecryptPEMBlock(pemBlock, []byte(config.Pwd)) 67 | if err == nil { 68 | header := `PRIVATE KEY` // default key type in header 69 | matcher := regexp.MustCompile("-----BEGIN (.*)-----") 70 | if matches := matcher.FindSubmatch(keyData); len(matches) > 1 { 71 | header = string(matches[1]) 72 | } 73 | keyData = pem.EncodeToMemory( 74 | &pem.Block{Type: header, Bytes: decoded}) 75 | } else { 76 | fmt.Printf("Error decrypting PEM-encoded secret: %s\n", err) 77 | } 78 | } 79 | } 80 | return &keyData 81 | } 82 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/ioutil" 7 | "net" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strconv" 12 | "strings" 13 | "syscall" 14 | "time" 15 | ) 16 | 17 | func client(config Config) { 18 | 19 | // open send port 20 | writeSocket := openUDPSocket(`w`, net.UDPAddr{ 21 | IP: net.IPv4(255, 255, 255, 255), // (broadcast IPv4) 22 | Port: config.UDPPort, 23 | }) 24 | defer writeSocket.Close() 25 | 26 | // open receive port on send port + 1 27 | _, porttxt, _ := net.SplitHostPort(writeSocket.LocalAddr().String()) 28 | port, _ := strconv.Atoi(porttxt) 29 | readSocket := openUDPSocket(`r`, net.UDPAddr{ 30 | IP: net.IPv4(0, 0, 0, 0), 31 | Port: port + 1, 32 | }) 33 | defer readSocket.Close() 34 | 35 | // listen for reply: first start 2 channels: dataCh, and errCh 36 | data, errors := make(chan []byte), make(chan error) 37 | go func(dataCh chan []byte, errCh chan error) { 38 | keyData := make([]byte, UDP_MSG_SIZE) 39 | n, _, err := readSocket.ReadFromUDP(keyData) 40 | if err != nil { 41 | errCh <- err 42 | } 43 | dataCh <- keyData[0:n] 44 | }(data, errors) 45 | 46 | // send key request 47 | fmt.Println("Broadcasting UDP key request...") 48 | _, err := writeSocket.Write([]byte(KEY_REQUEST_TEXT)) 49 | if err != nil { 50 | fmt.Println("ERROR sending key request: ", err) 51 | os.Exit(101) 52 | } 53 | 54 | // now start the timeout channel 55 | timeout := make(chan bool, 1) 56 | go func() { 57 | time.Sleep(time.Duration(config.Wait) * time.Second) 58 | timeout <- true 59 | }() 60 | 61 | // now wait for a reply, an error, or a timeout 62 | reply := `` 63 | select { 64 | case bytes := <-data: 65 | reply = string(bytes) 66 | if strings.HasPrefix(reply, `ERROR`) == true { 67 | fmt.Println("Received error from server:", reply) 68 | os.Exit(102) 69 | } 70 | fmt.Println("Got key from server.") 71 | case err := <-errors: 72 | fmt.Println("Error reading from receive port:", err) 73 | os.Exit(103) 74 | case <-timeout: 75 | fmt.Println("WARNING: timed out waiting for response from key server.") 76 | } 77 | 78 | // create key dir and file 79 | keyWritten := false // keep track of whether the key was written 80 | if reply != `` { 81 | fmt.Printf("Writing key to %s\n", config.KeyPath) 82 | err = os.MkdirAll(filepath.Dir(config.KeyPath), 0700) 83 | if err != nil { 84 | fmt.Printf("ERROR creating directory %s: %s\n", config.KeyPath, err) 85 | os.Exit(104) 86 | } 87 | err = ioutil.WriteFile(config.KeyPath, []byte(reply), 0600) 88 | if err != nil { 89 | fmt.Printf("ERROR writing keyfile %s: %s\n", config.KeyPath, err) 90 | os.Exit(105) 91 | } 92 | keyWritten = true 93 | } 94 | // defer close and deletion of keyfile 95 | // from here on, set exitCode and call return instead of os.Exit() 96 | exitCode := 0 97 | defer func() { 98 | if keyWritten == true { 99 | fmt.Printf("Deleting key file %s...\n", config.KeyPath) 100 | if err := os.Remove(config.KeyPath); err != nil { 101 | fmt.Printf("ERROR deleting keyfile '%s': %v\n", 102 | config.KeyPath, err) 103 | exitCode = 106 104 | return 105 | } 106 | } 107 | if exitCode != 0 { 108 | os.Exit(exitCode) 109 | } 110 | }() 111 | 112 | // run command 113 | cmd := exec.Command(flag.Arg(0), flag.Args()[1:]...) 114 | cmdText := strings.Join(flag.Args(), " ") 115 | cmd.Stdin = os.Stdin 116 | cmd.Stdout = os.Stdout 117 | cmd.Stderr = os.Stderr 118 | fmt.Println("Running command:", cmdText) 119 | if err := cmd.Start(); err != nil { 120 | fmt.Printf("ERROR starting command '%s': %v\n", cmdText, err) 121 | exitCode = 107 122 | return 123 | } 124 | 125 | if err = cmd.Wait(); err != nil { 126 | if exiterr, ok := err.(*exec.ExitError); ok { 127 | // The program has exited with an exit code != 0 128 | 129 | // This works on both Unix and Windows. Although package 130 | // syscall is generally platform dependent, WaitStatus is 131 | // defined for both Unix and Windows and in both cases has 132 | // an ExitStatus() method with the same signature. 133 | if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { 134 | exitCode = status.ExitStatus() 135 | fmt.Printf("ERROR: command '%s' exited with status %d\n", 136 | cmdText, exitCode) 137 | } else { 138 | fmt.Printf("ERROR: command '%s' exited with unknown status", 139 | cmdText) 140 | exitCode = 108 // problem getting command's exit status? 141 | } 142 | return 143 | } else { 144 | fmt.Printf("ERROR waiting on command '%s': %v\n", cmdText, err) 145 | exitCode = 109 146 | return 147 | } 148 | } 149 | 150 | fmt.Println("Command completed successfully.") 151 | } 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | docker-ssh-exec - Secure SSH key injection for Docker builds 2 | ================ 3 | Allows commands that require an SSH key to be run from within a `Dockerfile`, without leaving the key in the resulting image. 4 | 5 | ---------------- 6 | Overview 7 | ---------------- 8 | This program runs in two different modes: 9 | 10 | * a server mode, run as the Docker image `mdsol/docker-ssh-exec`, which transmits an SSH key on request to the the client; and 11 | * a client mode, invoked from within the `Dockerfile`, that grabs the key from the server, writes it to the filesystem, runs the desired build command, and then *deletes the key* before the filesystem is snapshotted into the build. 12 | 13 | ---------------- 14 | Installation 15 | ---------------- 16 | To install the server, just pull `mdsol/docker-ssh-exec` like any other Docker image. 17 | 18 | To install the client, just grab it from the [releases page][1], uncompress the archive, and copy the binary to somewhere in your `$PATH`. Remember that the client is run during the `docker build...` process, so either install the client just before invoking it, or make sure it's already present in your source image. Here's an example of the code you might run in your source image, to prepare it for SSH cloning from GitHub: 19 | 20 | # install Medidata docker-ssh-exec build tool from S3 bucket "mybucket" 21 | curl https://s3.amazonaws.com/mybucket/docker-ssh-exec/\ 22 | docker-ssh-exec_0.5.1_linux_amd64.tar.gz | \ 23 | tar -xz --strip-components=1 -C /usr/local/bin \ 24 | docker-ssh-exec_0.5.1_linux_amd64/docker-ssh-exec 25 | mkdir -p /root/.ssh && chmod 0700 /root/.ssh 26 | ssh-keyscan github.com >/root/.ssh/known_hosts 27 | 28 | 29 | ---------------- 30 | Usage 31 | ---------------- 32 | To run the server component, pass it the private half of your SSH key, either as a shared volume: 33 | 34 | docker run -v ~/.ssh/id_rsa:/root/.ssh/id_rsa --name=keyserver -d \ 35 | mdsol/docker-ssh-exec -server 36 | 37 | or as an ENV var: 38 | 39 | docker run -e DOCKER-SSH-KEY="$(cat ~/.ssh/id_rsa)" --name=keyserver -d \ 40 | mdsol/docker-ssh-exec -server 41 | 42 | The benefit of this second method is that OS X systems using a virtual Docker host cannot easily use Docker's shared volume feature with files on the OS X side. The drawback is that the kay data is exposed in the process list. 43 | 44 | Then, run a quick test of the client, to make sure it can get the key: 45 | 46 | docker run --rm -it mdsol/docker-ssh-exec cat /root/.ssh/id_rsa 47 | 48 | Finally, as long as the source image is set up to trust (or ignore) GitHub's server key, you can clone private repositories from within the `Dockerfile` like this: 49 | 50 | docker-exec-ssh git clone git@github.com:my_user/my_private_repo.git 51 | 52 | The client first transfers the key from the server, writing it to `$HOME/.ssh/id_rsa` (by default), then executes whatever command you supply as arguments. Before exiting, it deletes the key from the filesystem. 53 | 54 | Here's the command-line help: 55 | 56 | Usage of docker-ssh-exec: 57 | -key string 58 | path to key file (default "~/.ssh/id_rsa") 59 | -port int 60 | server receiving port (default 1067) 61 | -pwd string 62 | password for encrypted RSA key 63 | -server 64 | run key server instead of command 65 | -version 66 | print version and exit 67 | -wait int 68 | client timeout, in seconds (default 3) 69 | 70 | The software quits with a non-zero exit code (>100) on any error -- except a timeout from the keyserver, in which case it will just ignore the timeout and try to run the build command anyway. If the build command fails, `docker-ssh-exec` returns the exit code of the failed command. 71 | 72 | 73 | ---------------- 74 | Known Limitations / Bugs 75 | ---------------- 76 | The key data is limited to 4096 bytes. 77 | 78 | On macOS 10.14 or later, the default format of `ssh-keygen` will produce 79 | an "OpenSSH private key" ([reference][2]). For example: 80 | 81 | ``` 82 | $ ssh-keygen -t rsa -b 4096 -C "...@email.com" -f ~/.ssh/before_rsa 83 | Generating public/private rsa key pair. 84 | Enter passphrase (empty for no passphrase): 85 | Enter same passphrase again: 86 | Your identification has been saved in ${HOME}/.ssh/before_rsa. 87 | Your public key has been saved in ${HOME}/.ssh/before_rsa.pub. 88 | The key fingerprint is: 89 | ... 90 | $ head -2 ~/.ssh/before_rsa 91 | -----BEGIN OPENSSH PRIVATE KEY----- 92 | b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABAZOJlIwH 93 | ``` 94 | 95 | To use a passphrase, this library requires an actual "RSA private key". 96 | To make `ssh-keygen` produce one, use the `-m` (key format) flag: 97 | 98 | ``` 99 | $ ssh-keygen -t rsa -b 4096 -C "...@email.com" -f ~/.ssh/after_rsa -m PEM 100 | ... 101 | $ head -5 ~/.ssh/after_rsa 102 | -----BEGIN RSA PRIVATE KEY----- 103 | Proc-Type: 4,ENCRYPTED 104 | DEK-Info: AES-128-CBC,70B1F7ECFCC66C9DF073996B92D3C01E 105 | 106 | GNhm2zcN6oz+K9yZimDMx6w5PD+mDz7ylVulz+PnYVP5TVs4yZuVZF3GGlu/NYZ1 107 | ``` 108 | 109 | ---------------- 110 | Contribution / Development 111 | ---------------- 112 | This software was created by Benton Roberts _(broberts@mdsol.com)_ 113 | 114 | To build it yourself, just `go get` and `go install` as usual: 115 | 116 | go get github.com/mdsol/docker-ssh-exec 117 | cd $GOPATH/src/github.com/mdsol/docker-ssh-exec 118 | go install 119 | 120 | 121 | -------- 122 | [1]: https://github.com/mdsol/docker-ssh-exec/releases 123 | [2]: https://serverfault.com/q/939909/167925 124 | --------------------------------------------------------------------------------