diff --git a/command/agent/alloc_endpoint.go b/command/agent/alloc_endpoint.go index 839022a54..42d2d761d 100644 --- a/command/agent/alloc_endpoint.go +++ b/command/agent/alloc_endpoint.go @@ -398,9 +398,48 @@ func (s *HTTPServer) allocExec(allocID string, resp http.ResponseWriter, req *ht return nil, fmt.Errorf("failed to upgrade connection: %v", err) } + if err := readWsHandshake(conn.ReadJSON, req, &args.QueryOptions); err != nil { + conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(toWsCode(400), err.Error())) + return nil, err + } + return s.execStreamImpl(conn, &args) } +// readWsHandshake reads the websocket handshake message and sets +// query authentication token, if request requires a handshake +func readWsHandshake(readFn func(interface{}) error, req *http.Request, q *structs.QueryOptions) error { + + // Avoid handshake if request doesn't require one + if hv := req.URL.Query().Get("ws_handshake"); hv == "" { + return nil + } else if h, err := strconv.ParseBool(hv); err != nil { + return fmt.Errorf("ws_handshake value is not a boolean: %v", err) + } else if !h { + return nil + } + + var h wsHandshakeMessage + err := readFn(&h) + if err != nil { + return err + } + + supportedWSHandshakeVersion := 1 + if h.Version != supportedWSHandshakeVersion { + return fmt.Errorf("unexpected handshake value: %v", h.Version) + } + + q.AuthToken = h.AuthToken + return nil +} + +type wsHandshakeMessage struct { + Version int `json:"version"` + AuthToken string `json:"auth_token"` +} + func (s *HTTPServer) execStreamImpl(ws *websocket.Conn, args *cstructs.AllocExecRequest) (interface{}, error) { allocID := args.AllocID method := "Allocations.Exec" diff --git a/command/agent/alloc_endpoint_test.go b/command/agent/alloc_endpoint_test.go index c8092b000..4bdf4fee0 100644 --- a/command/agent/alloc_endpoint_test.go +++ b/command/agent/alloc_endpoint_test.go @@ -959,3 +959,57 @@ func TestHTTP_AllocAllGC_ACL(t *testing.T) { } }) } + +func TestHTTP_ReadWsHandshake(t *testing.T) { + cases := []struct { + name string + token string + handshake bool + }{ + { + name: "plain compatible mode", + token: "", + handshake: false, + }, + { + name: "handshake unauthenticated", + token: "", + handshake: true, + }, + { + name: "handshake authenticated", + token: "mysupersecret", + handshake: true, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + + called := false + readFn := func(h interface{}) error { + called = true + if !c.handshake { + return fmt.Errorf("should not be called") + } + + hm := h.(*wsHandshakeMessage) + hm.Version = 1 + hm.AuthToken = c.token + return nil + } + + req := httptest.NewRequest("PUT", "/target", nil) + if c.handshake { + req.URL.RawQuery = "ws_handshake=true" + } + + var q structs.QueryOptions + + err := readWsHandshake(readFn, req, &q) + require.NoError(t, err) + require.Equal(t, c.token, q.AuthToken) + require.Equal(t, c.handshake, called) + }) + } +} diff --git a/ui/app/controllers/exec.js b/ui/app/controllers/exec.js index c3aeee927..9de523987 100644 --- a/ui/app/controllers/exec.js +++ b/ui/app/controllers/exec.js @@ -14,6 +14,7 @@ const ANSI_WHITE = '\x1b[0m'; export default Controller.extend({ sockets: service(), system: service(), + token: service(), queryParams: ['allocation'], @@ -78,6 +79,6 @@ export default Controller.extend({ this.set('command', command); this.socket = this.sockets.getTaskStateSocket(this.taskState, command); - new ExecSocketXtermAdapter(this.terminal, this.socket); + new ExecSocketXtermAdapter(this.terminal, this.socket, this.token.secret); }, }); diff --git a/ui/app/services/sockets.js b/ui/app/services/sockets.js index 3d18a5e39..b629aa077 100644 --- a/ui/app/services/sockets.js +++ b/ui/app/services/sockets.js @@ -30,7 +30,7 @@ export default Service.extend({ return new WebSocket( `${protocol}//${prefix}/client/allocation/${taskState.allocation.id}` + - `/exec?task=${taskState.name}&tty=true` + + `/exec?task=${taskState.name}&tty=true&ws_handshake=true` + `&command=${encodeURIComponent(`["${command}"]`)}` ); } diff --git a/ui/app/utils/classes/exec-socket-xterm-adapter.js b/ui/app/utils/classes/exec-socket-xterm-adapter.js index 82141150c..c4a3dc2d9 100644 --- a/ui/app/utils/classes/exec-socket-xterm-adapter.js +++ b/ui/app/utils/classes/exec-socket-xterm-adapter.js @@ -4,11 +4,13 @@ import base64js from 'base64-js'; import { TextDecoderLite, TextEncoderLite } from 'text-encoder-lite'; export default class ExecSocketXtermAdapter { - constructor(terminal, socket) { + constructor(terminal, socket, token) { this.terminal = terminal; this.socket = socket; + this.token = token; socket.onopen = () => { + this.sendWsHandshake(); this.sendTtySize(); terminal.onData(data => { @@ -43,6 +45,10 @@ export default class ExecSocketXtermAdapter { ); } + sendWsHandshake() { + this.socket.send(JSON.stringify({ version: 1, auth_token: this.token || '' })); + } + handleData(data) { this.socket.send(JSON.stringify({ stdin: { data: encodeString(data) } })); } diff --git a/ui/tests/acceptance/exec-test.js b/ui/tests/acceptance/exec-test.js index d5b7a7650..3517e2a93 100644 --- a/ui/tests/acceptance/exec-test.js +++ b/ui/tests/acceptance/exec-test.js @@ -11,7 +11,8 @@ module('Acceptance | exec', function(hooks) { setupMirage(hooks); hooks.beforeEach(async function() { - window.localStorage.removeItem('nomadExecCommand'); + window.localStorage.clear(); + window.sessionStorage.clear(); server.create('agent'); server.create('node'); @@ -261,6 +262,7 @@ module('Acceptance | exec', function(hooks) { await settled(); assert.deepEqual(mockSocket.sent, [ + '{"version":1,"auth_token":""}', `{"tty_size":{"width":${window.execTerminal.cols},"height":${window.execTerminal.rows}}}`, '{"stdin":{"data":"DQ=="}}', ]); @@ -277,6 +279,44 @@ module('Acceptance | exec', function(hooks) { ); }); + test('the opening message includes the token if it exists', async function(assert) { + const { secretId } = server.create('token'); + window.localStorage.nomadTokenSecret = secretId; + + let mockSocket = new MockSocket(); + let mockSockets = Service.extend({ + getTaskStateSocket() { + return mockSocket; + }, + }); + + this.owner.register('service:sockets', mockSockets); + + let taskGroup = this.job.task_groups.models[0]; + let task = taskGroup.tasks.models[0]; + let allocations = this.server.db.allocations.where({ + jobId: this.job.id, + taskGroup: taskGroup.name, + }); + let allocation = allocations[allocations.length - 1]; + + await Exec.visitTask({ + job: this.job.id, + task_group: taskGroup.name, + task_name: task.name, + allocation: allocation.id.split('-')[0], + }); + + await Exec.terminal.pressEnter(); + await settled(); + mockSocket.onopen(); + + await Exec.terminal.pressEnter(); + await settled(); + + assert.equal(mockSocket.sent[0], `{"version":1,"auth_token":"${secretId}"}`); + }); + test('only one socket is opened after switching between tasks', async function(assert) { let mockSockets = Service.extend({ getTaskStateSocket() { @@ -308,7 +348,7 @@ module('Acceptance | exec', function(hooks) { let mockSockets = Service.extend({ getTaskStateSocket(taskState, command) { assert.equal(command, '/sh'); - localStorage.getItem('nomadExecCommand', JSON.stringify('/sh')); + window.localStorage.getItem('nomadExecCommand', JSON.stringify('/sh')); assert.step('Socket built'); @@ -366,7 +406,7 @@ module('Acceptance | exec', function(hooks) { }); test('a persisted customised command is recalled', async function(assert) { - localStorage.setItem('nomadExecCommand', JSON.stringify('/bin/sh')); + window.localStorage.setItem('nomadExecCommand', JSON.stringify('/bin/sh')); let taskGroup = this.job.task_groups.models[0]; let task = taskGroup.tasks.models[0]; diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js index 19e4d3870..c66c8f8e7 100644 --- a/ui/tests/acceptance/token-test.js +++ b/ui/tests/acceptance/token-test.js @@ -17,8 +17,8 @@ module('Acceptance | tokens', function(hooks) { setupMirage(hooks); hooks.beforeEach(function() { - localStorage.clear(); - sessionStorage.clear(); + window.localStorage.clear(); + window.sessionStorage.clear(); server.create('agent'); node = server.create('node'); diff --git a/ui/tests/integration/util/exec-socket-xterm-adapter-test.js b/ui/tests/integration/util/exec-socket-xterm-adapter-test.js index 7ecbf8eb0..c303d98ec 100644 --- a/ui/tests/integration/util/exec-socket-xterm-adapter-test.js +++ b/ui/tests/integration/util/exec-socket-xterm-adapter-test.js @@ -8,6 +8,66 @@ import { Terminal } from 'xterm-vendor'; module('Integration | Utility | exec-socket-xterm-adapter', function(hooks) { setupRenderingTest(hooks); + test('initiating socket sends authentication handshake', async function(assert) { + let done = assert.async(); + + let terminal = new Terminal(); + this.set('terminal', terminal); + + await render(hbs` + {{exec-terminal terminal=terminal}} + `); + + await settled(); + + let firstMessage = true; + let mockSocket = new Object({ + send(message) { + if (firstMessage) { + firstMessage = false; + assert.deepEqual(message, JSON.stringify({ version: 1, auth_token: 'mysecrettoken' })); + done(); + } + }, + }); + + new ExecSocketXtermAdapter(terminal, mockSocket, 'mysecrettoken'); + + mockSocket.onopen(); + + await settled(); + }); + + test('initiating socket sends authentication handshake even if unauthenticated', async function(assert) { + let done = assert.async(); + + let terminal = new Terminal(); + this.set('terminal', terminal); + + await render(hbs` + {{exec-terminal terminal=terminal}} + `); + + await settled(); + + let firstMessage = true; + let mockSocket = new Object({ + send(message) { + if (firstMessage) { + firstMessage = false; + assert.deepEqual(message, JSON.stringify({ version: 1, auth_token: '' })); + done(); + } + }, + }); + + new ExecSocketXtermAdapter(terminal, mockSocket, null); + + mockSocket.onopen(); + + await settled(); + }); + test('resizing the window passes a resize message through the socket', async function(assert) { let done = assert.async(); @@ -30,7 +90,7 @@ module('Integration | Utility | exec-socket-xterm-adapter', function(hooks) { }, }); - new ExecSocketXtermAdapter(terminal, mockSocket); + new ExecSocketXtermAdapter(terminal, mockSocket, ''); window.dispatchEvent(new Event('resize')); @@ -53,7 +113,7 @@ module('Integration | Utility | exec-socket-xterm-adapter', function(hooks) { send() {}, }); - new ExecSocketXtermAdapter(terminal, mockSocket); + new ExecSocketXtermAdapter(terminal, mockSocket, ''); mockSocket.onmessage({ data: '{"stdout":{"exited":"true"}}', @@ -76,7 +136,7 @@ module('Integration | Utility | exec-socket-xterm-adapter', function(hooks) { send() {}, }); - new ExecSocketXtermAdapter(terminal, mockSocket); + new ExecSocketXtermAdapter(terminal, mockSocket, ''); mockSocket.onmessage({ data: '{"stdout":{"data":"c2gtMy4yIPCfpbMk"}}',