From b0ebbdf68c107d29571bd422653fa14ef8ec3bb1 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 1 Aug 2016 13:40:12 +1200 Subject: [PATCH 1/2] refactor(api): create a new structure for the Go api (#94) * refactor(api): create a new structure for the Go api * refactor(api): update the way keyFile parameter is managed --- api/api.go | 33 ++++++ api/config.go | 25 +++++ api/csrf.go | 48 +++++++++ api/flags.go | 64 ++++++++++++ api/handler.go | 78 ++++++++++++++ api/ssl.go | 27 +++++ api/unix_handler.go | 47 +++++++++ dockerui.go | 243 -------------------------------------------- gruntFile.js | 6 +- 9 files changed, 325 insertions(+), 246 deletions(-) create mode 100644 api/api.go create mode 100644 api/config.go create mode 100644 api/csrf.go create mode 100644 api/flags.go create mode 100644 api/handler.go create mode 100644 api/ssl.go create mode 100644 api/unix_handler.go delete mode 100644 dockerui.go diff --git a/api/api.go b/api/api.go new file mode 100644 index 000000000..128d2a5c7 --- /dev/null +++ b/api/api.go @@ -0,0 +1,33 @@ +package main // import "github.com/cloudinovasi/ui-for-docker" + +import ( + "gopkg.in/alecthomas/kingpin.v2" + "log" + "net/http" +) + +// main is the entry point of the program +func main() { + kingpin.Version("1.5.0") + var ( + endpoint = kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String() + addr = kingpin.Flag("bind", "Address and port to serve UI For Docker").Default(":9000").Short('p').String() + assets = kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String() + data = kingpin.Flag("data", "Path to the data").Default(".").Short('d').String() + swarm = kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool() + tlsverify = kingpin.Flag("tlsverify", "TLS support").Default("false").Bool() + tlscacert = kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String() + tlscert = kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String() + tlskey = kingpin.Flag("tlskey", "Path to the TLS key").Default("/certs/key.pem").String() + labels = pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')) + ) + kingpin.Parse() + + configuration := newConfig(*swarm, *labels) + tlsFlags := newTLSFlags(*tlsverify, *tlscacert, *tlscert, *tlskey) + + handler := newHandler(*assets, *data, *endpoint, configuration, tlsFlags) + if err := http.ListenAndServe(*addr, handler); err != nil { + log.Fatal(err) + } +} diff --git a/api/config.go b/api/config.go new file mode 100644 index 000000000..832b00ad6 --- /dev/null +++ b/api/config.go @@ -0,0 +1,25 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +// Config defines the configuration available under the /config endpoint +type Config struct { + Swarm bool `json:"swarm"` + HiddenLabels pairList `json:"hiddenLabels"` +} + +// newConfig creates a new Config from command flags +func newConfig(swarm bool, labels pairList) Config { + return Config{ + Swarm: swarm, + HiddenLabels: labels, + } +} + +// configurationHandler defines a handler function used to encode the configuration in JSON +func configurationHandler(w http.ResponseWriter, r *http.Request, c Config) { + json.NewEncoder(w).Encode(c) +} diff --git a/api/csrf.go b/api/csrf.go new file mode 100644 index 000000000..4377ee343 --- /dev/null +++ b/api/csrf.go @@ -0,0 +1,48 @@ +package main + +import ( + "github.com/gorilla/csrf" + "github.com/gorilla/securecookie" + "io/ioutil" + "log" + "net/http" +) + +const keyFile = "authKey.dat" + +// newAuthKey reuses an existing CSRF authkey if present or generates a new one +func newAuthKey(path string) []byte { + var authKey []byte + authKeyPath := path + "/" + keyFile + data, err := ioutil.ReadFile(authKeyPath) + if err != nil { + log.Print("Unable to find an existing CSRF auth key. Generating a new key.") + authKey = securecookie.GenerateRandomKey(32) + err := ioutil.WriteFile(authKeyPath, authKey, 0644) + if err != nil { + log.Fatal("Unable to persist CSRF auth key.") + log.Fatal(err) + } + } else { + authKey = data + } + return authKey +} + +// newCSRF initializes a new CSRF handler +func newCSRFHandler(keyPath string) func(h http.Handler) http.Handler { + authKey := newAuthKey(keyPath) + return csrf.Protect( + authKey, + csrf.HttpOnly(false), + csrf.Secure(false), + ) +} + +// newCSRFWrapper wraps a http.Handler to add the CSRF token +func newCSRFWrapper(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-CSRF-Token", csrf.Token(r)) + h.ServeHTTP(w, r) + }) +} diff --git a/api/flags.go b/api/flags.go new file mode 100644 index 000000000..938613daa --- /dev/null +++ b/api/flags.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "gopkg.in/alecthomas/kingpin.v2" + "strings" +) + +// TLSFlags defines all the flags associated to the SSL configuration +type TLSFlags struct { + tls bool + caPath string + certPath string + keyPath string +} + +// pair defines a key/value pair +type pair struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// pairList defines an array of Label +type pairList []pair + +// Set implementation for Labels +func (l *pairList) Set(value string) error { + parts := strings.SplitN(value, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("expected NAME=VALUE got '%s'", value) + } + p := new(pair) + p.Name = parts[0] + p.Value = parts[1] + *l = append(*l, *p) + return nil +} + +// String implementation for Labels +func (l *pairList) String() string { + return "" +} + +// IsCumulative implementation for Labels +func (l *pairList) IsCumulative() bool { + return true +} + +// LabelParser defines a custom parser for Labels flags +func pairs(s kingpin.Settings) (target *[]pair) { + target = new([]pair) + s.SetValue((*pairList)(target)) + return +} + +// newTLSFlags creates a new TLSFlags from command flags +func newTLSFlags(tls bool, cacert string, cert string, key string) TLSFlags { + return TLSFlags{ + tls: tls, + caPath: cacert, + certPath: cert, + keyPath: key, + } +} diff --git a/api/handler.go b/api/handler.go new file mode 100644 index 000000000..9a6ca1059 --- /dev/null +++ b/api/handler.go @@ -0,0 +1,78 @@ +package main + +import ( + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" +) + +// newHandler creates a new http.Handler with CSRF protection +func newHandler(dir string, d string, e string, c Config, tlsFlags TLSFlags) http.Handler { + var ( + mux = http.NewServeMux() + fileHandler = http.FileServer(http.Dir(dir)) + ) + + u, perr := url.Parse(e) + if perr != nil { + log.Fatal(perr) + } + + handler := newAPIHandler(u, tlsFlags) + CSRFHandler := newCSRFHandler(d) + + mux.Handle("/dockerapi/", http.StripPrefix("/dockerapi", handler)) + mux.Handle("/", fileHandler) + mux.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) { + configurationHandler(w, r, c) + }) + return CSRFHandler(newCSRFWrapper(mux)) +} + +// newAPIHandler initializes a new http.Handler based on the URL scheme +func newAPIHandler(u *url.URL, tlsFlags TLSFlags) http.Handler { + var handler http.Handler + if u.Scheme == "tcp" { + if tlsFlags.tls { + handler = newTCPHandlerWithTLS(u, tlsFlags) + } else { + handler = newTCPHandler(u) + } + } else if u.Scheme == "unix" { + socketPath := u.Path + if _, err := os.Stat(socketPath); err != nil { + if os.IsNotExist(err) { + log.Fatalf("Unix socket %s does not exist", socketPath) + } + log.Fatal(err) + } + handler = newUnixHandler(socketPath) + } else { + log.Fatalf("Bad Docker enpoint: %s. Only unix:// and tcp:// are supported.", u) + } + return handler +} + +// newUnixHandler initializes a new UnixHandler +func newUnixHandler(e string) http.Handler { + return &unixHandler{e} +} + +// newTCPHandler initializes a HTTP reverse proxy +func newTCPHandler(u *url.URL) http.Handler { + u.Scheme = "http" + return httputil.NewSingleHostReverseProxy(u) +} + +// newTCPHandlerWithL initializes a HTTPS reverse proxy with a TLS configuration +func newTCPHandlerWithTLS(u *url.URL, tlsFlags TLSFlags) http.Handler { + u.Scheme = "https" + var tlsConfig = newTLSConfig(tlsFlags) + proxy := httputil.NewSingleHostReverseProxy(u) + proxy.Transport = &http.Transport{ + TLSClientConfig: tlsConfig, + } + return proxy +} diff --git a/api/ssl.go b/api/ssl.go new file mode 100644 index 000000000..6d86db5b8 --- /dev/null +++ b/api/ssl.go @@ -0,0 +1,27 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "io/ioutil" + "log" +) + +// newTLSConfig initializes a tls.Config from the TLS flags +func newTLSConfig(tlsFlags TLSFlags) *tls.Config { + cert, err := tls.LoadX509KeyPair(tlsFlags.certPath, tlsFlags.keyPath) + if err != nil { + log.Fatal(err) + } + caCert, err := ioutil.ReadFile(tlsFlags.caPath) + if err != nil { + log.Fatal(err) + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: caCertPool, + } + return tlsConfig +} diff --git a/api/unix_handler.go b/api/unix_handler.go new file mode 100644 index 000000000..15a5119d3 --- /dev/null +++ b/api/unix_handler.go @@ -0,0 +1,47 @@ +package main + +import ( + "io" + "log" + "net" + "net/http" + "net/http/httputil" +) + +// unixHandler defines a handler holding the path to a socket under UNIX +type unixHandler struct { + path string +} + +// ServeHTTP implementation for unixHandler +func (h *unixHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := net.Dial("unix", h.path) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + return + } + c := httputil.NewClientConn(conn, nil) + defer c.Close() + + res, err := c.Do(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + return + } + defer res.Body.Close() + + copyHeader(w.Header(), res.Header) + if _, err := io.Copy(w, res.Body); err != nil { + log.Println(err) + } +} + +func copyHeader(dst, src http.Header) { + for k, vv := range src { + for _, v := range vv { + dst.Add(k, v) + } + } +} diff --git a/dockerui.go b/dockerui.go deleted file mode 100644 index 3cc089dbe..000000000 --- a/dockerui.go +++ /dev/null @@ -1,243 +0,0 @@ -package main // import "github.com/cloudinovasi/ui-for-docker" - -import ( - "io" - "log" - "net" - "net/http" - "net/http/httputil" - "net/url" - "encoding/json" - "os" - "strings" - "github.com/gorilla/csrf" - "io/ioutil" - "fmt" - "github.com/gorilla/securecookie" - "gopkg.in/alecthomas/kingpin.v2" - "crypto/tls" - "crypto/x509" -) - -var ( - endpoint = kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String() - addr = kingpin.Flag("bind", "Address and port to serve UI For Docker").Default(":9000").Short('p').String() - assets = kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String() - data = kingpin.Flag("data", "Path to the data").Default(".").Short('d').String() - swarm = kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool() - tlsverify = kingpin.Flag("tlsverify", "TLS support").Default("false").Bool() - tlscacert = kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String() - tlscert = kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String() - tlskey = kingpin.Flag("tlskey", "Path to the TLS key").Default("/certs/key.pem").String() - labels = LabelParser(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')) - authKey []byte - authKeyFile = "authKey.dat" -) - -type UnixHandler struct { - path string -} - -type TlsFlags struct { - tls bool - caPath string - certPath string - keyPath string -} - -type Config struct { - Swarm bool `json:"swarm"` - HiddenLabels Labels `json:"hiddenLabels"` -} - -type Label struct { - Name string `json:"name"` - Value string `json:"value"` -} - -type Labels []Label - -func (l *Labels) Set(value string) error { - parts := strings.SplitN(value, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("expected HEADER=VALUE got '%s'", value) - } - label := new(Label) - label.Name = parts[0] - label.Value = parts[1] - *l = append(*l, *label) - return nil -} - -func (l *Labels) String() string { - return "" -} - -func (l *Labels) IsCumulative() bool { - return true -} - -func LabelParser(s kingpin.Settings) (target *[]Label) { - target = new([]Label) - s.SetValue((*Labels)(target)) - return -} - -func (h *UnixHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - conn, err := net.Dial("unix", h.path) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - return - } - c := httputil.NewClientConn(conn, nil) - defer c.Close() - - res, err := c.Do(r) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - return - } - defer res.Body.Close() - - copyHeader(w.Header(), res.Header) - if _, err := io.Copy(w, res.Body); err != nil { - log.Println(err) - } -} - -func copyHeader(dst, src http.Header) { - for k, vv := range src { - for _, v := range vv { - dst.Add(k, v) - } - } -} - -func configurationHandler(w http.ResponseWriter, r *http.Request, c Config) { - json.NewEncoder(w).Encode(c) -} - -func createTcpHandler(u *url.URL) http.Handler { - u.Scheme = "http"; - return httputil.NewSingleHostReverseProxy(u) -} - -func createTlsConfig(tlsFlags TlsFlags) *tls.Config { - cert, err := tls.LoadX509KeyPair(tlsFlags.certPath, tlsFlags.keyPath) - if err != nil { - log.Fatal(err) - } - caCert, err := ioutil.ReadFile(tlsFlags.caPath) - if err != nil { - log.Fatal(err) - } - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - RootCAs: caCertPool, - } - return tlsConfig; -} - -func createTcpHandlerWithTLS(u *url.URL, tlsFlags TlsFlags) http.Handler { - u.Scheme = "https"; - var tlsConfig = createTlsConfig(tlsFlags) - proxy := httputil.NewSingleHostReverseProxy(u) - proxy.Transport = &http.Transport{ - TLSClientConfig: tlsConfig, - } - return proxy; -} - -func createUnixHandler(e string) http.Handler { - return &UnixHandler{e} -} - -func createHandler(dir string, d string, e string, c Config, tlsFlags TlsFlags) http.Handler { - var ( - mux = http.NewServeMux() - fileHandler = http.FileServer(http.Dir(dir)) - h http.Handler - ) - u, perr := url.Parse(e) - if perr != nil { - log.Fatal(perr) - } - if u.Scheme == "tcp" { - if tlsFlags.tls { - h = createTcpHandlerWithTLS(u, tlsFlags) - } else { - h = createTcpHandler(u) - } - } else if u.Scheme == "unix" { - var socketPath = u.Path - if _, err := os.Stat(socketPath); err != nil { - if os.IsNotExist(err) { - log.Fatalf("unix socket %s does not exist", socketPath) - } - log.Fatal(err) - } - h = createUnixHandler(socketPath) - } else { - log.Fatalf("Bad Docker enpoint: %s. Only unix:// and tcp:// are supported.", e) - } - - // Use existing csrf authKey if present or generate a new one. - var authKeyPath = d + "/" + authKeyFile - dat, err := ioutil.ReadFile(authKeyPath) - if err != nil { - fmt.Println(err) - authKey = securecookie.GenerateRandomKey(32) - err := ioutil.WriteFile(authKeyPath, authKey, 0644) - if err != nil { - fmt.Println("unable to persist auth key", err) - } - } else { - authKey = dat - } - - CSRF := csrf.Protect( - authKey, - csrf.HttpOnly(false), - csrf.Secure(false), - ) - - mux.Handle("/dockerapi/", http.StripPrefix("/dockerapi", h)) - mux.Handle("/", fileHandler) - mux.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) { - configurationHandler(w, r, c) - }) - return CSRF(csrfWrapper(mux)) -} - -func csrfWrapper(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-CSRF-Token", csrf.Token(r)) - h.ServeHTTP(w, r) - }) -} - -func main() { - kingpin.Version("1.5.0") - kingpin.Parse() - - configuration := Config{ - Swarm: *swarm, - HiddenLabels: *labels, - } - - tlsFlags := TlsFlags{ - tls: *tlsverify, - caPath: *tlscacert, - certPath: *tlscert, - keyPath: *tlskey, - } - - handler := createHandler(*assets, *data, *endpoint, configuration, tlsFlags) - if err := http.ListenAndServe(*addr, handler); err != nil { - log.Fatal(err) - } -} diff --git a/gruntFile.js b/gruntFile.js index 7482b3ecb..94e290422 100644 --- a/gruntFile.js +++ b/gruntFile.js @@ -254,10 +254,10 @@ module.exports = function (grunt) { }, buildBinary: { command: [ - 'docker run --rm -v $(pwd):/src centurylink/golang-builder', - 'shasum ui-for-docker > ui-for-docker-checksum.txt', + 'docker run --rm -v $(pwd)/api:/src centurylink/golang-builder', + 'shasum api/ui-for-docker > ui-for-docker-checksum.txt', 'mkdir -p dist', - 'mv ui-for-docker dist/' + 'mv api/ui-for-docker dist/' ].join(' && ') }, run: { From 5878eed7ece9ff2ed4ec605624b4cadfd73256e3 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 3 Aug 2016 15:11:09 +1200 Subject: [PATCH 2/2] feat(global): add container exec support --- api/api.go | 68 ++++++---- api/config.go | 4 +- api/exec.go | 24 ++++ api/flags.go | 18 --- api/handler.go | 45 +++---- api/hijack.go | 123 ++++++++++++++++++ api/main.go | 43 ++++++ api/ssl.go | 8 +- app/app.js | 8 +- app/components/container/container.html | 1 + .../containerConsole/containerConsole.html | 43 ++++++ .../containerConsoleController.js | 104 +++++++++++++++ app/shared/services.js | 10 +- assets/css/app.css | 7 +- bower.json | 3 +- gruntFile.js | 4 +- 16 files changed, 438 insertions(+), 75 deletions(-) create mode 100644 api/exec.go create mode 100644 api/hijack.go create mode 100644 api/main.go create mode 100644 app/components/containerConsole/containerConsole.html create mode 100644 app/components/containerConsole/containerConsoleController.js diff --git a/api/api.go b/api/api.go index 128d2a5c7..9d45f28a0 100644 --- a/api/api.go +++ b/api/api.go @@ -1,33 +1,57 @@ -package main // import "github.com/cloudinovasi/ui-for-docker" +package main import ( - "gopkg.in/alecthomas/kingpin.v2" + "crypto/tls" "log" "net/http" + "net/url" ) -// main is the entry point of the program -func main() { - kingpin.Version("1.5.0") - var ( - endpoint = kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String() - addr = kingpin.Flag("bind", "Address and port to serve UI For Docker").Default(":9000").Short('p').String() - assets = kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String() - data = kingpin.Flag("data", "Path to the data").Default(".").Short('d').String() - swarm = kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool() - tlsverify = kingpin.Flag("tlsverify", "TLS support").Default("false").Bool() - tlscacert = kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String() - tlscert = kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String() - tlskey = kingpin.Flag("tlskey", "Path to the TLS key").Default("/certs/key.pem").String() - labels = pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')) - ) - kingpin.Parse() +type ( + api struct { + endpoint *url.URL + bindAddress string + assetPath string + dataPath string + tlsConfig *tls.Config + } - configuration := newConfig(*swarm, *labels) - tlsFlags := newTLSFlags(*tlsverify, *tlscacert, *tlscert, *tlskey) + apiConfig struct { + Endpoint string + BindAddress string + AssetPath string + DataPath string + SwarmSupport bool + TLSEnabled bool + TLSCACertPath string + TLSCertPath string + TLSKeyPath string + } +) - handler := newHandler(*assets, *data, *endpoint, configuration, tlsFlags) - if err := http.ListenAndServe(*addr, handler); err != nil { +func (a *api) run(configuration *Config) { + handler := a.newHandler(configuration) + if err := http.ListenAndServe(a.bindAddress, handler); err != nil { log.Fatal(err) } } + +func newAPI(apiConfig apiConfig) *api { + endpointURL, err := url.Parse(apiConfig.Endpoint) + if err != nil { + log.Fatal(err) + } + + var tlsConfig *tls.Config + if apiConfig.TLSEnabled { + tlsConfig = newTLSConfig(apiConfig.TLSCACertPath, apiConfig.TLSCertPath, apiConfig.TLSKeyPath) + } + + return &api{ + endpoint: endpointURL, + bindAddress: apiConfig.BindAddress, + assetPath: apiConfig.AssetPath, + dataPath: apiConfig.DataPath, + tlsConfig: tlsConfig, + } +} diff --git a/api/config.go b/api/config.go index 832b00ad6..06b30faaa 100644 --- a/api/config.go +++ b/api/config.go @@ -20,6 +20,6 @@ func newConfig(swarm bool, labels pairList) Config { } // configurationHandler defines a handler function used to encode the configuration in JSON -func configurationHandler(w http.ResponseWriter, r *http.Request, c Config) { - json.NewEncoder(w).Encode(c) +func configurationHandler(w http.ResponseWriter, r *http.Request, c *Config) { + json.NewEncoder(w).Encode(*c) } diff --git a/api/exec.go b/api/exec.go new file mode 100644 index 000000000..4b139aeb7 --- /dev/null +++ b/api/exec.go @@ -0,0 +1,24 @@ +package main + +import ( + "golang.org/x/net/websocket" + "log" +) + +// execContainer is used to create a websocket communication with an exec instance +func (a *api) execContainer(ws *websocket.Conn) { + qry := ws.Request().URL.Query() + execID := qry.Get("id") + + var host string + if a.endpoint.Scheme == "tcp" { + host = a.endpoint.Host + } else if a.endpoint.Scheme == "unix" { + host = a.endpoint.Path + } + + if err := hijack(host, a.endpoint.Scheme, "POST", "/exec/"+execID+"/start", a.tlsConfig, true, ws, ws, ws, nil, nil); err != nil { + log.Fatalf("error during hijack: %s", err) + return + } +} diff --git a/api/flags.go b/api/flags.go index 938613daa..47578a748 100644 --- a/api/flags.go +++ b/api/flags.go @@ -6,14 +6,6 @@ import ( "strings" ) -// TLSFlags defines all the flags associated to the SSL configuration -type TLSFlags struct { - tls bool - caPath string - certPath string - keyPath string -} - // pair defines a key/value pair type pair struct { Name string `json:"name"` @@ -52,13 +44,3 @@ func pairs(s kingpin.Settings) (target *[]pair) { s.SetValue((*pairList)(target)) return } - -// newTLSFlags creates a new TLSFlags from command flags -func newTLSFlags(tls bool, cacert string, cert string, key string) TLSFlags { - return TLSFlags{ - tls: tls, - caPath: cacert, - certPath: cert, - keyPath: key, - } -} diff --git a/api/handler.go b/api/handler.go index 9a6ca1059..b38417bb4 100644 --- a/api/handler.go +++ b/api/handler.go @@ -1,6 +1,7 @@ package main import ( + "golang.org/x/net/websocket" "log" "net/http" "net/http/httputil" @@ -9,22 +10,18 @@ import ( ) // newHandler creates a new http.Handler with CSRF protection -func newHandler(dir string, d string, e string, c Config, tlsFlags TLSFlags) http.Handler { +func (a *api) newHandler(c *Config) http.Handler { var ( mux = http.NewServeMux() - fileHandler = http.FileServer(http.Dir(dir)) + fileHandler = http.FileServer(http.Dir(a.assetPath)) ) - u, perr := url.Parse(e) - if perr != nil { - log.Fatal(perr) - } + handler := a.newAPIHandler() + CSRFHandler := newCSRFHandler(a.dataPath) - handler := newAPIHandler(u, tlsFlags) - CSRFHandler := newCSRFHandler(d) - - mux.Handle("/dockerapi/", http.StripPrefix("/dockerapi", handler)) mux.Handle("/", fileHandler) + mux.Handle("/dockerapi/", http.StripPrefix("/dockerapi", handler)) + mux.Handle("/ws/exec", websocket.Handler(a.execContainer)) mux.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) { configurationHandler(w, r, c) }) @@ -32,47 +29,47 @@ func newHandler(dir string, d string, e string, c Config, tlsFlags TLSFlags) htt } // newAPIHandler initializes a new http.Handler based on the URL scheme -func newAPIHandler(u *url.URL, tlsFlags TLSFlags) http.Handler { +func (a *api) newAPIHandler() http.Handler { var handler http.Handler - if u.Scheme == "tcp" { - if tlsFlags.tls { - handler = newTCPHandlerWithTLS(u, tlsFlags) + var endpoint = *a.endpoint + if endpoint.Scheme == "tcp" { + if a.tlsConfig != nil { + handler = a.newTCPHandlerWithTLS(&endpoint) } else { - handler = newTCPHandler(u) + handler = a.newTCPHandler(&endpoint) } - } else if u.Scheme == "unix" { - socketPath := u.Path + } else if endpoint.Scheme == "unix" { + socketPath := endpoint.Path if _, err := os.Stat(socketPath); err != nil { if os.IsNotExist(err) { log.Fatalf("Unix socket %s does not exist", socketPath) } log.Fatal(err) } - handler = newUnixHandler(socketPath) + handler = a.newUnixHandler(socketPath) } else { - log.Fatalf("Bad Docker enpoint: %s. Only unix:// and tcp:// are supported.", u) + log.Fatalf("Bad Docker enpoint: %v. Only unix:// and tcp:// are supported.", &endpoint) } return handler } // newUnixHandler initializes a new UnixHandler -func newUnixHandler(e string) http.Handler { +func (a *api) newUnixHandler(e string) http.Handler { return &unixHandler{e} } // newTCPHandler initializes a HTTP reverse proxy -func newTCPHandler(u *url.URL) http.Handler { +func (a *api) newTCPHandler(u *url.URL) http.Handler { u.Scheme = "http" return httputil.NewSingleHostReverseProxy(u) } // newTCPHandlerWithL initializes a HTTPS reverse proxy with a TLS configuration -func newTCPHandlerWithTLS(u *url.URL, tlsFlags TLSFlags) http.Handler { +func (a *api) newTCPHandlerWithTLS(u *url.URL) http.Handler { u.Scheme = "https" - var tlsConfig = newTLSConfig(tlsFlags) proxy := httputil.NewSingleHostReverseProxy(u) proxy.Transport = &http.Transport{ - TLSClientConfig: tlsConfig, + TLSClientConfig: a.tlsConfig, } return proxy } diff --git a/api/hijack.go b/api/hijack.go new file mode 100644 index 000000000..ff6cd9071 --- /dev/null +++ b/api/hijack.go @@ -0,0 +1,123 @@ +package main + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httputil" + "time" +) + +type execConfig struct { + Tty bool + Detach bool +} + +// hijack allows to upgrade an HTTP connection to a TCP connection +// It redirects IO streams for stdin, stdout and stderr to a websocket +func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerminal bool, in io.ReadCloser, stdout, stderr io.Writer, started chan io.Closer, data interface{}) error { + execConfig := &execConfig{ + Tty: true, + Detach: false, + } + + buf, err := json.Marshal(execConfig) + if err != nil { + return fmt.Errorf("error marshaling exec config: %s", err) + } + + rdr := bytes.NewReader(buf) + + req, err := http.NewRequest(method, path, rdr) + if err != nil { + return fmt.Errorf("error during hijack request: %s", err) + } + + req.Header.Set("User-Agent", "Docker-Client") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "tcp") + req.Host = addr + + var ( + dial net.Conn + dialErr error + ) + + if tlsConfig == nil { + dial, dialErr = net.Dial(scheme, addr) + } else { + dial, dialErr = tls.Dial(scheme, addr, tlsConfig) + } + + if dialErr != nil { + return dialErr + } + + // When we set up a TCP connection for hijack, there could be long periods + // of inactivity (a long running command with no output) that in certain + // network setups may cause ECONNTIMEOUT, leaving the client in an unknown + // state. Setting TCP KeepAlive on the socket connection will prohibit + // ECONNTIMEOUT unless the socket connection truly is broken + if tcpConn, ok := dial.(*net.TCPConn); ok { + tcpConn.SetKeepAlive(true) + tcpConn.SetKeepAlivePeriod(30 * time.Second) + } + if err != nil { + return err + } + clientconn := httputil.NewClientConn(dial, nil) + defer clientconn.Close() + + // Server hijacks the connection, error 'connection closed' expected + clientconn.Do(req) + + rwc, br := clientconn.Hijack() + defer rwc.Close() + + if started != nil { + started <- rwc + } + + var receiveStdout chan error + + if stdout != nil || stderr != nil { + go func() (err error) { + if setRawTerminal && stdout != nil { + _, err = io.Copy(stdout, br) + } + return err + }() + } + + go func() error { + if in != nil { + io.Copy(rwc, in) + } + + if conn, ok := rwc.(interface { + CloseWrite() error + }); ok { + if err := conn.CloseWrite(); err != nil { + } + } + return nil + }() + + if stdout != nil || stderr != nil { + if err := <-receiveStdout; err != nil { + return err + } + } + go func() { + for { + fmt.Println(br) + } + }() + + return nil +} diff --git a/api/main.go b/api/main.go new file mode 100644 index 000000000..f5ab609a2 --- /dev/null +++ b/api/main.go @@ -0,0 +1,43 @@ +package main // import "github.com/cloudinovasi/ui-for-docker" + +import ( + "gopkg.in/alecthomas/kingpin.v2" +) + +// main is the entry point of the program +func main() { + kingpin.Version("1.5.0") + var ( + endpoint = kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String() + addr = kingpin.Flag("bind", "Address and port to serve UI For Docker").Default(":9000").Short('p').String() + assets = kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String() + data = kingpin.Flag("data", "Path to the data").Default(".").Short('d').String() + tlsverify = kingpin.Flag("tlsverify", "TLS support").Default("false").Bool() + tlscacert = kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String() + tlscert = kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String() + tlskey = kingpin.Flag("tlskey", "Path to the TLS key").Default("/certs/key.pem").String() + swarm = kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool() + labels = pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')) + ) + kingpin.Parse() + + apiConfig := apiConfig{ + Endpoint: *endpoint, + BindAddress: *addr, + AssetPath: *assets, + DataPath: *data, + SwarmSupport: *swarm, + TLSEnabled: *tlsverify, + TLSCACertPath: *tlscacert, + TLSCertPath: *tlscert, + TLSKeyPath: *tlskey, + } + + configuration := &Config{ + Swarm: *swarm, + HiddenLabels: *labels, + } + + api := newAPI(apiConfig) + api.run(configuration) +} diff --git a/api/ssl.go b/api/ssl.go index 6d86db5b8..89f76e85e 100644 --- a/api/ssl.go +++ b/api/ssl.go @@ -7,13 +7,13 @@ import ( "log" ) -// newTLSConfig initializes a tls.Config from the TLS flags -func newTLSConfig(tlsFlags TLSFlags) *tls.Config { - cert, err := tls.LoadX509KeyPair(tlsFlags.certPath, tlsFlags.keyPath) +// newTLSConfig initializes a tls.Config using a CA certificate, a certificate and a key +func newTLSConfig(caCertPath, certPath, keyPath string) *tls.Config { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { log.Fatal(err) } - caCert, err := ioutil.ReadFile(tlsFlags.caPath) + caCert, err := ioutil.ReadFile(caCertPath) if err != nil { log.Fatal(err) } diff --git a/app/app.js b/app/app.js index 88e45553a..c09b7d08e 100644 --- a/app/app.js +++ b/app/app.js @@ -9,13 +9,14 @@ angular.module('uifordocker', [ 'uifordocker.filters', 'dashboard', 'container', + 'containerConsole', + 'containerLogs', 'containers', 'createContainer', 'docker', 'events', 'images', 'image', - 'containerLogs', 'stats', 'swarm', 'network', @@ -57,6 +58,11 @@ angular.module('uifordocker', [ templateUrl: 'app/components/containerLogs/containerlogs.html', controller: 'ContainerLogsController' }) + .state('console', { + url: "^/containers/:id/console", + templateUrl: 'app/components/containerConsole/containerConsole.html', + controller: 'ContainerConsoleController' + }) .state('actions', { abstract: true, url: "/actions", diff --git a/app/components/container/container.html b/app/components/container/container.html index d3e334f28..bc76f31cc 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -64,6 +64,7 @@
diff --git a/app/components/containerConsole/containerConsole.html b/app/components/containerConsole/containerConsole.html new file mode 100644 index 000000000..751bf738e --- /dev/null +++ b/app/components/containerConsole/containerConsole.html @@ -0,0 +1,43 @@ + + + + + + Containers > {{ container.Name|trimcontainername }} > Console + + + +
+
+ + +
+ +
+
+ +
+ +
+
+ +
+
+ + +
+
+
+
+
+
+
+ +
+
+
+
+
diff --git a/app/components/containerConsole/containerConsoleController.js b/app/components/containerConsole/containerConsoleController.js new file mode 100644 index 000000000..566529458 --- /dev/null +++ b/app/components/containerConsole/containerConsoleController.js @@ -0,0 +1,104 @@ +angular.module('containerConsole', []) +.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Settings', 'Container', 'Exec', '$timeout', 'Messages', 'errorMsgFilter', +function ($scope, $stateParams, Settings, Container, Exec, $timeout, Messages, errorMsgFilter) { + $scope.state = {}; + $scope.state.command = "bash"; + $scope.connected = false; + + var socket, term; + + // Ensure the socket is closed before leaving the view + $scope.$on('$stateChangeStart', function (event, next, current) { + if (socket !== null) { + socket.close(); + } + }); + + Container.get({id: $stateParams.id}, function(d) { + $scope.container = d; + $('#loadingViewSpinner').hide(); + }); + + $scope.connect = function() { + $('#loadConsoleSpinner').show(); + var termWidth = Math.round($('#terminal-container').width() / 8.2); + var termHeight = 30; + var execConfig = { + id: $stateParams.id, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + Cmd: $scope.state.command.replace(" ", ",").split(",") + }; + + Container.exec(execConfig, function(d) { + if (d.Id) { + var execId = d.Id; + resizeTTY(execId, termHeight, termWidth); + var url = window.location.href.split('#')[0].replace('http://', 'ws://') + 'ws/exec?id=' + execId; + initTerm(url, termHeight, termWidth); + } else { + $('#loadConsoleSpinner').hide(); + Messages.error('Error', errorMsgFilter(d)); + } + }, function (e) { + $('#loadConsoleSpinner').hide(); + Messages.error("Failure", e.data); + }); + }; + + $scope.disconnect = function() { + $scope.connected = false; + if (socket !== null) { + socket.close(); + } + if (term !== null) { + term.destroy(); + } + }; + + function resizeTTY(execId, height, width) { + $timeout(function() { + Exec.resize({id: execId, height: height, width: width}, function (d) { + var error = errorMsgFilter(d); + if (error) { + Messages.error('Error', 'Unable to resize TTY'); + } + }); + }, 2000); + + } + + function initTerm(url, height, width) { + socket = new WebSocket(url); + + $scope.connected = true; + socket.onopen = function(evt) { + $('#loadConsoleSpinner').hide(); + term = new Terminal({ + cols: width, + rows: height, + cursorBlink: true + }); + + term.on('data', function (data) { + socket.send(data); + }); + term.open(document.getElementById('terminal-container')); + + socket.onmessage = function (e) { + term.write(e.data); + }; + socket.onerror = function (error) { + $scope.connected = false; + + }; + socket.onclose = function(evt) { + $scope.connected = false; + // term.write("Session terminated"); + // term.destroy(); + }; + }; + } +}]); diff --git a/app/shared/services.js b/app/shared/services.js index 73b2a78f8..646d8fa75 100644 --- a/app/shared/services.js +++ b/app/shared/services.js @@ -18,9 +18,17 @@ angular.module('uifordocker.services', ['ngResource', 'ngSanitize']) create: {method: 'POST', params: {action: 'create'}}, remove: {method: 'DELETE', params: {id: '@id', v: 0}}, rename: {method: 'POST', params: {id: '@id', action: 'rename'}, isArray: false}, - stats: {method: 'GET', params: {id: '@id', stream: false, action: 'stats'}, timeout: 5000} + stats: {method: 'GET', params: {id: '@id', stream: false, action: 'stats'}, timeout: 5000}, + exec: {method: 'POST', params: {id: '@id', action: 'exec'}} }); }]) + .factory('Exec', ['$resource', 'Settings', function ExecFactory($resource, Settings) { + 'use strict'; + // https://docs.docker.com/engine/reference/api/docker_remote_api_<%= remoteApiVersion %>/#/exec-resize + return $resource(Settings.url + '/exec/:id/:action', {}, { + resize: {method: 'POST', params: {id: '@id', action: 'resize', h: '@height', w: '@width'}} + }); + }]) .factory('ContainerCommit', ['$resource', '$http', 'Settings', function ContainerCommitFactory($resource, $http, Settings) { 'use strict'; // http://docs.docker.com/reference/api/docker_remote_api_<%= remoteApiVersion %>/#create-a-new-image-from-a-container-s-changes diff --git a/assets/css/app.css b/assets/css/app.css index 3921400ff..ecfd4e977 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -183,5 +183,10 @@ input[type="radio"] { } .widget .widget-body table tbody .image-tag { - font-size: 90% !important; + font-size: 90% !important; +} + +.terminal-container { + width: 100%; + padding: 10px 5px; } diff --git a/bower.json b/bower.json index 12974762a..eda8a5475 100644 --- a/bower.json +++ b/bower.json @@ -38,7 +38,8 @@ "jquery.gritter": "1.7.4", "lodash": "4.12.0", "rdash-ui": "1.0.*", - "moment": "~2.14.1" + "moment": "~2.14.1", + "xterm.js": "~1.0.0" }, "resolutions": { "angular": "1.5.5" diff --git a/gruntFile.js b/gruntFile.js index 94e290422..86c2f3ebe 100644 --- a/gruntFile.js +++ b/gruntFile.js @@ -72,6 +72,7 @@ module.exports = function (grunt) { 'bower_components/Chart.js/Chart.min.js', 'bower_components/lodash/dist/lodash.min.js', 'bower_components/moment/min/moment.min.js', + 'bower_components/xterm.js/src/xterm.js', 'assets/js/jquery.gritter.js', // Using custom version to fix error in minified build due to "use strict" 'assets/js/legend.js' // Not a bower package ], @@ -85,7 +86,8 @@ module.exports = function (grunt) { 'bower_components/jquery.gritter/css/jquery.gritter.css', 'bower_components/font-awesome/css/font-awesome.min.css', 'bower_components/rdash-ui/dist/css/rdash.min.css', - 'bower_components/angular-ui-select/dist/select.min.css' + 'bower_components/angular-ui-select/dist/select.min.css', + 'bower_components/xterm.js/src/xterm.css' ] }, clean: {