diff --git a/api/api.go b/api/api.go index 5a8f12d04..9d45f28a0 100644 --- a/api/api.go +++ b/api/api.go @@ -1,34 +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')) - registries = pairs(kingpin.Flag("registries", "Supported Docker registries").Short('r')) - ) - kingpin.Parse() +type ( + api struct { + endpoint *url.URL + bindAddress string + assetPath string + dataPath string + tlsConfig *tls.Config + } - configuration := newConfig(*swarm, *labels, *registries) - 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 4d4590daf..48fecd0bb 100644 --- a/api/config.go +++ b/api/config.go @@ -22,6 +22,6 @@ func newConfig(swarm bool, labels, registries 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..d86c74a8d --- /dev/null +++ b/api/main.go @@ -0,0 +1,45 @@ +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')) + registries = pairs(kingpin.Flag("registries", "Supported Docker registries").Short('r')) + ) + 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, + Registries: *registries, + } + + 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 6ea224302..51cde3982 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', @@ -55,6 +56,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 @@
Stats Logs + Console
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: {