Merge pull request #7612 from hashicorp/b-auth-alloc-exec-ws

Authenticate alloc/exec websocket requests
This commit is contained in:
Mahmood Ali
2020-04-06 09:24:51 -04:00
committed by GitHub
8 changed files with 211 additions and 11 deletions

View File

@@ -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"

View File

@@ -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)
})
}
}

View File

@@ -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);
},
});

View File

@@ -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}"]`)}`
);
}

View File

@@ -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) } }));
}

View File

@@ -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];

View File

@@ -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');

View File

@@ -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"}}',