mirror of
https://github.com/kemko/nomad.git
synced 2026-01-05 18:05:42 +03:00
Merge pull request #7612 from hashicorp/b-auth-alloc-exec-ws
Authenticate alloc/exec websocket requests
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}"]`)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) } }));
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"}}',
|
||||
|
||||
Reference in New Issue
Block a user