/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
var Guacamole = Guacamole || {};
/**
* Core object providing abstract communication for Guacamole. This object
* is a null implementation whose functions do nothing. Guacamole applications
* should use {@link Guacamole.HTTPTunnel} instead, or implement their own tunnel based
* on this one.
*
* @constructor
* @see Guacamole.HTTPTunnel
*/
Guacamole.Tunnel = function() {
/**
* Connect to the tunnel with the given optional data. This data is
* typically used for authentication. The format of data accepted is
* up to the tunnel implementation.
*
* @param {String} data The data to send to the tunnel when connecting.
*/
this.connect = function(data) {};
/**
* Disconnect from the tunnel.
*/
this.disconnect = function() {};
/**
* Send the given message through the tunnel to the service on the other
* side. All messages are guaranteed to be received in the order sent.
*
* @param {...*} elements
* The elements of the message to send to the service on the other side
* of the tunnel.
*/
this.sendMessage = function(elements) {};
/**
* The current state of this tunnel.
*
* @type {Number}
*/
this.state = Guacamole.Tunnel.State.CONNECTING;
/**
* The maximum amount of time to wait for data to be received, in
* milliseconds. If data is not received within this amount of time,
* the tunnel is closed with an error. The default value is 15000.
*
* @type {Number}
*/
this.receiveTimeout = 15000;
/**
* The UUID uniquely identifying this tunnel. If not yet known, this will
* be null.
*
* @type {String}
*/
this.uuid = null;
/**
* Fired whenever an error is encountered by the tunnel.
*
* @event
* @param {Guacamole.Status} status A status object which describes the
* error.
*/
this.onerror = null;
/**
* Fired whenever the state of the tunnel changes.
*
* @event
* @param {Number} state The new state of the client.
*/
this.onstatechange = null;
/**
* Fired once for every complete Guacamole instruction received, in order.
*
* @event
* @param {String} opcode The Guacamole instruction opcode.
* @param {Array} parameters The parameters provided for the instruction,
* if any.
*/
this.oninstruction = null;
};
/**
* The Guacamole protocol instruction opcode reserved for arbitrary internal
* use by tunnel implementations. The value of this opcode is guaranteed to be
* the empty string (""). Tunnel implementations may use this opcode for any
* purpose. It is currently used by the HTTP tunnel to mark the end of the HTTP
* response, and by the WebSocket tunnel to transmit the tunnel UUID.
*
* @constant
* @type {String}
*/
Guacamole.Tunnel.INTERNAL_DATA_OPCODE = '';
/**
* All possible tunnel states.
*/
Guacamole.Tunnel.State = {
/**
* A connection is in pending. It is not yet known whether connection was
* successful.
*
* @type {Number}
*/
"CONNECTING": 0,
/**
* Connection was successful, and data is being received.
*
* @type {Number}
*/
"OPEN": 1,
/**
* The connection is closed. Connection may not have been successful, the
* tunnel may have been explicitly closed by either side, or an error may
* have occurred.
*
* @type {Number}
*/
"CLOSED": 2
};
/**
* Guacamole Tunnel implemented over HTTP via XMLHttpRequest.
*
* @constructor
* @augments Guacamole.Tunnel
*
* @param {String} tunnelURL
* The URL of the HTTP tunneling service.
*
* @param {Boolean} [crossDomain=false]
* Whether tunnel requests will be cross-domain, and thus must use CORS
* mechanisms and headers. By default, it is assumed that tunnel requests
* will be made to the same domain.
*/
Guacamole.HTTPTunnel = function(tunnelURL, crossDomain) {
/**
* Reference to this HTTP tunnel.
* @private
*/
var tunnel = this;
var TUNNEL_CONNECT = tunnelURL + "?connect";
var TUNNEL_READ = tunnelURL + "?read:";
var TUNNEL_WRITE = tunnelURL + "?write:";
var POLLING_ENABLED = 1;
var POLLING_DISABLED = 0;
// Default to polling - will be turned off automatically if not needed
var pollingMode = POLLING_ENABLED;
var sendingMessages = false;
var outputMessageBuffer = "";
// If requests are expected to be cross-domain, the cookie that the HTTP
// tunnel depends on will only be sent if withCredentials is true
var withCredentials = !!crossDomain;
/**
* The current receive timeout ID, if any.
* @private
*/
var receive_timeout = null;
/**
* Initiates a timeout which, if data is not received, causes the tunnel
* to close with an error.
*
* @private
*/
function reset_timeout() {
// Get rid of old timeout (if any)
window.clearTimeout(receive_timeout);
// Set new timeout
receive_timeout = window.setTimeout(function () {
close_tunnel(new Guacamole.Status(Guacamole.Status.Code.UPSTREAM_TIMEOUT, "Server timeout."));
}, tunnel.receiveTimeout);
}
/**
* Closes this tunnel, signaling the given status and corresponding
* message, which will be sent to the onerror handler if the status is
* an error status.
*
* @private
* @param {Guacamole.Status} status The status causing the connection to
* close;
*/
function close_tunnel(status) {
// Ignore if already closed
if (tunnel.state === Guacamole.Tunnel.State.CLOSED)
return;
// If connection closed abnormally, signal error.
if (status.code !== Guacamole.Status.Code.SUCCESS && tunnel.onerror) {
// Ignore RESOURCE_NOT_FOUND if we've already connected, as that
// only signals end-of-stream for the HTTP tunnel.
if (tunnel.state === Guacamole.Tunnel.State.CONNECTING
|| status.code !== Guacamole.Status.Code.RESOURCE_NOT_FOUND)
tunnel.onerror(status);
}
// Mark as closed
tunnel.state = Guacamole.Tunnel.State.CLOSED;
// Reset output message buffer
sendingMessages = false;
if (tunnel.onstatechange)
tunnel.onstatechange(tunnel.state);
}
this.sendMessage = function() {
// Do not attempt to send messages if not connected
if (tunnel.state !== Guacamole.Tunnel.State.OPEN)
return;
// Do not attempt to send empty messages
if (arguments.length === 0)
return;
/**
* Converts the given value to a length/string pair for use as an
* element in a Guacamole instruction.
*
* @private
* @param value The value to convert.
* @return {String} The converted value.
*/
function getElement(value) {
var string = new String(value);
return string.length + "." + string;
}
// Initialized message with first element
var message = getElement(arguments[0]);
// Append remaining elements
for (var i=1; i<arguments.length; i++)
message += "," + getElement(arguments[i]);
// Final terminator
message += ";";
// Add message to buffer
outputMessageBuffer += message;
// Send if not currently sending
if (!sendingMessages)
sendPendingMessages();
};
function sendPendingMessages() {
// Do not attempt to send messages if not connected
if (tunnel.state !== Guacamole.Tunnel.State.OPEN)
return;
if (outputMessageBuffer.length > 0) {
sendingMessages = true;
var message_xmlhttprequest = new XMLHttpRequest();
message_xmlhttprequest.open("POST", TUNNEL_WRITE + tunnel.uuid);
message_xmlhttprequest.withCredentials = withCredentials;
message_xmlhttprequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
// Once response received, send next queued event.
message_xmlhttprequest.onreadystatechange = function() {
if (message_xmlhttprequest.readyState === 4) {
// If an error occurs during send, handle it
if (message_xmlhttprequest.status !== 200)
handleHTTPTunnelError(message_xmlhttprequest);
// Otherwise, continue the send loop
else
sendPendingMessages();
}
};
message_xmlhttprequest.send(outputMessageBuffer);
outputMessageBuffer = ""; // Clear buffer
}
else
sendingMessages = false;
}
function handleHTTPTunnelError(xmlhttprequest) {
var code = parseInt(xmlhttprequest.getResponseHeader("Guacamole-Status-Code"));
var message = xmlhttprequest.getResponseHeader("Guacamole-Error-Message");
close_tunnel(new Guacamole.Status(code, message));
}
function handleResponse(xmlhttprequest) {
var interval = null;
var nextRequest = null;
var dataUpdateEvents = 0;
// The location of the last element's terminator
var elementEnd = -1;
// Where to start the next length search or the next element
var startIndex = 0;
// Parsed elements
var elements = new Array();
function parseResponse() {
// Do not handle responses if not connected
if (tunnel.state !== Guacamole.Tunnel.State.OPEN) {
// Clean up interval if polling
if (interval !== null)
clearInterval(interval);
return;
}
// Do not parse response yet if not ready
if (xmlhttprequest.readyState < 2) return;
// Attempt to read status
var status;
try { status = xmlhttprequest.status; }
// If status could not be read, assume successful.
catch (e) { status = 200; }
// Start next request as soon as possible IF request was successful
if (!nextRequest && status === 200)
nextRequest = makeRequest();
// Parse stream when data is received and when complete.
if (xmlhttprequest.readyState === 3 ||
xmlhttprequest.readyState === 4) {
reset_timeout();
// Also poll every 30ms (some browsers don't repeatedly call onreadystatechange for new data)
if (pollingMode === POLLING_ENABLED) {
if (xmlhttprequest.readyState === 3 && !interval)
interval = setInterval(parseResponse, 30);
else if (xmlhttprequest.readyState === 4 && !interval)
clearInterval(interval);
}
// If canceled, stop transfer
if (xmlhttprequest.status === 0) {
tunnel.disconnect();
return;
}
// Halt on error during request
else if (xmlhttprequest.status !== 200) {
handleHTTPTunnelError(xmlhttprequest);
return;
}
// Attempt to read in-progress data
var current;
try { current = xmlhttprequest.responseText; }
// Do not attempt to parse if data could not be read
catch (e) { return; }
// While search is within currently received data
while (elementEnd < current.length) {
// If we are waiting for element data
if (elementEnd >= startIndex) {
// We now have enough data for the element. Parse.
var element = current.substring(startIndex, elementEnd);
var terminator = current.substring(elementEnd, elementEnd+1);
// Add element to array
elements.push(element);
// If last element, handle instruction
if (terminator === ";") {
// Get opcode
var opcode = elements.shift();
// Call instruction handler.
if (tunnel.oninstruction)
tunnel.oninstruction(opcode, elements);
// Clear elements
elements.length = 0;
}
// Start searching for length at character after
// element terminator
startIndex = elementEnd + 1;
}
// Search for end of length
var lengthEnd = current.indexOf(".", startIndex);
if (lengthEnd !== -1) {
// Parse length
var length = parseInt(current.substring(elementEnd+1, lengthEnd));
// If we're done parsing, handle the next response.
if (length === 0) {
// Clean up interval if polling
if (!interval)
clearInterval(interval);
// Clean up object
xmlhttprequest.onreadystatechange = null;
xmlhttprequest.abort();
// Start handling next request
if (nextRequest)
handleResponse(nextRequest);
// Done parsing
break;
}
// Calculate start of element
startIndex = lengthEnd + 1;
// Calculate location of element terminator
elementEnd = startIndex + length;
}
// If no period yet, continue search when more data
// is received
else {
startIndex = current.length;
break;
}
} // end parse loop
}
}
// If response polling enabled, attempt to detect if still
// necessary (via wrapping parseResponse())
if (pollingMode === POLLING_ENABLED) {
xmlhttprequest.onreadystatechange = function() {
// If we receive two or more readyState==3 events,
// there is no need to poll.
if (xmlhttprequest.readyState === 3) {
dataUpdateEvents++;
if (dataUpdateEvents >= 2) {
pollingMode = POLLING_DISABLED;
xmlhttprequest.onreadystatechange = parseResponse;
}
}
parseResponse();
};
}
// Otherwise, just parse
else
xmlhttprequest.onreadystatechange = parseResponse;
parseResponse();
}
/**
* Arbitrary integer, unique for each tunnel read request.
* @private
*/
var request_id = 0;
function makeRequest() {
// Make request, increment request ID
var xmlhttprequest = new XMLHttpRequest();
xmlhttprequest.open("GET", TUNNEL_READ + tunnel.uuid + ":" + (request_id++));
xmlhttprequest.withCredentials = withCredentials;
xmlhttprequest.send(null);
return xmlhttprequest;
}
this.connect = function(data) {
// Start waiting for connect
reset_timeout();
// Start tunnel and connect
var connect_xmlhttprequest = new XMLHttpRequest();
connect_xmlhttprequest.onreadystatechange = function() {
if (connect_xmlhttprequest.readyState !== 4)
return;
// If failure, throw error
if (connect_xmlhttprequest.status !== 200) {
handleHTTPTunnelError(connect_xmlhttprequest);
return;
}
reset_timeout();
// Get UUID from response
tunnel.uuid = connect_xmlhttprequest.responseText;
tunnel.state = Guacamole.Tunnel.State.OPEN;
if (tunnel.onstatechange)
tunnel.onstatechange(tunnel.state);
// Start reading data
handleResponse(makeRequest());
};
connect_xmlhttprequest.open("POST", TUNNEL_CONNECT, true);
connect_xmlhttprequest.withCredentials = withCredentials;
connect_xmlhttprequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
connect_xmlhttprequest.send(data);
};
this.disconnect = function() {
close_tunnel(new Guacamole.Status(Guacamole.Status.Code.SUCCESS, "Manually closed."));
};
};
Guacamole.HTTPTunnel.prototype = new Guacamole.Tunnel();
/**
* Guacamole Tunnel implemented over WebSocket via XMLHttpRequest.
*
* @constructor
* @augments Guacamole.Tunnel
* @param {String} tunnelURL The URL of the WebSocket tunneling service.
*/
Guacamole.WebSocketTunnel = function(tunnelURL) {
/**
* Reference to this WebSocket tunnel.
* @private
*/
var tunnel = this;
/**
* The WebSocket used by this tunnel.
* @private
*/
var socket = null;
/**
* The current receive timeout ID, if any.
* @private
*/
var receive_timeout = null;
/**
* The WebSocket protocol corresponding to the protocol used for the current
* location.
* @private
*/
var ws_protocol = {
"http:": "ws:",
"https:": "wss:"
};
// Transform current URL to WebSocket URL
// If not already a websocket URL
if ( tunnelURL.substring(0, 3) !== "ws:"
&& tunnelURL.substring(0, 4) !== "wss:") {
var protocol = ws_protocol[window.location.protocol];
// If absolute URL, convert to absolute WS URL
if (tunnelURL.substring(0, 1) === "/")
tunnelURL =
protocol
+ "//" + window.location.host
+ tunnelURL;
// Otherwise, construct absolute from relative URL
else {
// Get path from pathname
var slash = window.location.pathname.lastIndexOf("/");
var path = window.location.pathname.substring(0, slash + 1);
// Construct absolute URL
tunnelURL =
protocol
+ "//" + window.location.host
+ path
+ tunnelURL;
}
}
/**
* Initiates a timeout which, if data is not received, causes the tunnel
* to close with an error.
*
* @private
*/
function reset_timeout() {
// Get rid of old timeout (if any)
window.clearTimeout(receive_timeout);
// Set new timeout
receive_timeout = window.setTimeout(function () {
close_tunnel(new Guacamole.Status(Guacamole.Status.Code.UPSTREAM_TIMEOUT, "Server timeout."));
}, tunnel.receiveTimeout);
}
/**
* Closes this tunnel, signaling the given status and corresponding
* message, which will be sent to the onerror handler if the status is
* an error status.
*
* @private
* @param {Guacamole.Status} status The status causing the connection to
* close;
*/
function close_tunnel(status) {
// Ignore if already closed
if (tunnel.state === Guacamole.Tunnel.State.CLOSED)
return;
// If connection closed abnormally, signal error.
if (status.code !== Guacamole.Status.Code.SUCCESS && tunnel.onerror)
tunnel.onerror(status);
// Mark as closed
tunnel.state = Guacamole.Tunnel.State.CLOSED;
if (tunnel.onstatechange)
tunnel.onstatechange(tunnel.state);
socket.close();
}
this.sendMessage = function(elements) {
// Do not attempt to send messages if not connected
if (tunnel.state !== Guacamole.Tunnel.State.OPEN)
return;
// Do not attempt to send empty messages
if (arguments.length === 0)
return;
/**
* Converts the given value to a length/string pair for use as an
* element in a Guacamole instruction.
*
* @private
* @param value The value to convert.
* @return {String} The converted value.
*/
function getElement(value) {
var string = new String(value);
return string.length + "." + string;
}
// Initialized message with first element
var message = getElement(arguments[0]);
// Append remaining elements
for (var i=1; i<arguments.length; i++)
message += "," + getElement(arguments[i]);
// Final terminator
message += ";";
socket.send(message);
};
this.connect = function(data) {
reset_timeout();
// Connect socket
socket = new WebSocket(tunnelURL + "?" + data, "guacamole");
socket.onopen = function(event) {
reset_timeout();
};
socket.onclose = function(event) {
close_tunnel(new Guacamole.Status(parseInt(event.reason), event.reason));
};
socket.onerror = function(event) {
close_tunnel(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, event.data));
};
socket.onmessage = function(event) {
reset_timeout();
var message = event.data;
var startIndex = 0;
var elementEnd;
var elements = [];
do {
// Search for end of length
var lengthEnd = message.indexOf(".", startIndex);
if (lengthEnd !== -1) {
// Parse length
var length = parseInt(message.substring(elementEnd+1, lengthEnd));
// Calculate start of element
startIndex = lengthEnd + 1;
// Calculate location of element terminator
elementEnd = startIndex + length;
}
// If no period, incomplete instruction.
else
close_tunnel(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, "Incomplete instruction."));
// We now have enough data for the element. Parse.
var element = message.substring(startIndex, elementEnd);
var terminator = message.substring(elementEnd, elementEnd+1);
// Add element to array
elements.push(element);
// If last element, handle instruction
if (terminator === ";") {
// Get opcode
var opcode = elements.shift();
// Update state and UUID when first instruction received
if (tunnel.state !== Guacamole.Tunnel.State.OPEN) {
// Associate tunnel UUID if received
if (opcode === Guacamole.Tunnel.INTERNAL_DATA_OPCODE)
tunnel.uuid = elements[0];
// Tunnel is now open and UUID is available
tunnel.state = Guacamole.Tunnel.State.OPEN;
if (tunnel.onstatechange)
tunnel.onstatechange(tunnel.state);
}
// Call instruction handler.
if (opcode !== Guacamole.Tunnel.INTERNAL_DATA_OPCODE && tunnel.oninstruction)
tunnel.oninstruction(opcode, elements);
// Clear elements
elements.length = 0;
}
// Start searching for length at character after
// element terminator
startIndex = elementEnd + 1;
} while (startIndex < message.length);
};
};
this.disconnect = function() {
close_tunnel(new Guacamole.Status(Guacamole.Status.Code.SUCCESS, "Manually closed."));
};
};
Guacamole.WebSocketTunnel.prototype = new Guacamole.Tunnel();
/**
* Guacamole Tunnel which cycles between all specified tunnels until
* no tunnels are left. Another tunnel is used if an error occurs but
* no instructions have been received. If an instruction has been
* received, or no tunnels remain, the error is passed directly out
* through the onerror handler (if defined).
*
* @constructor
* @augments Guacamole.Tunnel
* @param {...*} tunnelChain
* The tunnels to use, in order of priority.
*/
Guacamole.ChainedTunnel = function(tunnelChain) {
/**
* Reference to this chained tunnel.
* @private
*/
var chained_tunnel = this;
/**
* Data passed in via connect(), to be used for
* wrapped calls to other tunnels' connect() functions.
* @private
*/
var connect_data;
/**
* Array of all tunnels passed to this ChainedTunnel through the
* constructor arguments.
* @private
*/
var tunnels = [];
/**
* The tunnel committed via commit_tunnel(), if any, or null if no tunnel
* has yet been committed.
*
* @private
* @type {Guacamole.Tunnel}
*/
var committedTunnel = null;
// Load all tunnels into array
for (var i=0; i<arguments.length; i++)
tunnels.push(arguments[i]);
/**
* Sets the current tunnel.
*
* @private
* @param {Guacamole.Tunnel} tunnel The tunnel to set as the current tunnel.
*/
function attach(tunnel) {
// Set own functions to tunnel's functions
chained_tunnel.disconnect = tunnel.disconnect;
chained_tunnel.sendMessage = tunnel.sendMessage;
/**
* Fails the currently-attached tunnel, attaching a new tunnel if
* possible.
*
* @private
* @param {Guacamole.Status} [status]
* An object representing the failure that occured in the
* currently-attached tunnel, if known.
*
* @return {Guacamole.Tunnel}
* The next tunnel, or null if there are no more tunnels to try or
* if no more tunnels should be tried.
*/
var failTunnel = function failTunnel(status) {
// Do not attempt to continue using next tunnel on server timeout
if (status && status.code === Guacamole.Status.Code.UPSTREAM_TIMEOUT) {
tunnels = [];
return null;
}
// Get next tunnel
var next_tunnel = tunnels.shift();
// If there IS a next tunnel, try using it.
if (next_tunnel) {
tunnel.onerror = null;
tunnel.oninstruction = null;
tunnel.onstatechange = null;
attach(next_tunnel);
}
return next_tunnel;
};
/**
* Use the current tunnel from this point forward. Do not try any more
* tunnels, even if the current tunnel fails.
*
* @private
*/
function commit_tunnel() {
tunnel.onstatechange = chained_tunnel.onstatechange;
tunnel.oninstruction = chained_tunnel.oninstruction;
tunnel.onerror = chained_tunnel.onerror;
chained_tunnel.uuid = tunnel.uuid;
committedTunnel = tunnel;
}
// Wrap own onstatechange within current tunnel
tunnel.onstatechange = function(state) {
switch (state) {
// If open, use this tunnel from this point forward.
case Guacamole.Tunnel.State.OPEN:
commit_tunnel();
if (chained_tunnel.onstatechange)
chained_tunnel.onstatechange(state);
break;
// If closed, mark failure, attempt next tunnel
case Guacamole.Tunnel.State.CLOSED:
if (!failTunnel() && chained_tunnel.onstatechange)
chained_tunnel.onstatechange(state);
break;
}
};
// Wrap own oninstruction within current tunnel
tunnel.oninstruction = function(opcode, elements) {
// Accept current tunnel
commit_tunnel();
// Invoke handler
if (chained_tunnel.oninstruction)
chained_tunnel.oninstruction(opcode, elements);
};
// Attach next tunnel on error
tunnel.onerror = function(status) {
// Mark failure, attempt next tunnel
if (!failTunnel(status) && chained_tunnel.onerror)
chained_tunnel.onerror(status);
};
// Attempt connection
tunnel.connect(connect_data);
}
this.connect = function(data) {
// Remember connect data
connect_data = data;
// Get committed tunnel if exists or the first tunnel on the list
var next_tunnel = committedTunnel ? committedTunnel : tunnels.shift();
// Attach first tunnel
if (next_tunnel)
attach(next_tunnel);
// If there IS no first tunnel, error
else if (chained_tunnel.onerror)
chained_tunnel.onerror(Guacamole.Status.Code.SERVER_ERROR, "No tunnels to try.");
};
};
Guacamole.ChainedTunnel.prototype = new Guacamole.Tunnel();