diff --git a/README.md b/README.md index b97fd6193..73502f72e 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,16 @@ The `--privileged` flag is required for hosts using SELinux. By default UI For Docker connects to the Docker daemon with`/var/run/docker.sock`. For this to work you need to bind mount the unix socket into the container with `-v /var/run/docker.sock:/var/run/docker.sock`. -You can use the `-e` flag to change this socket: +You can use the `--host`, `-H` flags to change this socket: ``` # Connect to a tcp socket: -$ docker run -d -p 9000:9000 cloudinovasi/cloudinovasi-ui -e http://127.0.0.1:2375 +$ docker run -d -p 9000:9000 cloudinovasi/cloudinovasi-ui -H tcp://127.0.0.1:2375 +``` + +``` +# Connect to another unix socket: +$ docker run -d -p 9000:9000 cloudinovasi/cloudinovasi-ui -H unix:///path/to/docker.sock ``` ### Swarm support @@ -43,7 +48,7 @@ You can access a specific view for you Swarm cluster by defining the `--swarm` f ``` # Connect to a tcp socket and enable Swarm: -$ docker run -d -p 9000:9000 cloudinovasi/cloudinovasi-ui -e http://: --swarm +$ docker run -d -p 9000:9000 cloudinovasi/cloudinovasi-ui -H tcp://: --swarm ``` *NOTE*: Due to Swarm not exposing information in a machine readable way, the app is bound to a specific version of Swarm at the moment. @@ -56,6 +61,24 @@ UI For Docker listens on port 9000 by default. If you run UI For Docker inside a $ docker run -d -p 10.20.30.1:80:9000 --privileged -v /var/run/docker.sock:/var/run/docker.sock cloudinovasi/cloudinovasi-ui ``` +### Access a Docker engine protected via TLS + +Ensure that you have access to the CA, the cert and the public key used to access your Docker engine. + +These files will need to be named `ca.pem`, `cert.pem` and `key.pem` respectively. Store them somewhere on your disk and mount a volume containing these files inside the UI container: + +``` +$ docker run -d -p 9000:9000 cloudinovasi/cloudinovasi-ui -v /path/to/certs:/certs -H https://my-docker-host.domain:2376 --tlsverify +``` + +You can also use the `--tlscacert`, `--tlscert` and `--tlskey` flags if you want to change the default path to the CA, certificate and key file respectively: + +``` +$ docker run -d -p 9000:9000 cloudinovasi/cloudinovasi-ui -v /path/to/certs:/certs -H https://my-docker-host.domain:2376 --tlsverify --tlscacert /certs/myCa.pem --tlscert /certs/myCert.pem --tlskey /certs/myKey.pem +``` + +*Note*: Replace `/path/to/certs` to the path to the certificate files on your disk. + ### Hide containers with specific labels You can hide specific containers in the containers view by using the `--hide-label` or `-l` options and specifying a label. @@ -86,10 +109,14 @@ $ docker run -d -p 9000:9000 --privileged -v /var/run/docker.sock:/var/run/docke The following options are available for the `ui-for-docker` binary: -* `--endpoint`, `-e`: Docker deamon endpoint (default: *"/var/run/docker.sock"*) -* `--bind`, `-p`: Address and port to serve UI For Docker (default: *":9000"*) -* `--data`, `-d`: Path to the data folder (default: *"."*) -* `--assets`, `-a`: Path to the assets (default: *"."*) -* `--swarm`, `-s`: Swarm cluster support (default: *false*) -* `--hide-label`, `-l`: Hide containers with a specific label in the UI (format *LABEL_NAME=LABEL_VALUE*) +* `--host`, `-H`: Docker daemon endpoint (default: `"unix:///var/run/docker.sock"`) +* `--bind`, `-p`: Address and port to serve UI For Docker (default: `":9000"`) +* `--data`, `-d`: Path to the data folder (default: `"."`) +* `--assets`, `-a`: Path to the assets (default: `"."`) +* `--swarm`, `-s`: Swarm cluster support (default: `false`) * `--registries`, `-r`: Available registries in the UI (format *REGISTRY_NAME=REGISTRY_ADDRESS*) +* `--tlsverify`: TLS support (default: `false`) +* `--tlscacert`: Path to the CA (default `/certs/ca.pem`) +* `--tlscert`: Path to the TLS certificate file (default `/certs/cert.pem`) +* `--tlskey`: Path to the TLS key (default `/certs/key.pem`) +* `--hide-label`, `-l`: Hide containers with a specific label in the UI diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index 36b26fa8f..0a7ca115a 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -39,7 +39,7 @@ - + State @@ -52,7 +52,7 @@ - + IP Address @@ -85,10 +85,10 @@ - {{ container.State }} + {{ container.Status|containerstatus }} {{ container|swarmcontainername}} {{ container|containername}} - {{ container.IP ? container.IP : '-' }} + {{ container.IP ? container.IP : '-' }} {{ container|swarmhostname}} {{ container.Image }} {{ container.Command|truncate:60 }} diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index 76fc50428..b7662e243 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -4,6 +4,7 @@ function ($scope, Container, Settings, Messages, Config, errorMsgFilter) { $scope.state = {}; $scope.state.displayAll = Settings.displayAll; + $scope.state.displayIP = false; $scope.sortType = 'State'; $scope.sortReverse = true; $scope.state.selectedItemCount = 0; @@ -22,7 +23,11 @@ function ($scope, Container, Settings, Messages, Config, errorMsgFilter) { containers = hideContainers(d); } $scope.containers = containers.map(function (container) { - return new ContainerViewModel(container); + var model = new ContainerViewModel(container); + if (model.IP) { + $scope.state.displayIP = true; + } + return model; }); $('#loadContainersSpinner').hide(); }); diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 73d023467..ea77a3099 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -14,6 +14,7 @@ function ($scope, $state, Config, Container, Image, Volume, Network, Messages, e }; $scope.imageConfig = {}; + $scope.config = { Env: [], HostConfig: { diff --git a/app/components/createNetwork/createNetworkController.js b/app/components/createNetwork/createNetworkController.js new file mode 100644 index 000000000..56ffb79f6 --- /dev/null +++ b/app/components/createNetwork/createNetworkController.js @@ -0,0 +1,74 @@ +angular.module('createNetwork', []) +.controller('CreateNetworkController', ['$scope', '$state', 'Messages', 'Network', 'errorMsgFilter', +function ($scope, $state, Messages, Network, errorMsgFilter) { + $scope.formValues = { + DriverOptions: [], + Subnet: '', + Gateway: '' + }; + + $scope.config = { + Driver: 'bridge', + CheckDuplicate: true, + Internal: false, + IPAM: { + Config: [] + } + }; + + $scope.addDriverOption = function() { + $scope.formValues.DriverOptions.push({ name: '', value: '' }); + }; + + $scope.removeDriverOption = function(index) { + $scope.formValues.DriverOptions.splice(index, 1); + }; + + function createNetwork(config) { + $('#createNetworkSpinner').show(); + Network.create(config, function (d) { + if (d.Id) { + Messages.send("Network created", d.Id); + $('#createNetworkSpinner').hide(); + $state.go('networks', {}, {reload: true}); + } else { + $('#createNetworkSpinner').hide(); + Messages.error('Unable to create network', errorMsgFilter(d)); + } + }, function (e) { + $('#createNetworkSpinner').hide(); + Messages.error('Unable to create network', e.data); + }); + } + + function prepareIPAMConfiguration(config) { + if ($scope.formValues.Subnet) { + var ipamConfig = {}; + ipamConfig.Subnet = $scope.formValues.Subnet; + if ($scope.formValues.Gateway) { + ipamConfig.Gateway = $scope.formValues.Gateway ; + } + config.IPAM.Config.push(ipamConfig); + } + } + + function prepareDriverOptions(config) { + var options = {}; + $scope.formValues.DriverOptions.forEach(function (option) { + options[option.name] = option.value; + }); + config.Options = options; + } + + function prepareConfiguration() { + var config = angular.copy($scope.config); + prepareIPAMConfiguration(config); + prepareDriverOptions(config); + return config; + } + + $scope.create = function () { + var config = prepareConfiguration(); + createNetwork(config); + }; +}]); diff --git a/app/components/createNetwork/createnetwork.html b/app/components/createNetwork/createnetwork.html new file mode 100644 index 000000000..31ed20d37 --- /dev/null +++ b/app/components/createNetwork/createnetwork.html @@ -0,0 +1,95 @@ + + + + Networks > Add network + + + +
+
+ + +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ + driver option + +
+ +
+
+
+ name + +
+
+ value + + + + +
+
+
+ +
+ + +
+
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+ + Cancel +
+
diff --git a/app/components/images/images.html b/app/components/images/images.html index 09420abda..62956c948 100644 --- a/app/components/images/images.html +++ b/app/components/images/images.html @@ -4,7 +4,6 @@ - Images diff --git a/app/shared/filters.js b/app/shared/filters.js index 4b5b93700..f1b40abfb 100644 --- a/app/shared/filters.js +++ b/app/shared/filters.js @@ -21,16 +21,31 @@ angular.module('dockerui.filters', []) .filter('containerstatusbadge', function () { 'use strict'; return function (text) { - if (text === 'paused') { + var status = _.toLower(text); + if (status.indexOf('paused') !== -1) { return 'warning'; - } else if (text === 'created') { + } else if (status.indexOf('created') !== -1) { return 'info'; - } else if (text === 'exited') { + } else if (status.indexOf('exited') !== -1) { return 'danger'; } return 'success'; }; }) +.filter('containerstatus', function () { + 'use strict'; + return function (text) { + var status = _.toLower(text); + if (status.indexOf('paused') !== -1) { + return 'paused'; + } else if (status.indexOf('created') !== -1) { + return 'created'; + } else if (status.indexOf('exited') !== -1) { + return 'stopped'; + } + return 'running'; + }; +}) .filter('nodestatusbadge', function () { 'use strict'; return function (text) { diff --git a/app/shared/viewmodel.js b/app/shared/viewmodel.js index 901a2fd86..1b222692e 100644 --- a/app/shared/viewmodel.js +++ b/app/shared/viewmodel.js @@ -10,9 +10,12 @@ function ImageViewModel(data) { function ContainerViewModel(data) { this.Id = data.Id; - this.State = data.State; + this.Status = data.Status; this.Names = data.Names; - this.IP = data.NetworkSettings.Networks[Object.keys(data.NetworkSettings.Networks)[0]].IPAddress; + // Unavailable in Docker < 1.10 + if (data.NetworkSettings) { + this.IP = data.NetworkSettings.Networks[Object.keys(data.NetworkSettings.Networks)[0]].IPAddress; + } this.Image = data.Image; this.Command = data.Command; this.Checked = false; diff --git a/dockerui.go b/dockerui.go index cd5dc32ba..e0f281dc8 100644 --- a/dockerui.go +++ b/dockerui.go @@ -15,16 +15,22 @@ import ( "fmt" "github.com/gorilla/securecookie" "gopkg.in/alecthomas/kingpin.v2" + "crypto/tls" + "crypto/x509" ) var ( - endpoint = kingpin.Flag("endpoint", "Dockerd endpoint").Default("/var/run/docker.sock").Short('e').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() - labels = LabelParser(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')) + 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() registries = LabelParser(kingpin.Flag("registries", "Supported Docker registries").Short('r')) + 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" ) @@ -33,6 +39,13 @@ 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"` @@ -108,35 +121,70 @@ func configurationHandler(w http.ResponseWriter, r *http.Request, c Config) { json.NewEncoder(w).Encode(c) } -func createTcpHandler(e string) http.Handler { - u, err := url.Parse(e) +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) } - return httputil.NewSingleHostReverseProxy(u) + 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) http.Handler { +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 ) - - if strings.Contains(e, "http") { - h = createTcpHandler(e) - } else { - if _, err := os.Stat(e); err != nil { + 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", e) + log.Fatalf("unix socket %s does not exist", socketPath) } log.Fatal(err) } - h = createUnixHandler(e) + 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. @@ -184,7 +232,14 @@ func main() { Registries: *registries, } - handler := createHandler(*assets, *data, *endpoint, configuration) + 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 160068fcc..3ff9bbc31 100644 --- a/gruntFile.js +++ b/gruntFile.js @@ -40,6 +40,7 @@ module.exports = function (grunt) { grunt.registerTask('run', ['if:binaryNotExist', 'build', 'shell:buildImage', 'shell:run']); grunt.registerTask('run-swarm', ['if:binaryNotExist', 'build', 'shell:buildImage', 'shell:runSwarm', 'watch:buildSwarm']); grunt.registerTask('run-dev', ['if:binaryNotExist', 'shell:buildImage', 'shell:run', 'watch:build']); + grunt.registerTask('run-ssl', ['if:binaryNotExist', 'shell:buildImage', 'shell:runSsl', 'watch:buildSsl']); grunt.registerTask('clear', ['clean:app']); // Print a timestamp (useful for when watching) @@ -224,6 +225,10 @@ module.exports = function (grunt) { buildSwarm: { files: ['<%= src.js %>', '<%= src.specs %>', '<%= src.css %>', '<%= src.tpl %>', '<%= src.html %>'], tasks: ['build', 'shell:buildImage', 'shell:runSwarm', 'shell:cleanImages'] + }, + buildSsl: { + files: ['<%= src.js %>', '<%= src.specs %>', '<%= src.css %>', '<%= src.tpl %>', '<%= src.html %>'], + tasks: ['build', 'shell:buildImage', 'shell:runSsl', 'shell:cleanImages'] } }, jshint: { @@ -267,7 +272,14 @@ module.exports = function (grunt) { command: [ 'docker stop ui-for-docker', 'docker rm ui-for-docker', - 'docker run --privileged -d -p 9000:9000 -v /tmp/docker-ui:/data --name ui-for-docker ui-for-docker -e http://10.0.7.10:4000 --swarm -d /data -r local=192.168.2.193:5000' + 'docker run -d -p 9000:9000 -v /tmp/docker-ui:/data --name ui-for-docker ui-for-docker -H tcp://10.0.7.10:4000 --swarm -d /data' + ].join(';') + }, + runSsl: { + command: [ + 'docker stop ui-for-docker', + 'docker rm ui-for-docker', + 'docker run -d -p 9000:9000 -v /tmp/docker-ui:/data -v /tmp/docker-ssl:/certs --name ui-for-docker ui-for-docker -H tcp://10.0.7.10:2376 -d /data --tlsverify' ].join(';') }, cleanImages: {