diff --git a/api/http/handler/websocket/attach.go b/api/http/handler/websocket/attach.go new file mode 100644 index 000000000..b85c8d7d6 --- /dev/null +++ b/api/http/handler/websocket/attach.go @@ -0,0 +1,122 @@ +package websocket + +import ( + "net" + "net/http" + "net/http/httputil" + "time" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/websocket" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/portainer/api" +) + +// websocketAttach handles GET requests on /websocket/attach?id=&endpointId=&nodeName=&token= +// If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint. +// If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and +// an AttachStart operation HTTP request will be created and hijacked. +// Authentication and access is controled via the mandatory token query parameter. +func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + attachID, err := request.RetrieveQueryParameter(r, "id", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: id", err} + } + if !govalidator.IsHexadecimal(attachID) { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: id (must be hexadecimal identifier)", err} + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + err = handler.requestBouncer.EndpointAccess(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } + + params := &webSocketRequestParams{ + endpoint: endpoint, + ID: attachID, + nodeName: r.FormValue("nodeName"), + } + + err = handler.handleAttachRequest(w, r, params) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during websocket attach operation", err} + } + + return nil +} + +func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { + + r.Header.Del("Origin") + + if params.nodeName != "" || params.endpoint.Type == portainer.AgentOnDockerEnvironment { + return handler.proxyWebsocketRequest(w, r, params) + } + + websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) + if err != nil { + return err + } + defer websocketConn.Close() + + return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID) +} + +func hijackAttachStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, attachID string) error { + dial, err := initDial(endpoint) + if err != nil { + return err + } + + // 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) + } + + httpConn := httputil.NewClientConn(dial, nil) + defer httpConn.Close() + + attachStartRequest, err := createAttachStartRequest(attachID) + if err != nil { + return err + } + + err = hijackRequest(websocketConn, httpConn, attachStartRequest) + if err != nil { + return err + } + + return nil +} + +func createAttachStartRequest(attachID string) (*http.Request, error) { + + request, err := http.NewRequest("POST", "/containers/"+attachID+"/attach?stdin=1&stdout=1&stderr=1&stream=1", nil) + if err != nil { + return nil, err + } + + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "tcp") + + return request, nil +} diff --git a/api/http/handler/websocket/websocket_dial.go b/api/http/handler/websocket/dial.go similarity index 100% rename from api/http/handler/websocket/websocket_dial.go rename to api/http/handler/websocket/dial.go diff --git a/api/http/handler/websocket/websocket_dial_windows.go b/api/http/handler/websocket/dial_windows.go similarity index 99% rename from api/http/handler/websocket/websocket_dial_windows.go rename to api/http/handler/websocket/dial_windows.go index 49a9afd28..5a3cf0d0f 100644 --- a/api/http/handler/websocket/websocket_dial_windows.go +++ b/api/http/handler/websocket/dial_windows.go @@ -3,9 +3,8 @@ package websocket import ( - "net" - "github.com/Microsoft/go-winio" + "net" ) func createDial(scheme, host string) (net.Conn, error) { diff --git a/api/http/handler/websocket/websocket_exec.go b/api/http/handler/websocket/exec.go similarity index 54% rename from api/http/handler/websocket/websocket_exec.go rename to api/http/handler/websocket/exec.go index bc3cde00a..7b7b8a753 100644 --- a/api/http/handler/websocket/websocket_exec.go +++ b/api/http/handler/websocket/exec.go @@ -1,32 +1,20 @@ package websocket import ( - "bufio" "bytes" - "crypto/tls" "encoding/json" - "fmt" "net" "net/http" "net/http/httputil" - "net/url" "time" "github.com/asaskevich/govalidator" "github.com/gorilla/websocket" - "github.com/koding/websocketproxy" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/crypto" ) -type webSocketExecRequestParams struct { - execID string - nodeName string - endpoint *portainer.Endpoint -} - type execStartOperationPayload struct { Tty bool Detach bool @@ -63,13 +51,13 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} } - params := &webSocketExecRequestParams{ + params := &webSocketRequestParams{ endpoint: endpoint, - execID: execID, + ID: execID, nodeName: r.FormValue("nodeName"), } - err = handler.handleRequest(w, r, params) + err = handler.handleExecRequest(w, r, params) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during websocket exec operation", err} } @@ -77,7 +65,7 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h return nil } -func (handler *Handler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { +func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { r.Header.Del("Origin") if params.nodeName != "" || params.endpoint.Type == portainer.AgentOnDockerEnvironment { @@ -90,41 +78,7 @@ func (handler *Handler) handleRequest(w http.ResponseWriter, r *http.Request, pa } defer websocketConn.Close() - return hijackExecStartOperation(websocketConn, params.endpoint, params.execID) -} - -func (handler *Handler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { - agentURL, err := url.Parse(params.endpoint.URL) - if err != nil { - return err - } - - agentURL.Scheme = "ws" - proxy := websocketproxy.NewProxy(agentURL) - - if params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify { - agentURL.Scheme = "wss" - proxy.Dialer = &websocket.Dialer{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: params.endpoint.TLSConfig.TLSSkipVerify, - }, - } - } - - signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) - if err != nil { - return err - } - - proxy.Director = func(incoming *http.Request, out http.Header) { - out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey()) - out.Set(portainer.PortainerAgentSignatureHeader, signature) - out.Set(portainer.PortainerAgentTargetHeader, params.nodeName) - } - - proxy.ServeHTTP(w, r) - - return nil + return hijackExecStartOperation(websocketConn, params.endpoint, params.ID) } func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error { @@ -159,30 +113,6 @@ func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer return nil } -func initDial(endpoint *portainer.Endpoint) (net.Conn, error) { - url, err := url.Parse(endpoint.URL) - if err != nil { - return nil, err - } - - host := url.Host - - if url.Scheme == "unix" || url.Scheme == "npipe" { - host = url.Path - } - - if endpoint.TLSConfig.TLS { - tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify) - if err != nil { - return nil, err - } - - return tls.Dial(url.Scheme, host, tlsConfig) - } - - return createDial(url.Scheme, host) -} - func createExecStartRequest(execID string) (*http.Request, error) { execStartOperationPayload := &execStartOperationPayload{ Tty: true, @@ -206,64 +136,3 @@ func createExecStartRequest(execID string) (*http.Request, error) { return request, nil } - -func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error { - // Server hijacks the connection, error 'connection closed' expected - resp, err := httpConn.Do(request) - if err != httputil.ErrPersistEOF { - if err != nil { - return err - } - if resp.StatusCode != http.StatusSwitchingProtocols { - resp.Body.Close() - return fmt.Errorf("unable to upgrade to tcp, received %d", resp.StatusCode) - } - } - - tcpConn, brw := httpConn.Hijack() - defer tcpConn.Close() - - errorChan := make(chan error, 1) - go streamFromTCPConnToWebsocketConn(websocketConn, brw, errorChan) - go streamFromWebsocketConnToTCPConn(websocketConn, tcpConn, errorChan) - - err = <-errorChan - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { - return err - } - - return nil -} - -func streamFromWebsocketConnToTCPConn(websocketConn *websocket.Conn, tcpConn net.Conn, errorChan chan error) { - for { - _, in, err := websocketConn.ReadMessage() - if err != nil { - errorChan <- err - break - } - - _, err = tcpConn.Write(in) - if err != nil { - errorChan <- err - break - } - } -} - -func streamFromTCPConnToWebsocketConn(websocketConn *websocket.Conn, br *bufio.Reader, errorChan chan error) { - for { - out := make([]byte, 2048) - _, err := br.Read(out) - if err != nil { - errorChan <- err - break - } - - err = websocketConn.WriteMessage(websocket.TextMessage, out) - if err != nil { - errorChan <- err - break - } - } -} diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go index 14a197d84..891257823 100644 --- a/api/http/handler/websocket/handler.go +++ b/api/http/handler/websocket/handler.go @@ -26,5 +26,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { } h.PathPrefix("/websocket/exec").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketExec))) + h.PathPrefix("/websocket/attach").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketAttach))) return h } diff --git a/api/http/handler/websocket/hijack.go b/api/http/handler/websocket/hijack.go new file mode 100644 index 000000000..f8a7b6624 --- /dev/null +++ b/api/http/handler/websocket/hijack.go @@ -0,0 +1,36 @@ +package websocket + +import ( + "fmt" + "github.com/gorilla/websocket" + "net/http" + "net/http/httputil" +) + +func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error { + // Server hijacks the connection, error 'connection closed' expected + resp, err := httpConn.Do(request) + if err != httputil.ErrPersistEOF { + if err != nil { + return err + } + if resp.StatusCode != http.StatusSwitchingProtocols { + resp.Body.Close() + return fmt.Errorf("unable to upgrade to tcp, received %d", resp.StatusCode) + } + } + + tcpConn, brw := httpConn.Hijack() + defer tcpConn.Close() + + errorChan := make(chan error, 1) + go streamFromTCPConnToWebsocketConn(websocketConn, brw, errorChan) + go streamFromWebsocketConnToTCPConn(websocketConn, tcpConn, errorChan) + + err = <-errorChan + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { + return err + } + + return nil +} diff --git a/api/http/handler/websocket/initdial.go b/api/http/handler/websocket/initdial.go new file mode 100644 index 000000000..27663734a --- /dev/null +++ b/api/http/handler/websocket/initdial.go @@ -0,0 +1,35 @@ +package websocket + +import ( + "crypto/tls" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "net" + "net/url" +) + +func initDial(endpoint *portainer.Endpoint) (net.Conn, error) { + url, err := url.Parse(endpoint.URL) + if err != nil { + return nil, err + } + + host := url.Host + + if url.Scheme == "unix" || url.Scheme == "npipe" { + host = url.Path + } + + if endpoint.TLSConfig.TLS { + tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify) + if err != nil { + return nil, err + } + + return tls.Dial(url.Scheme, host, tlsConfig) + } + + con, err := createDial(url.Scheme, host) + + return con, err +} diff --git a/api/http/handler/websocket/proxy.go b/api/http/handler/websocket/proxy.go new file mode 100644 index 000000000..7eba78b3a --- /dev/null +++ b/api/http/handler/websocket/proxy.go @@ -0,0 +1,44 @@ +package websocket + +import ( + "crypto/tls" + "github.com/gorilla/websocket" + "github.com/koding/websocketproxy" + "github.com/portainer/portainer/api" + "net/http" + "net/url" +) + +func (handler *Handler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { + agentURL, err := url.Parse(params.endpoint.URL) + if err != nil { + return err + } + + agentURL.Scheme = "ws" + proxy := websocketproxy.NewProxy(agentURL) + + if params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify { + agentURL.Scheme = "wss" + proxy.Dialer = &websocket.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: params.endpoint.TLSConfig.TLSSkipVerify, + }, + } + } + + signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) + if err != nil { + return err + } + + proxy.Director = func(incoming *http.Request, out http.Header) { + out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey()) + out.Set(portainer.PortainerAgentSignatureHeader, signature) + out.Set(portainer.PortainerAgentTargetHeader, params.nodeName) + } + + proxy.ServeHTTP(w, r) + + return nil +} diff --git a/api/http/handler/websocket/stream.go b/api/http/handler/websocket/stream.go new file mode 100644 index 000000000..16e68f8de --- /dev/null +++ b/api/http/handler/websocket/stream.go @@ -0,0 +1,40 @@ +package websocket + +import ( + "bufio" + "github.com/gorilla/websocket" + "net" +) + +func streamFromWebsocketConnToTCPConn(websocketConn *websocket.Conn, tcpConn net.Conn, errorChan chan error) { + for { + _, in, err := websocketConn.ReadMessage() + if err != nil { + errorChan <- err + break + } + + _, err = tcpConn.Write(in) + if err != nil { + errorChan <- err + break + } + } +} + +func streamFromTCPConnToWebsocketConn(websocketConn *websocket.Conn, br *bufio.Reader, errorChan chan error) { + for { + out := make([]byte, 2048) + _, err := br.Read(out) + if err != nil { + errorChan <- err + break + } + + err = websocketConn.WriteMessage(websocket.TextMessage, out) + if err != nil { + errorChan <- err + break + } + } +} diff --git a/api/http/handler/websocket/types.go b/api/http/handler/websocket/types.go new file mode 100644 index 000000000..abb86c7db --- /dev/null +++ b/api/http/handler/websocket/types.go @@ -0,0 +1,11 @@ +package websocket + +import ( + "github.com/portainer/portainer/api" +) + +type webSocketRequestParams struct { + ID string + nodeName string + endpoint *portainer.Endpoint +} diff --git a/app/docker/__module.js b/app/docker/__module.js index bea1c8a66..a3b15cc8f 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -75,12 +75,23 @@ angular.module('portainer.docker', ['portainer.app']) } }; - var containerConsole = { - name: 'docker.containers.container.console', - url: '/console', + var containerAttachConsole = { + name: 'docker.containers.container.attach', + url: '/attach', views: { 'content@': { - templateUrl: './views/containers/console/containerconsole.html', + templateUrl: './views/containers/console/attach.html', + controller: 'ContainerConsoleController' + } + } + }; + + var containerExecConsole = { + name: 'docker.containers.container.exec', + url: '/exec', + views: { + 'content@': { + templateUrl: './views/containers/console/exec.html', controller: 'ContainerConsoleController' } } @@ -473,7 +484,8 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(configCreation); $stateRegistryProvider.register(containers); $stateRegistryProvider.register(container); - $stateRegistryProvider.register(containerConsole); + $stateRegistryProvider.register(containerExecConsole); + $stateRegistryProvider.register(containerAttachConsole); $stateRegistryProvider.register(containerCreation); $stateRegistryProvider.register(containerInspect); $stateRegistryProvider.register(containerLogs); diff --git a/app/docker/components/container-quick-actions/containerQuickActions.html b/app/docker/components/container-quick-actions/containerQuickActions.html index 5475dd9c5..e24fb93f0 100644 --- a/app/docker/components/container-quick-actions/containerQuickActions.html +++ b/app/docker/components/container-quick-actions/containerQuickActions.html @@ -34,11 +34,18 @@ title="Stats"> - + + + + \ No newline at end of file diff --git a/app/docker/rest/container.js b/app/docker/rest/container.js index 7841ba2b2..94c3befbc 100644 --- a/app/docker/rest/container.js +++ b/app/docker/rest/container.js @@ -73,6 +73,10 @@ function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, C }, prune: { method: 'POST', params: { action: 'prune', filters: '@filters' } + }, + resize: { + method: 'POST', params: {id: '@id', action: 'resize', h: '@height', w: '@width'}, + transformResponse: genericHandler, ignoreLoadingBar: true } }); }]); diff --git a/app/docker/services/containerService.js b/app/docker/services/containerService.js index 439f7cd26..cc4cbea87 100644 --- a/app/docker/services/containerService.js +++ b/app/docker/services/containerService.js @@ -1,8 +1,8 @@ import { ContainerDetailsViewModel, ContainerViewModel, ContainerStatsViewModel } from '../models/container'; angular.module('portainer.docker') -.factory('ContainerService', ['$q', 'Container', 'ResourceControlService', 'LogHelper', -function ContainerServiceFactory($q, Container, ResourceControlService, LogHelper) { +.factory('ContainerService', ['$q', 'Container', 'ResourceControlService', 'LogHelper', '$timeout', +function ContainerServiceFactory($q, Container, ResourceControlService, LogHelper, $timeout) { 'use strict'; var service = {}; @@ -37,6 +37,26 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe return deferred.promise; }; + service.resizeTTY = function (id, width, height, timeout) { + var deferred = $q.defer(); + + $timeout(function() { + Container.resize({}, {id: id, height: height, width: width}).$promise + .then(function success(data) { + if (data.message) { + deferred.reject({msg: 'Unable to resize tty of container ' + id, err: data.message}); + } else { + deferred.resolve(data); + } + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to resize tty of container ' + id, err: err}); + }); + }, timeout); + + return deferred.promise; + }; + service.startContainer = function(id) { return Container.start({ id: id }, {}).$promise; }; diff --git a/app/docker/services/execService.js b/app/docker/services/execService.js index 2e0c04f95..bcb09b77c 100644 --- a/app/docker/services/execService.js +++ b/app/docker/services/execService.js @@ -1,27 +1,27 @@ angular.module('portainer.docker') -.factory('ExecService', ['$q', '$timeout', 'Exec', function ExecServiceFactory($q, $timeout, Exec) { - 'use strict'; - var service = {}; + .factory('ExecService', ['$q', '$timeout', 'Exec', function ExecServiceFactory($q, $timeout, Exec) { + 'use strict'; + var service = {}; - service.resizeTTY = function(execId, height, width, timeout) { - var deferred = $q.defer(); + service.resizeTTY = function (execId, width, height, timeout) { + var deferred = $q.defer(); - $timeout(function() { - Exec.resize({}, { id: execId, height: height, width: width }).$promise - .then(function success(data) { - if (data.message) { - deferred.reject({ msg: 'Unable to exec into container', err: data.message }); - } else { - deferred.resolve(data); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to exec into container', err: err }); - }); - }, timeout); + $timeout(function() { + Exec.resize({}, {id: execId, height: height, width: width}).$promise + .then(function success(data) { + if (data.message) { + deferred.reject({msg: "Unable to resize tty of exec", err: data.message}); + } else { + deferred.resolve(data); + } + }) + .catch(function error(err) { + deferred.reject({msg: "Unable to resize tty of exec", err: err}); + }); + }, timeout); - return deferred.promise; - }; + return deferred.promise; + }; - return service; -}]); + return service; + }]); diff --git a/app/docker/views/containers/console/attach.html b/app/docker/views/containers/console/attach.html new file mode 100644 index 000000000..98e618848 --- /dev/null +++ b/app/docker/views/containers/console/attach.html @@ -0,0 +1,50 @@ + + + + Containers > {{ container.Name|trimcontainername }} > Console + + + +
+
+ + + + +
+

+ + The interactive-flag is not set. You might not be able to use the console properly. +

+
+ +
+

+ + The TTY-flag is not set. You might not be able to use the console properly. +

+
+ +
+

+ + The container is not running. +

+
+ + + +
+
+
+
+ +
+
+
+
+
diff --git a/app/docker/views/containers/console/containerConsoleController.js b/app/docker/views/containers/console/containerConsoleController.js index f1bf02821..2fa3c3e46 100644 --- a/app/docker/views/containers/console/containerConsoleController.js +++ b/app/docker/views/containers/console/containerConsoleController.js @@ -1,131 +1,215 @@ -import { Terminal } from 'xterm'; +import {Terminal} from 'xterm'; angular.module('portainer.docker') -.controller('ContainerConsoleController', ['$scope', '$transition$', 'ContainerService', 'ImageService', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ExecService', 'HttpRequestHelper', 'LocalStorage', 'CONSOLE_COMMANDS_LABEL_PREFIX', -function ($scope, $transition$, ContainerService, ImageService, EndpointProvider, Notifications, ContainerHelper, ExecService, HttpRequestHelper, LocalStorage, CONSOLE_COMMANDS_LABEL_PREFIX) { - var socket, term; + .controller('ContainerConsoleController', ['$scope', '$transition$', 'ContainerService', 'ImageService', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ExecService', 'HttpRequestHelper', 'LocalStorage', 'CONSOLE_COMMANDS_LABEL_PREFIX', + function ($scope, $transition$, ContainerService, ImageService, EndpointProvider, Notifications, ContainerHelper, ExecService, HttpRequestHelper, LocalStorage, CONSOLE_COMMANDS_LABEL_PREFIX) { + var socket, term; - $scope.state = { - loaded: false, - connected: false - }; - - $scope.formValues = {}; - $scope.containerCommands = []; - - // Ensure the socket is closed before leaving the view - $scope.$on('$stateChangeStart', function () { - if (socket && socket !== null) { - socket.close(); - } - }); - - $scope.connect = function() { - var termWidth = Math.floor(($('#terminal-container').width() - 20) / 8.39); - var termHeight = 30; - var command = $scope.formValues.isCustomCommand ? - $scope.formValues.customCommand : $scope.formValues.command; - var execConfig = { - id: $transition$.params().id, - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - Tty: true, - User: $scope.formValues.user, - Cmd: ContainerHelper.commandStringToArray(command) - }; - - var execId; - ContainerService.createExec(execConfig) - .then(function success(data) { - execId = data.Id; - var jwtToken = LocalStorage.getJWT(); - var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID() + '&token=' + jwtToken; - if ($transition$.params().nodeName) { - url += '&nodeName=' + $transition$.params().nodeName; - } - if (url.indexOf('https') > -1) { - url = url.replace('https://', 'wss://'); - } else { - url = url.replace('http://', 'ws://'); - } - initTerm(url, termHeight, termWidth); - return ExecService.resizeTTY(execId, termHeight, termWidth, 2000); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to exec into container'); - $scope.disconnect(); - }); - }; - - $scope.disconnect = function() { - $scope.state.connected = false; - if (socket !== null) { - socket.close(); - } - if (term !== null) { - term.destroy(); - } - }; - - function initTerm(url, height, width) { - socket = new WebSocket(url); - - $scope.state.connected = true; - socket.onopen = function() { - term = new Terminal(); - - term.on('data', function (data) { - socket.send(data); + let states = Object.freeze({ + disconnected: 0, + connecting: 1, + connected: 2, }); - term.open(document.getElementById('terminal-container')); - term.focus(); - term.resize(width, height); - term.setOption('cursorBlink', true); - term.fit(); - window.onresize = function() { - term.fit(); - }; + $scope.loaded = false; + $scope.states = states; + $scope.state = states.disconnected; - socket.onmessage = function (e) { - term.write(e.data); - }; - socket.onerror = function () { - $scope.state.connected = false; - }; - socket.onclose = function() { - $scope.state.connected = false; - }; - }; - } + $scope.formValues = {}; + $scope.containerCommands = []; - function initView() { - HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); - ContainerService.container($transition$.params().id) - .then(function success(data) { - var container = data; - $scope.container = container; - return ImageService.image(container.Image); - }) - .then(function success(data) { - var image = data; - var containerLabels = $scope.container.Config.Labels; - $scope.imageOS = image.Os; - $scope.formValues.command = image.Os === 'windows' ? 'powershell' : 'bash'; - $scope.containerCommands = Object.keys(containerLabels) - .filter(function(label) { - return label.indexOf(CONSOLE_COMMANDS_LABEL_PREFIX) === 0; + // Ensure the socket is closed before leaving the view + $scope.$on('$stateChangeStart', function () { + $scope.disconnect(); + }); + + $scope.connectAttach = function() { + if ($scope.state > states.disconnected) { + return; + } + + $scope.state = states.connecting; + + let attachId = $transition$.params().id; + + ContainerService.container(attachId).then((details) => { + + if (!details.State.Running) { + Notifications.error("Failure", details, "Container " + attachId + " is not running!"); + $scope.disconnect(); + return; + } + + const params = { + token: LocalStorage.getJWT(), + endpointId: EndpointProvider.endpointID(), + id: attachId + }; + + var url = window.location.href.split('#')[0] + 'api/websocket/attach?' + (Object.keys(params).map((k) => k + "=" + params[k]).join("&")); + + initTerm(url, ContainerService.resizeTTY.bind(this, attachId)); }) - .map(function(label) { - return {title: label.replace(CONSOLE_COMMANDS_LABEL_PREFIX, ''), command: containerLabels[label]}; - }); - $scope.state.loaded = true; - }) - .catch(function error(err) { - Notifications.error('Error', err, 'Unable to retrieve container details'); - }); - } + .catch(function error(err) { + Notifications.error('Error', err, 'Unable to retrieve container details'); + $scope.disconnect(); + }); + }; - initView(); -}]); + $scope.connectExec = function () { + if ($scope.state > states.disconnected) { + return; + } + + $scope.state = states.connecting; + var command = $scope.formValues.isCustomCommand ? + $scope.formValues.customCommand : $scope.formValues.command; + var execConfig = { + id: $transition$.params().id, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + User: $scope.formValues.user, + Cmd: ContainerHelper.commandStringToArray(command) + }; + + ContainerService.createExec(execConfig) + .then(function success(data) { + + const params = { + token: LocalStorage.getJWT(), + endpointId: EndpointProvider.endpointID(), + id: data.Id + }; + + var url = window.location.href.split('#')[0] + 'api/websocket/exec?' + (Object.keys(params).map((k) => k + "=" + params[k]).join("&")); + + initTerm(url, ExecService.resizeTTY.bind(this, params.id)); + + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to exec into container'); + $scope.disconnect(); + }); + }; + + $scope.disconnect = function () { + if (socket) { + socket.close(); + } + if ($scope.state > states.disconnected) { + $scope.state = states.disconnected; + if (term) { + term.write("\n\r(connection closed)"); + term.dispose(); + } + } + }; + + $scope.autoconnectAttachView = function () { + return $scope.initView().then(function success() { + if ($scope.container.State.Running) { + $scope.connectAttach(); + } + }); + }; + + function resize(restcall, add) { + add = add || 0; + + term.fit(); + var termWidth = term.cols; + var termHeight = 30; + term.resize(termWidth, termHeight); + + + restcall(termWidth + add, termHeight + add, 1); + } + + function initTerm(url, resizeRestCall) { + + let resizefun = resize.bind(this, resizeRestCall); + + if ($transition$.params().nodeName) { + url += '&nodeName=' + $transition$.params().nodeName; + } + if (url.indexOf('https') > -1) { + url = url.replace('https://', 'wss://'); + } else { + url = url.replace('http://', 'ws://'); + } + + socket = new WebSocket(url); + + + socket.onopen = function () { + $scope.state = states.connected; + term = new Terminal(); + + + term.on('data', function (data) { + socket.send(data); + }); + var terminal_container = document.getElementById('terminal-container'); + term.open(terminal_container); + term.focus(); + term.setOption('cursorBlink', true); + + window.onresize = function () { + resizefun(); + $scope.$apply(); + }; + + $scope.$watch('toggle', function () { + setTimeout(resizefun, 400); + }); + + socket.onmessage = function (e) { + term.write(e.data); + }; + socket.onerror = function (err) { + $scope.disconnect(); + $scope.$apply(); + Notifications.error("Failure", err, "Connection error"); + }; + socket.onclose = function () { + $scope.disconnect(); + $scope.$apply(); + }; + + resizefun(1); + $scope.$apply(); + }; + } + + $scope.initView = function () { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); + return ContainerService.container($transition$.params().id) + .then(function success(data) { + var container = data; + $scope.container = container; + return ImageService.image(container.Image); + }) + .then(function success(data) { + var image = data; + var containerLabels = $scope.container.Config.Labels; + $scope.imageOS = image.Os; + $scope.formValues.command = image.Os === 'windows' ? 'powershell' : 'bash'; + $scope.containerCommands = Object.keys(containerLabels) + .filter(function (label) { + return label.indexOf(CONSOLE_COMMANDS_LABEL_PREFIX) === 0; + }) + .map(function (label) { + return { + title: label.replace(CONSOLE_COMMANDS_LABEL_PREFIX, ''), + command: containerLabels[label] + }; + }); + $scope.loaded = true; + }) + .catch(function error(err) { + Notifications.error('Error', err, 'Unable to retrieve container details'); + }); + } + }]); diff --git a/app/docker/views/containers/console/containerconsole.html b/app/docker/views/containers/console/exec.html similarity index 80% rename from app/docker/views/containers/console/containerconsole.html rename to app/docker/views/containers/console/exec.html index e938c49c0..31594f1c6 100644 --- a/app/docker/views/containers/console/containerconsole.html +++ b/app/docker/views/containers/console/exec.html @@ -1,17 +1,17 @@ - + Containers > {{ container.Name|trimcontainername }} > Console -
+
- +
-
+
@@ -51,16 +51,22 @@
- + + + The container is not running. +
-
+
- +
diff --git a/app/docker/views/containers/edit/container.html b/app/docker/views/containers/edit/container.html index f9160fa51..829ad5e0c 100644 --- a/app/docker/views/containers/edit/container.html +++ b/app/docker/views/containers/edit/container.html @@ -87,7 +87,8 @@ Logs Inspect Stats - Console + Exec + Attach
diff --git a/assets/css/app.css b/assets/css/app.css index e6f40c89e..fe5ad069e 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -232,7 +232,7 @@ a[ng-click]{ .terminal-container { width: 100%; - padding: 10px 5px; + padding: 10px 0; } .interactive {